Skip to content

[factory] Key dispatch claims on work-unit identity, not surface path #211

Description

@khaliqgant

Problem

Dispatch claims are keyed on a surface-local identifier, so the same work unit
reachable through two surfaces produces two independent claims and dispatches
twice.

src/orchestrator/batch-tracker.ts:284
export const issueKey = (issue: IssueRef): string => `${issue.key}:${issue.uuid}:${issue.path}`

issue.path is the Relayfile sense path — see the fixture in
src/state/file-state-store.test.ts:289:
AR-9387:uuid-9387:/linear/issues/AR-9387__uuid-9387.json. An issue arriving
GitHub-native carries a different key, uuid, and path, so it hashes to a
different claim key and the existing lease never sees a conflict.

This is the AR-448 duplicate-dispatch shape. The rule it produced is already
written down: a claim belongs to the work unit, not to a surface or a
dispatcher.

What is already correct

The claim machinery itself is sound and should not be rebuilt:

  • claimDispatchLifecycle (src/ports/state.ts:252) is a leased claim with
    fencing epochs, mutex-guarded in FileStateStore and Durable-Object-backed
    in src/hosted/state-store.ts.
  • #claimDispatchLifecycle (src/orchestrator/factory.ts:3174) already fails
    closed — it throws Refusing to dispatch ${key}: ... when the claim is not
    acquired.

The defect is the key, not the locking.

Existing partial mitigation

matchingGithubLifecycleEntry(lifecycles, seed) in
src/state/in-memory-state-store.ts scans open lifecycles for an aliasing
GitHub entry when the direct key misses, and claim.key ?? key threads the
resolved key back. That covers exactly one surface pairing, costs O(open
lifecycles) per claim, and exists only to compensate for the bad key.

Fix

  1. Add workUnitId(issue, contract) returning a canonical work-unit identity,
    resolved in priority order: the upstream repo + issue number when the
    surface exposes one, else the mirror's declared origin, else the
    surface-native key. Emit a stable string, e.g. gh:AgentWorkforce/pear#412.

  2. Key claimDispatchLifecycle on that instead of issueKey():

    const claim = await this.#state.claimDispatchLifecycle(
      this.#workspaceId,
      workUnitId(decision.issue, this.#contract),   // was: issueKey(decision.issue)
      seed, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS,
    )

    Keep issueKey() for display and log correlation only.

  3. Persist the surface-local key as an alias on the lifecycle record so
    writeback still knows which surface to reconcile to.

  4. Delete matchingGithubLifecycleEntry and the claim.key ?? key fallback.

  5. Migration: on load, re-key existing lifecycles through workUnitId. On
    collision, keep the entry holding the live lease and mark the other
    abandoned.

Explicitly out of scope

Cross-dispatcher visibility. This change fixes cross-surface collision
within a single store. A local factory start daemon and the hosted Factory
still keep claims in separate namespaces and cannot see each other. If both can
be live for one workspace, the claim has to move to the hosted store for both —
file that as a follow-up rather than widening this issue.

Definition of done

  • A test dispatches the same underlying GitHub issue twice — once via
    issueSource: "github", once via a [factory] Linear mirror — and the second
    attempt is refused with the existing "already owned" reason.
  • matchingGithubLifecycleEntry is gone.
  • Existing on-disk lifecycle state survives the re-key, with collisions
    resolved as described.
  • No change to lease, fencing, or fail-closed semantics.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions