Problem
A turn interrupted after dispatch begins is lost silently, and the receipt it leaves behind is never removed.
markDispatchStarted sets dispatchStartedAt before the first external side effect. If the process dies between that point and completeEvent / failEvent, the receipt stays status: "claimed" with the marker set. From there:
- Never replayed.
activeRecoverableReceipts (src/state/store.ts:2004) requires dispatchStartedAt === undefined, so startup recovery skips it. This is correct as designed: the turn may already have posted activities to Linear, and replaying it would double-run.
- Never removed.
prune (src/state/store.ts:1913) only removes receipts where isTerminal(status) holds, and claimed is not terminal. The maxEntries eviction also only considers terminal receipts, so the store can pass its cap and stay there. One entry accumulates per interrupted turn, permanently.
- Never reported. Nothing outside the store reads
dispatchStartedAt. /healthz returns a bare ok.
In Linear the session simply goes quiet. A queued session and an abandoned one look identical, which is the manual liveness check documented in CLAUDE.md. Observed on 2026-08-19 when the service was restarted with two turns active.
claimEvent already distinguishes these two cases (src/state/store.ts:622 and :657): a foreign claim with no dispatch marker transfers and re-runs, a foreign claim with the marker set is answered ambiguous and not dispatched. Both branches only fire when a redelivery arrives. Linear got its 200, so no redelivery is coming, and nothing else ever looks at the receipt.
Proposed change
A startup sweep that terminalizes abandoned dispatches and tells Linear the turn ended. Recovery semantics do not change: an interrupted turn is still never replayed.
Ownership. failEvent cannot be reused as-is. It routes through terminalize, which calls ownedActiveClaim and requires claim.ownerId === this.ownerId; ownerId defaults to a fresh randomUUID() per store instance, so a restarted process is never the owner of its predecessor's claim. This needs a distinct store method for foreign-owned stranded claims.
Liveness. Terminalizing any foreign-owned claim would let a second process abandon a first process's live turns if both ever shared a state directory. Persist process identity on the claim at markDispatchStarted, reusing the identity source behind the lock owner record (lockProcessIdentity / lockBootIdentity, checked the same way removeAbandonedLock does), and sweep only claims whose owning process is provably gone. A startup-only sweep guarded by a single-instance assumption was considered and rejected: the assumption is not enforced anywhere in code.
- Persist owning process identity on the claim when the dispatch marker is set.
- Add a store method that lists non-terminal receipts carrying
dispatchStartedAt whose owning process is gone, and one that marks them failed with the existing AmbiguousDispatch error class. Terminal status makes them prunable through the path that already exists.
- Run the sweep at startup before
recoverAcceptedIngressPass (src/server.ts:961). Log bounded identifiers and a count, never prompt or issue content.
- Post one activity per abandoned session saying the turn was interrupted and was not retried.
{ type: "error", body } matches how the server already reports terminal failure (src/server.ts:1711). Use getOrCreateActivityId so a sweep that itself crashes and re-runs does not post twice.
Acceptance criteria
Notes
Frequency is low, since it needs a crash or a restart mid-turn. The unbounded growth in the state file is what makes it worth fixing rather than tolerating.
Split out of #8, which is otherwise delivered.
Problem
A turn interrupted after dispatch begins is lost silently, and the receipt it leaves behind is never removed.
markDispatchStartedsetsdispatchStartedAtbefore the first external side effect. If the process dies between that point andcompleteEvent/failEvent, the receipt staysstatus: "claimed"with the marker set. From there:activeRecoverableReceipts(src/state/store.ts:2004) requiresdispatchStartedAt === undefined, so startup recovery skips it. This is correct as designed: the turn may already have posted activities to Linear, and replaying it would double-run.prune(src/state/store.ts:1913) only removes receipts whereisTerminal(status)holds, andclaimedis not terminal. ThemaxEntrieseviction also only considers terminal receipts, so the store can pass its cap and stay there. One entry accumulates per interrupted turn, permanently.dispatchStartedAt./healthzreturns a bareok.In Linear the session simply goes quiet. A queued session and an abandoned one look identical, which is the manual liveness check documented in
CLAUDE.md. Observed on 2026-08-19 when the service was restarted with two turns active.claimEventalready distinguishes these two cases (src/state/store.ts:622and:657): a foreign claim with no dispatch marker transfers and re-runs, a foreign claim with the marker set is answered ambiguous and not dispatched. Both branches only fire when a redelivery arrives. Linear got its 200, so no redelivery is coming, and nothing else ever looks at the receipt.Proposed change
A startup sweep that terminalizes abandoned dispatches and tells Linear the turn ended. Recovery semantics do not change: an interrupted turn is still never replayed.
Ownership.
failEventcannot be reused as-is. It routes throughterminalize, which callsownedActiveClaimand requiresclaim.ownerId === this.ownerId;ownerIddefaults to a freshrandomUUID()per store instance, so a restarted process is never the owner of its predecessor's claim. This needs a distinct store method for foreign-owned stranded claims.Liveness. Terminalizing any foreign-owned claim would let a second process abandon a first process's live turns if both ever shared a state directory. Persist process identity on the claim at
markDispatchStarted, reusing the identity source behind the lock owner record (lockProcessIdentity/lockBootIdentity, checked the same wayremoveAbandonedLockdoes), and sweep only claims whose owning process is provably gone. A startup-only sweep guarded by a single-instance assumption was considered and rejected: the assumption is not enforced anywhere in code.dispatchStartedAtwhose owning process is gone, and one that marks themfailedwith the existingAmbiguousDispatcherror class. Terminal status makes them prunable through the path that already exists.recoverAcceptedIngressPass(src/server.ts:961). Log bounded identifiers and a count, never prompt or issue content.{ type: "error", body }matches how the server already reports terminal failure (src/server.ts:1711). UsegetOrCreateActivityIdso a sweep that itself crashes and re-runs does not post twice.Acceptance criteria
claimedwithdispatchStartedAtset by a dead process reachesfailed/AmbiguousDispatchon the next startup.maxEntrieseviction.npm run typecheck && npm testpass.Notes
Frequency is low, since it needs a crash or a restart mid-turn. The unbounded growth in the state file is what makes it worth fixing rather than tolerating.
Split out of #8, which is otherwise delivered.