Skip to content

fix(dispatch): read the terminal-dispatch-failure attempt row through issueStateKey (#438) - #452

Open
khaliqgant wants to merge 1 commit into
mainfrom
lane/438-attempt-state-key
Open

fix(dispatch): read the terminal-dispatch-failure attempt row through issueStateKey (#438)#452
khaliqgant wants to merge 1 commit into
mainfrom
lane/438-attempt-state-key

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #438: factory.ts's terminal-dispatch-failure branch wrote the failed-dispatch attempt row via #recordDispatchFailure(decision.issue), which keys under issueStateKey(issue) — the composed Relayfile sense path for a GitHub-native ref. The very next line read it back under the raw decision.issue.key (the bare GitHub issue number) instead. The two keys only coincide on the Linear surface (issueStateKey falls back to issue.key there), which is why that surface was unaffected.

Effect on origin/main: once maxAttempts was reached for a GitHub-native issue, the durable dispatch lifecycle was saved 'retryable' and handed to #scheduleDispatchLifecycleRetry instead of being closed out 'abandoned' — even though the attempt row itself (written and read consistently everywhere else via issueStateKey) was already terminal: true and refusing every further dispatch attempt with "dispatch already terminal". The result is an unresolvable retry loop against a lifecycle that can never dispatch again.

Fix: read through issueStateKey(decision.issue) at factory.ts:6123 so the writer and reader agree — matching every other call site that touches the attempt-state store in this file.

Grep for other write/read mismatches of this shape

Per the lane brief's DoD, I grepped every call site touching the attempt/canonical state store (getDispatchAttempts, recordDispatchAttempt, releaseInFlight, getCanonicalState, recordCanonicalState) in src/orchestrator/factory.ts for a write keyed by issueStateKey(...) paired with a read keyed by a bare .issue.key (or the reverse). No other occurrences found — the line fixed here (factory.ts:6123, formerly decision.issue.key) was the only mismatch of this shape. Not fixing any others in this PR since none were found; this is a report, not a scope expansion.

(getDispatchLifecycle/saveDispatchLifecycle call sites were also inspected — all use dispatchLifecycleKey(...) or a variable derived from it consistently, so they're outside this defect's shape.)

Test plan

  • Added a GitHub-native fail-first test in factory.test.ts: 'closes a GitHub-native lifecycle abandoned, not retryable, once the attempt latch refuses it'.
    • Fails on origin/main, exit code 1.
    • Passes with the fix, exit code 0.
  • Confirmed the equivalent Linear-surface test ('backs off dispatch errors and enforces a retry gap before the bounded terminal attempt') and the related 'leaves an abandoned row alone when the attempt latch already refuses the issue' still pass after the fix.
  • CI: to be confirmed via gh run list --branch lane/438-attempt-state-key once checks land (not --commit).

Not mergingmergePolicy is never per the lane brief.

🤖 Generated with Claude Code

https://claude.ai/code/session_0145bFsaCqLTFhDNj89CuA5U


Summary by cubic

Fixes a bug where the terminal-dispatch-failure branch read the attempt row under a different key than it wrote, causing GitHub-native issues to be left 'retryable' forever instead of being closed out 'abandoned'.

  • The write uses issueStateKey; the read used the raw issue.key, which only matches on the Linear surface.
  • Changed the read to issueStateKey so writer and reader agree, matching every other attempt-state call site.
  • Added a GitHub-native fail-first test that fails on the old behavior and passes with the fix.

Written for commit 6c3fba7. Summary will update on new commits.

Review in cubic

… issueStateKey (#438)

factory.ts:6122-6123 wrote the failed-dispatch attempt row via
#recordDispatchFailure(decision.issue), which keys under
issueStateKey(issue) — the composed Relayfile sense path for a
GitHub-native ref. The very next line read it back under the raw
decision.issue.key (the bare GitHub issue number), so the read always
missed and terminalFailure was permanently false for GitHub issues.
The two keys only coincide on the Linear surface, which is why the
Linear-surface test already passed.

The visible effect: once maxAttempts is reached, a GitHub-native
dispatch lifecycle was saved 'retryable' and handed to
#scheduleDispatchLifecycleRetry instead of being closed out
'abandoned', even though the attempt row itself (written and read
consistently elsewhere via issueStateKey) was already terminal and
refusing every further dispatch with "dispatch already terminal" —
an unresolvable retry loop against a lifecycle that can never
dispatch again.

Fix: read through issueStateKey(decision.issue) so the writer and
reader agree, matching every other call site in this file.

grep for other write/read pairs pairing issueStateKey(...) with a
bare `.issue.key` (or the reverse) across getDispatchAttempts /
recordDispatchAttempt / releaseInFlight / getCanonicalState /
recordCanonicalState found none — the fixed line was the only
mismatch of this shape in factory.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145bFsaCqLTFhDNj89CuA5U

Session-Id: a044ee0a-7d0b-4861-a3ec-8fae6e56bfaa
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 6c3fba746786c71ee8c7d5ecd945254ced9f62ab.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 364f83b4-42fb-45f0-bd01-bfa8181f0cbe

📥 Commits

Reviewing files that changed from the base of the PR and between 29d52c1 and 6c3fba7.

📒 Files selected for processing (2)
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The dispatch failure path now reads attempt state with issueStateKey. A regression test verifies that a terminal GitHub dispatch attempt transitions the lifecycle to abandoned instead of retryable.

Changes

GitHub dispatch lifecycle

Layer / File(s) Summary
Align dispatch state lookup and lifecycle regression
src/orchestrator/factory.ts, src/orchestrator/factory.test.ts
The failure path uses issueStateKey(decision.issue) to read dispatch attempts. The regression test verifies terminal GitHub attempts produce an abandoned lifecycle.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 6c3fb

The dispatch-attempt lookup now uses the canonical issue state key, allowing terminal GitHub dispatch failures to transition to abandoned. Regression coverage verifies the lifecycle does not remain retryable after the terminal attempt latch is reached.

Suggested reviewers: kjgbot, miyaontherelay

Poem

A rabbit found the key in flight
And matched the write to read just right
One failed attempt reached its end
No retry round the bend
The lifecycle rests, abandoned and bright

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the dispatch fix and specifies that the terminal failure attempt row is read through issueStateKey.
Description check ✅ Passed The description accurately explains the GitHub key mismatch, its retry-loop impact, the code fix, and the regression test.
Linked Issues check ✅ Passed The changes satisfy issue #438 by aligning the attempt-state read with issueStateKey and adding a GitHub-native regression test for abandoned terminal failures.
Out of Scope Changes check ✅ Passed The pull request contains only the targeted key correction and its related regression test. No unrelated code changes are identified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (2 skipped: 2 too large.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lane/438-attempt-state-key

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T17:39:04.731391Z 6c3fba7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

@khaliqgant

Copy link
Copy Markdown
Member Author

@codex review
@coderabbitai full review

Requesting an explicit review — CI is green, and cubic reported "no issues found," but that's on 2 files with no per-line threads, which isn't the same as a reviewer having actually engaged with the change. Please specifically challenge:

  1. The behavior change: factory.ts:6123 now reads the failed-dispatch attempt row via issueStateKey(decision.issue) instead of the bare decision.issue.key. For a GitHub-native issue this makes failedState resolve to the row #recordDispatchFailure actually wrote (previously always undefined), which flips terminalFailure from always-false to correctly-true once maxAttempts is reached — so the durable lifecycle now closes 'abandoned' instead of being saved 'retryable' and handed to #scheduleDispatchLifecycleRetry forever. Is there a legitimate case where a GitHub-native dispatch failure should still retry after the attempt latch is already terminal? I don't see one — the attempt gate (#dispatchBlockReason) already refuses every further dispatch with "dispatch already terminal" regardless of this fix — but I'd like that argued against, not assumed.
  2. The pinning test: factory.test.ts'closes a GitHub-native lifecycle abandoned, not retryable, once the attempt latch refuses it'. It uses maxAttempts: 1 so the first spawn failure is already the terminal one, and asserts the durable lifecycle phase settles to 'abandoned' (it read 'dispatching'/'retryable' and never converged on origin/main — verified fail-first, exit 1 → 0 with the fix). If this test's shape doesn't actually pin the defect, or pins something narrower/broader than the real bug, say so.

Also grepped every getDispatchAttempts / recordDispatchAttempt / releaseInFlight / getCanonicalState / recordCanonicalState call site in factory.ts for the same write/read key mismatch shape (per #438's DoD) — found no other occurrences. If you spot one I missed, please flag it in a comment rather than fixing it here (this PR is scoped to the one instance).

Not merging — mergePolicy is never.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Candidate files ==='
fd -a -t f 'factory(\.test)?\.ts$' . | sort

printf '%s\n' '=== Changed files and diff ==='
git diff --stat HEAD^ HEAD
git diff --unified=80 HEAD^ HEAD -- \
  src/orchestrator/factory.ts \
  src/orchestrator/factory.test.ts \
  || true

printf '%s\n' '=== factory.ts outline: relevant symbols ==='
ast-grep outline src/orchestrator/factory.ts \
  --items all \
  --match 'issueStateKey|recordDispatchFailure|dispatchBlockReason|scheduleDispatchLifecycleRetry|getDispatchAttempts|recordDispatchAttempt|releaseInFlight|getCanonicalState|recordCanonicalState|getDispatchLifecycle|saveDispatchLifecycle' \
  --view expanded \
  || true

printf '%s\n' '=== attempt/canonical/lifecycle call sites ==='
rg -n -C 5 \
  'issueStateKey\s*\(|`#recordDispatchFailure`\s*\(|`#dispatchBlockReason`\s*\(|`#scheduleDispatchLifecycleRetry`\s*\(|getDispatchAttempts\s*\(|recordDispatchAttempt\s*\(|releaseInFlight\s*\(|getCanonicalState\s*\(|recordCanonicalState\s*\(|getDispatchLifecycle\s*\(|saveDispatchLifecycle\s*\(' \
  src/orchestrator/factory.ts \
  || true

printf '%s\n' '=== target test and nearby tests ==='
rg -n -C 18 \
  "closes a GitHub-native lifecycle abandoned, not retryable, once the attempt latch refuses it|backs off dispatch errors and enforces a retry gap before the bounded terminal attempt|leaves an abandoned row alone when the attempt latch already refuses the issue" \
  src/orchestrator/factory.test.ts \
  || true

Length of output: 102737


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Key derivation ==='
sed -n '20845,20895p' src/orchestrator/factory.ts
printf '%s\n' '=== Failure recording and dispatch gate ==='
sed -n '10085,10170p' src/orchestrator/factory.ts
printf '%s\n' '=== Terminal failure transition and retry scheduling ==='
sed -n '6108,6215p' src/orchestrator/factory.ts
printf '%s\n' '=== Durable lifecycle retry driver ==='
sed -n '8200,8405p' src/orchestrator/factory.ts

printf '%s\n' '=== Test helper declarations and implementations ==='
rg -n -C 12 \
  'class DurableSpawnFailingFleetClient\b|function githubIssuePath\b|const githubIssuePath\b|function githubIssueFile\b|const githubIssueFile\b|function issueKey\b|const issueKey\b|async runOnce\b' \
  src/orchestrator/factory.test.ts \
  src/orchestrator/factory.ts \
  || true

printf '%s\n' '=== Full target test ==='
sed -n '19970,20020p' src/orchestrator/factory.test.ts

printf '%s\n' '=== Named state API calls with direct bare issue-key arguments ==='
rg -n -P -C 4 \
  '(getDispatchAttempts|recordDispatchAttempt|releaseInFlight|getCanonicalState|recordCanonicalState)\s*\([^,\n]+,\s*[^,\n]*\.issue\.key\b|(getDispatchAttempts|recordDispatchAttempt|releaseInFlight|getCanonicalState|recordCanonicalState)\s*\([^,\n]+,\s*issue\.key\b' \
  src/orchestrator/factory.ts \
  || true

Length of output: 40950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== GitHub issue fixture ==='
sed -n '400,470p' src/orchestrator/factory.test.ts

printf '%s\n' '=== Failing fleet fixture hierarchy ==='
rg -n -C 30 \
  'class SpawnFailingFleetClient\b|class DurableSpawnFailingFleetClient\b|spawnPty failed|async spawn\b|spawn\s*\(' \
  src/orchestrator/factory.test.ts \
  || true

printf '%s\n' '=== runOnce dispatch-error handling ==='
sed -n '3230,3460p' src/orchestrator/factory.ts
rg -n -C 18 'await this\.dispatch\s*\(|dispatch\(decision|dispatch failed|dispatchErrors' src/orchestrator/factory.ts

printf '%s\n' '=== Retryable lifecycle recovery branch ==='
sed -n '8390,8710p' src/orchestrator/factory.ts

printf '%s\n' '=== All named attempt/canonical API call arguments ==='
rg -n -P \
  '(getDispatchAttempts|recordDispatchAttempt|releaseInFlight|getCanonicalState|recordCanonicalState)\s*\(' \
  src/orchestrator/factory.ts

Length of output: 134175


@khaliqgant I found no blocking issue in this change.

The behavior change is correct.

For a GitHub-native issue, #recordDispatchFailure writes the attempt row with issueStateKey(issue). This resolves to issueKey(issue). Before this change, Line 6123 read the bare issue number instead. The read could not find the row that the writer had created.

A terminal attempt latch must not use the ordinary dispatch retry path. #dispatchBlockReason reads the same issueStateKey and refuses that issue with "dispatch already terminal". A retryable lifecycle would later enter #resumeDurableDispatch, but the attempt latch prevents a valid new dispatch. That produces a nonterminal durable lifecycle with no valid recovery action. Closing the lifecycle as abandoned is the consistent result.

The test pins the defect.

It uses a GitHub-native path and maxAttempts: 1. The first failing spawn therefore makes the attempt row terminal. The test then checks both required durable effects:

  • The attempt row exists under the GitHub path-derived key and has terminal: true.
  • The lifecycle reaches abandoned.

Before the fix, the attempt assertion still passes, but the lifecycle remains retryable. Therefore, the lifecycle assertion distinguishes the fixed behavior from the defect. The test is narrow and does not overreach into Linear behavior.

I also checked the named getDispatchAttempts, recordDispatchAttempt, releaseInFlight, getCanonicalState, and recordCanonicalState accesses in src/orchestrator/factory.ts. I found no other direct bare .issue.key argument that creates the same attempt-state or canonical-state key mismatch.

I did not execute the test suite in this review environment.

✅ Action performed

Full review finished.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 6c3fba7467

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant