Skip to content

[factory] Dispatch attempt state is written under issueStateKey but read under issue.key, so maxAttempts never latches on GitHub #438

Description

@khaliqgant

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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