Found while building a test for #435 (the lifecycle-terminal dispatch refusal). Filing separately rather than widening that PR: this changes dispatch retry semantics and belongs to whoever owns the attempt counters.
The mismatch
src/orchestrator/factory.ts:6039-6040, in the terminal-dispatch-failure branch:
await this.#recordDispatchFailure(decision.issue)
failedState = await this.#state.getDispatchAttempts(this.#workspaceId, decision.issue.key)
#recordDispatchFailure (factory.ts:9777) writes under issueStateKey(issue). The read one line later uses decision.issue.key. And issueStateKey (factory.ts:20242) is:
const issueStateKey = (issue: IssueRef): string =>
githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key
So the two agree on the Linear surface and disagree on the GitHub surface, where issueKey(issue) is a composite and issue.key is a bare number.
Consequence
On a GitHub-sourced Factory, failedState is always undefined, so:
const terminalFailure = liveStateChanged || Boolean(failedState?.terminal)
collapses to liveStateChanged alone. dispatch.maxAttempts therefore never drives the abandon-on-failure path for a GitHub issue — the only route to an abandoned row there is LiveDispatchStateChangedError.
Note the attempt row itself is still written correctly under issueStateKey, and #dispatchBlockReason (factory.ts:9745) reads it under the same key — so the retry-limit gate still works on a later sweep. What is lost is the in-pass decision: a dispatch that has exhausted its attempts is saved retryable instead of abandoned, and does not take the #recordDispatchTerminal / cleanup path at factory.ts:6092-6101 on that pass.
Reproduction
A GitHub-native issue, dispatch: { errorCooldownMs: 0, maxAttempts: 1 }, and a fleet client whose spawn fails. Expected abandoned; observed retryable:
AssertionError: expected [ 'retryable' ] to deeply equal [ 'abandoned' ]
- "abandoned",
+ "retryable",
The identical test on the Linear surface produces abandoned, because there issueStateKey(issue) === issue.key.
Suggested direction
Read through issueStateKey(decision.issue) so the writer and reader agree — the same one-spelling rule #367 was filed for. Worth checking the other getDispatchAttempts call sites for the same shape while in there.
Not proposing a patch; flagging the disagreement and its blast radius.
Found while building a test for #435 (the
lifecycle-terminaldispatch refusal). Filing separately rather than widening that PR: this changes dispatch retry semantics and belongs to whoever owns the attempt counters.The mismatch
src/orchestrator/factory.ts:6039-6040, in the terminal-dispatch-failure branch:#recordDispatchFailure(factory.ts:9777) writes underissueStateKey(issue). The read one line later usesdecision.issue.key. AndissueStateKey(factory.ts:20242) is:So the two agree on the Linear surface and disagree on the GitHub surface, where
issueKey(issue)is a composite andissue.keyis a bare number.Consequence
On a GitHub-sourced Factory,
failedStateis alwaysundefined, so:collapses to
liveStateChangedalone.dispatch.maxAttemptstherefore never drives the abandon-on-failure path for a GitHub issue — the only route to anabandonedrow there isLiveDispatchStateChangedError.Note the attempt row itself is still written correctly under
issueStateKey, and#dispatchBlockReason(factory.ts:9745) reads it under the same key — so the retry-limit gate still works on a later sweep. What is lost is the in-pass decision: a dispatch that has exhausted its attempts is savedretryableinstead ofabandoned, and does not take the#recordDispatchTerminal/ cleanup path atfactory.ts:6092-6101on that pass.Reproduction
A GitHub-native issue,
dispatch: { errorCooldownMs: 0, maxAttempts: 1 }, and a fleet client whose spawn fails. Expectedabandoned; observedretryable:The identical test on the Linear surface produces
abandoned, because thereissueStateKey(issue) === issue.key.Suggested direction
Read through
issueStateKey(decision.issue)so the writer and reader agree — the same one-spelling rule #367 was filed for. Worth checking the othergetDispatchAttemptscall sites for the same shape while in there.Not proposing a patch; flagging the disagreement and its blast radius.