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
-
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.
-
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.
-
Persist the surface-local key as an alias on the lifecycle record so
writeback still knows which surface to reconcile to.
-
Delete matchingGithubLifecycleEntry and the claim.key ?? key fallback.
-
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.
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.
issue.pathis the Relayfile sense path — see the fixture insrc/state/file-state-store.test.ts:289:AR-9387:uuid-9387:/linear/issues/AR-9387__uuid-9387.json. An issue arrivingGitHub-native carries a different
key,uuid, andpath, so it hashes to adifferent 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 withfencing epochs, mutex-guarded in
FileStateStoreand Durable-Object-backedin
src/hosted/state-store.ts.#claimDispatchLifecycle(src/orchestrator/factory.ts:3174) already failsclosed — it throws
Refusing to dispatch ${key}: ...when the claim is notacquired.
The defect is the key, not the locking.
Existing partial mitigation
matchingGithubLifecycleEntry(lifecycles, seed)insrc/state/in-memory-state-store.tsscans open lifecycles for an aliasingGitHub entry when the direct key misses, and
claim.key ?? keythreads theresolved key back. That covers exactly one surface pairing, costs O(open
lifecycles) per claim, and exists only to compensate for the bad key.
Fix
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.Key
claimDispatchLifecycleon that instead ofissueKey():Keep
issueKey()for display and log correlation only.Persist the surface-local key as an alias on the lifecycle record so
writeback still knows which surface to reconcile to.
Delete
matchingGithubLifecycleEntryand theclaim.key ?? keyfallback.Migration: on load, re-key existing lifecycles through
workUnitId. Oncollision, 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 startdaemon and the hosted Factorystill 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
issueSource: "github", once via a[factory]Linear mirror — and the secondattempt is refused with the existing "already owned" reason.
matchingGithubLifecycleEntryis gone.resolved as described.