Skip to content

fix(factory): make dispatch claims durable and observable - #248

Open
khaliqgant wants to merge 1 commit into
mainfrom
codex/242-durable-dispatch-writeback
Open

fix(factory): make dispatch claims durable and observable#248
khaliqgant wants to merge 1 commit into
mainfrom
codex/242-durable-dispatch-writeback

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

  • make the dispatch label/state and dispatch comment a claim-critical two-part write
  • retry failed claim writes three times, verify GitHub labels by provider read-back, and error/dead-letter on exhaustion
  • persist pending, verified, and degraded claim state in durable lifecycles and the in-flight registry
  • expose registry-backed issue, agent, and claim details through factory status
  • reuse the same claim path during durable recovery so comment failures cannot disappear on resume

Coordination

Related read-path work is in #246 for #240. This PR stays scoped to write acknowledgement, retries, and independent status visibility. Both touch src/orchestrator/factory.ts, so #246 should be merged or rebased deliberately; no #240 behavior is duplicated here. #245 for #241 is confined to mount supervision.

Verification

  • npm run build
  • focused orchestrator, GitHub writeback, and CLI status regressions
  • full npm test: 1,554 passed; the dist-entrypoint test hit its 5s timeout under full-suite load
  • isolated rerun of src/__tests__/dist-entrypoints.test.ts: passed in 1.5s
  • git diff --check
  • diff secret-pattern scan

Closes #242


Summary by cubic

Makes dispatch lifecycle writes claim-critical, durable, and observable. Previously best‑effort comment/label writes could be skipped; now Factory verifies provider state, retries, dead‑letters on exhaustion, persists claim status, and exposes it via factory status (addresses #242).

  • Applies GitHub factory:in-progress before the dispatch comment, confirms the label via provider read‑back, and retries both writes up to 3 times; on exhaustion, logs an error, marks the claim as degraded, and fails the dispatch.
  • For Linear, retries state change and comment; dispatch fails if the claim comment cannot be recorded after retries.
  • Persists claim status (pending, verified, degraded) in both the durable lifecycle and the in‑flight registry; durable recovery reuses the same claim path so comment failures cannot disappear.
  • factory status now reports inFlightDispatches from the local registry, grouped by issue with agent details and the current claim; visible even when GitHub writeback is degraded.
  • GhCliGithubWriteback requires read‑back confirmation of label edits; rejects unconfirmed writes.
  • Adds counters: dispatchWritebackFailures, dispatchWritebackRetries, dispatchWritebackDeadLetters, and dispatchClaimRegistryWriteFailures.

Migration

  • Configure loop.registryPath in your Factory config to enable inFlightDispatches in factory status.

Written for commit d804202. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 100 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f2f773cb-8763-4d47-ba57-835300bc9821

📥 Commits

Reviewing files that changed from the base of the PR and between b27e130 and d804202.

📒 Files selected for processing (11)
  • README.md
  • src/cli/fleet.test.ts
  • src/cli/fleet.ts
  • src/index.ts
  • src/orchestrator/batch-tracker.ts
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
  • src/ports/state.ts
  • src/types.ts
  • src/writeback/github.ts
  • src/writeback/writeback.test.ts

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 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d804202c3b

ℹ️ 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".

implementingStateId = this.#states.idFor(issue.team, 'agentImplementing')
await this.#linear.setState(issue, implementingStateId)
}
implementingStateId = await this.#applyDispatchClaim(record, issue, 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.

P1 Badge Preserve respawnability after retrying the dispatch claim

When the default internal backend is using GitAgentWorktreeManager and this claim exhausts its three retries, the thrown error enters the generic dispatch-failure path, where #teardownFailedDispatchWorktrees releases the already spawned agents and removes their worktrees before saving the lifecycle as retryable. The saved records still contain each tracked.result, so #resumeDurableDispatch later calls #spawnAgent, which returns early for those receipts without checking the roster or respawning; if writeback recovers, the lifecycle is marked running even though no workers remain. Claim failures need either to retain the agents/worktrees or clear/reconcile the spawn receipts before durable recovery.

Useful? React with 👍 / 👎.

@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.

5 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/orchestrator/batch-tracker.ts">

<violation number="1" location="src/orchestrator/batch-tracker.ts:20">
P1: The `restore()` method rebuilds an `InFlightIssue` from scratch and drops `dispatchClaim`, even though the new interface now includes it. During crash recovery the in-flight record is rebuilt via `inFlightRecordFromLifecycle(...)` → `batch.restore(...)` (factory.ts lines 2634, 3049, 3064, 3949, 13040). `dispatchClaim` is kept by `inFlightRecordFromLifecycle` but discarded by `restore`, so a verified/pending claim never survives durable resume. As a result `#writeInFlightRegistry()` copies `record.dispatchClaim` from the restored record only when it is present, so a clean dispatch claim set before a crash no longer appears in `factory status` after recovery — exactly the registry-kept-available-when-writeback-degraded behavior this PR promises. Copy `dispatchClaim` in `restore()` like `inFlightRecordFromLifecycle` does.</violation>
</file>

<file name="src/orchestrator/factory.ts">

<violation number="1" location="src/orchestrator/factory.ts:2715">
P2: `#dispatchClaimStatuses` only ever gets `.set()` entries (lines 2715, 4161, 4541, 4576, 5933) and is never pruned or bounded, so it retains one object per dispatched issue for the life of the process. In a long-running live daemon that dispatches many issues this grows without bound even after the issue leaves the in-flight batch. Remove entries when the in-flight issue is released (alongside the existing in-flight cleanup) or bound the map like the append-only fallback-eligibility caches.</violation>

<violation number="2" location="src/orchestrator/factory.ts:2726">
P1: After the dispatch claim writeback exhausts its three retries, the thrown error now propagates into the generic dispatch-failure path, where teardownFailedDispatchWorktrees tears down the already-spawned agents and worktrees before persisting the lifecycle as retryable. Because the saved lifecycle record still has tracked.result populated from the spawn, durable recovery's spawnAgent call will short-circuit as already-spawned instead of respawning, so if writeback later succeeds the lifecycle can be marked running with no actual workers left. Either preserve the spawned agents/worktrees on a claim failure, or clear/reconcile the spawn receipts before durable recovery retries the claim.</violation>

<violation number="3" location="src/orchestrator/factory.ts:4524">
P2: The GitHub dispatch comment write and its `hasCommentMarker` read-back are not atomic, and `postComment` is non-idempotent: if the comment lands but the immediately-following read-back doesn't show it (GitHub reads can lag fresh writes), `#retryDispatchWriteback` re-invokes `postComment`, posting a duplicate comment on the issue. Consider deduplicating the marker check before posting on each retry (already done at loop entry) and, if a read-back miss is detected after an `apply()`, retry only the verification instead of re-posting, or treat the 'already present' marker as success before calling `postComment`.</violation>

<violation number="4" location="src/orchestrator/factory.ts:4542">
P1: A crash after this registry write loses the claim state from the durable lifecycle because lifecycle persistence is deferred to a later dispatch phase. Recovery resets the claim and can repost a successful Linear comment; persist the lifecycle during each claim transition before proceeding.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

agents: Map<string, TrackedAgent>
invocationIds: Set<string>
result?: DispatchResult
dispatchClaim?: FactoryDispatchClaimStatus

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The restore() method rebuilds an InFlightIssue from scratch and drops dispatchClaim, even though the new interface now includes it. During crash recovery the in-flight record is rebuilt via inFlightRecordFromLifecycle(...)batch.restore(...) (factory.ts lines 2634, 3049, 3064, 3949, 13040). dispatchClaim is kept by inFlightRecordFromLifecycle but discarded by restore, so a verified/pending claim never survives durable resume. As a result #writeInFlightRegistry() copies record.dispatchClaim from the restored record only when it is present, so a clean dispatch claim set before a crash no longer appears in factory status after recovery — exactly the registry-kept-available-when-writeback-degraded behavior this PR promises. Copy dispatchClaim in restore() like inFlightRecordFromLifecycle does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/batch-tracker.ts, line 20:

<comment>The `restore()` method rebuilds an `InFlightIssue` from scratch and drops `dispatchClaim`, even though the new interface now includes it. During crash recovery the in-flight record is rebuilt via `inFlightRecordFromLifecycle(...)` → `batch.restore(...)` (factory.ts lines 2634, 3049, 3064, 3949, 13040). `dispatchClaim` is kept by `inFlightRecordFromLifecycle` but discarded by `restore`, so a verified/pending claim never survives durable resume. As a result `#writeInFlightRegistry()` copies `record.dispatchClaim` from the restored record only when it is present, so a clean dispatch claim set before a crash no longer appears in `factory status` after recovery — exactly the registry-kept-available-when-writeback-degraded behavior this PR promises. Copy `dispatchClaim` in `restore()` like `inFlightRecordFromLifecycle` does.</comment>

<file context>
@@ -17,6 +17,7 @@ export interface InFlightIssue {
   agents: Map<string, TrackedAgent>
   invocationIds: Set<string>
   result?: DispatchResult
+  dispatchClaim?: FactoryDispatchClaimStatus
 }
 
</file context>

updatedAtMs: this.#clock.now(),
}
this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim)
await this.#writeDispatchClaimRegistry(record.issue)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A crash after this registry write loses the claim state from the durable lifecycle because lifecycle persistence is deferred to a later dispatch phase. Recovery resets the claim and can repost a successful Linear comment; persist the lifecycle during each claim transition before proceeding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 4542:

<comment>A crash after this registry write loses the claim state from the durable lifecycle because lifecycle persistence is deferred to a later dispatch phase. Recovery resets the claim and can repost a successful Linear comment; persist the lifecycle during each claim transition before proceeding.</comment>

<file context>
@@ -4476,6 +4503,116 @@ export class FactoryLoop implements Factory {
+      updatedAtMs: this.#clock.now(),
+    }
+    this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim)
+    await this.#writeDispatchClaimRegistry(record.issue)
+    return implementingStateId
+  }
</file context>

implementingStateId = this.#states.idFor(issue.team, 'agentImplementing')
await this.#linear.setState(issue, implementingStateId)
}
implementingStateId = await this.#applyDispatchClaim(record, issue, 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.

P1: After the dispatch claim writeback exhausts its three retries, the thrown error now propagates into the generic dispatch-failure path, where teardownFailedDispatchWorktrees tears down the already-spawned agents and worktrees before persisting the lifecycle as retryable. Because the saved lifecycle record still has tracked.result populated from the spawn, durable recovery's spawnAgent call will short-circuit as already-spawned instead of respawning, so if writeback later succeeds the lifecycle can be marked running with no actual workers left. Either preserve the spawned agents/worktrees on a claim failure, or clear/reconcile the spawn receipts before durable recovery retries the claim.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 2726:

<comment>After the dispatch claim writeback exhausts its three retries, the thrown error now propagates into the generic dispatch-failure path, where teardownFailedDispatchWorktrees tears down the already-spawned agents and worktrees before persisting the lifecycle as retryable. Because the saved lifecycle record still has tracked.result populated from the spawn, durable recovery's spawnAgent call will short-circuit as already-spawned instead of respawning, so if writeback later succeeds the lifecycle can be marked running with no actual workers left. Either preserve the spawned agents/worktrees on a claim failure, or clear/reconcile the spawn receipts before durable recovery retries the claim.</comment>

<file context>
@@ -2711,17 +2723,7 @@ export class FactoryLoop implements Factory {
-          implementingStateId = this.#states.idFor(issue.team, 'agentImplementing')
-          await this.#linear.setState(issue, implementingStateId)
-        }
+        implementingStateId = await this.#applyDispatchClaim(record, issue, comment)
         this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path })
       }
</file context>

record,
issue,
'GitHub dispatch comment',
async () => this.#githubWriteback.postComment(issue, 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.

P2: The GitHub dispatch comment write and its hasCommentMarker read-back are not atomic, and postComment is non-idempotent: if the comment lands but the immediately-following read-back doesn't show it (GitHub reads can lag fresh writes), #retryDispatchWriteback re-invokes postComment, posting a duplicate comment on the issue. Consider deduplicating the marker check before posting on each retry (already done at loop entry) and, if a read-back miss is detected after an apply(), retry only the verification instead of re-posting, or treat the 'already present' marker as success before calling postComment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 4524:

<comment>The GitHub dispatch comment write and its `hasCommentMarker` read-back are not atomic, and `postComment` is non-idempotent: if the comment lands but the immediately-following read-back doesn't show it (GitHub reads can lag fresh writes), `#retryDispatchWriteback` re-invokes `postComment`, posting a duplicate comment on the issue. Consider deduplicating the marker check before posting on each retry (already done at loop entry) and, if a read-back miss is detected after an `apply()`, retry only the verification instead of re-posting, or treat the 'already present' marker as success before calling `postComment`.</comment>

<file context>
@@ -4476,6 +4503,116 @@ export class FactoryLoop implements Factory {
+        record,
+        issue,
+        'GitHub dispatch comment',
+        async () => this.#githubWriteback.postComment(issue, comment),
+        commentApplied,
+      )
</file context>

state: 'pending',
updatedAtMs: this.#clock.now(),
}
this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: #dispatchClaimStatuses only ever gets .set() entries (lines 2715, 4161, 4541, 4576, 5933) and is never pruned or bounded, so it retains one object per dispatched issue for the life of the process. In a long-running live daemon that dispatches many issues this grows without bound even after the issue leaves the in-flight batch. Remove entries when the in-flight issue is released (alongside the existing in-flight cleanup) or bound the map like the append-only fallback-eligibility caches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 2715:

<comment>`#dispatchClaimStatuses` only ever gets `.set()` entries (lines 2715, 4161, 4541, 4576, 5933) and is never pruned or bounded, so it retains one object per dispatched issue for the life of the process. In a long-running live daemon that dispatches many issues this grows without bound even after the issue leaves the in-flight batch. Remove entries when the in-flight issue is released (alongside the existing in-flight cleanup) or bound the map like the append-only fallback-eligibility caches.</comment>

<file context>
@@ -2702,6 +2707,13 @@ export class FactoryLoop implements Factory {
+          state: 'pending',
+          updatedAtMs: this.#clock.now(),
+        }
+        this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim)
+      }
       await this.#writeInFlightRegistry()
</file context>

@khaliqgant

Copy link
Copy Markdown
Member Author

Required: verify against the built CLI, not just unit tests

Do not mark this done on green unit tests alone. Build this repo and exercise the real factory CLI.

Critical context first

The Factory daemon running in production is @agent-relay/factory@0.1.20 (installed Jul 17). This repo is at 0.1.57. Every runtime symptom in issue #242 was observed against 0.1.20 — 37 versions behind the source you are editing.

So before writing a fix, establish that the defect actually exists on current main:

npm ci && npm run build
node bin/factory.mjs --help          # sanity: built CLI runs
node -e "console.log(require('./package.json').version)"   # expect 0.1.57

If the defect does NOT reproduce on a build of current main, stop and say so on the issue. The correct fix is then "release and deploy 0.1.57", not a code change. Reporting that is a success, not a failure — do not invent a change to justify the dispatch.

Reproduce → fix → re-verify

# #242 — dispatch state must reach GitHub or fail loudly
node bin/factory.mjs dispatch <ISSUE_KEY> --config <path>
node bin/factory.mjs status --config <path>

Prove: after dispatch, the GitHub issue actually carries factory:in-progress and the dispatch comment. Then force the GitHub write to fail and show it retries and surfaces an error rather than returning silently. factory status must report in-flight work without depending on the writeback path.

Attach the actual terminal output for the failing run and the passing run. A diff, a type signature, or a green test is not evidence that the CLI behaves correctly.

Notes

  • --dry-run discovers and triages without writes or agent spawns — use it to avoid spawning real agents during verification.
  • Point at an explicit config with --config; Factory resolves exactly one file and does not search.
  • mergePolicy: never — do not merge. Stop at review.

@khaliqgant

Copy link
Copy Markdown
Member Author

Re-scope required — the issue description was wrong and has been rewritten

I filed #242 claiming dispatch state was "dropped before enqueue", citing relayfile writeback status showing pending: 0, failed: 0. That diagnosis was wrong. Please re-read #242; the body and title are rewritten.

What actually happens

Dispatch is not atomic. It has at least three effects — spawn implementer, spawn reviewer, write the GitHub claim (factory:in-progress + dispatch comment). If Factory dies partway through, the dispatch is left half-committed.

The discriminator is which instance dispatched, not anything about the issue:

dispatched by claim written
a healthy instance (#242, #243, #244) 13–60 seconds
an instance that later died (#240, #241, #419) 7h 45m — only after a restart

For 7¾ hours those three had a running implementer, no reviewer at all, and no label or comment on GitHub. The 0.1.58 startup reconcile repaired all three within ~2 minutes of restart, spawning the three missing reviewers.

The write never reached relayfile's queue because it died inside Factory. Do not go looking for evidence in relayfile's writeback subsystem — that was my error.

What this means for your PR

factory#248 was written against the old description. Two things changed:

  1. The fix is not "add retry/dead-letter to the writeback path." It is making dispatch atomic or explicitly recoverable — a crash must not leave a running agent with no claim and no reviewer.
  2. A repair already exists but fires only at startup. 0.1.58's reconcile is the right idea at the wrong time. The gap is that a half-committed dispatch is never repaired while the daemon keeps running. That is likely the smallest correct fix: run the reconcile on a periodic sweep, and make it cover the reviewer, not just the label.

Also note

The production daemon has been upgraded 0.1.20 → 0.1.58 since #242 was filed. Every symptom in the issue was observed on 0.1.20. Confirm the defect still reproduces on a build of current main before changing code — if it does not, say so and close this PR. That is a successful outcome, not a failure; do not invent a change to justify the dispatch.

Verification requirements are in the rewritten issue. Kill Factory mid-dispatch and prove recovery happens without a restart.

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 is not atomic — a crash mid-dispatch leaves a running agent with no reviewer and no claim

1 participant