Skip to content

fix: bound startup recovery and release durable capacity#164

Merged
kjgbot merged 6 commits into
mainfrom
fix/startup-recovery-progress
Jul 21, 2026
Merged

fix: bound startup recovery and release durable capacity#164
kjgbot merged 6 commits into
mainfrom
fix/startup-recovery-progress

Conversation

@kjgbot

@kjgbot kjgbot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • bound agent recovery by durable run + logical worker identity so rotated names/session refs cannot restart forever and true reopenings get a fresh budget
  • record successful no-session respawns as the single allowed recovery attempt
  • persist terminal abandoned lifecycle state before promoting the next queued issue, including after fence rejection and ownership reacquisition
  • probe the exact deterministic implementation branch during startup recovery instead of scanning every mounted PR
  • prioritize implementers and cap reconciled startup exits at four so a large stale roster cannot overload the broker or let reviewer recovery delay capacity reclamation

Live failure reproduced

Factory discovered ready issues and persisted queued rows, but five no-PR recovery lifecycles retained all durable capacity. Missing implementers exited immediately after recovery while reviewer sessions rotated IDs, producing an unbounded recovery loop. Cleanup removed the process-local batch entry but left the durable row in publishing/running, so promotion remained fenced. The mounted GitHub backfill also delayed recovery by scanning hundreds of historical issue records before an exact branch check. An unbounded 30-agent startup reconciliation then saturated the broker API.

Validation

  • npm test: 55 files, 1,097 tests passed
  • npm run build
  • npm run featuremap:check: 241 features, no advisories
  • exact committed package consumer E2E: 9/9 checks passed locally and in hosted attestation for 1edd4f8
  • regressions prove name/session rotation is bounded, reopenings receive a fresh recovery budget, no-session terminal recovery promotes queued work, a rejected abandoned-phase save re-acquires ownership before promotion, and startup recovery never exceeds four concurrent implementation probes

Live canary

Exact PR head 1edd4f8 ran with Relayfile SDK 0.10.33 and the hardened Relay broker. Durable queued rows dropped from 16 to 12; Factory #145, #143, #147 and Cloud #2777 were promoted. The broker reports live implementer PIDs for #145, #143 and #147 plus their reviewers. Mount health is authoritatively recovered with zero degraded mounts.

Release gate

Hosted exact-head CI and package attestation are green, all review threads are resolved, and the live canary has promoted and spawned previously queued work.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change bounds durable reconciled-agent recovery, prioritizes and throttles startup handling, improves exact-branch completion checks, deduplicates recovery by logical identity, and persists abandoned before promoting queued work.

Changes

Durable recovery lifecycle

Layer / File(s) Summary
Reconciled-exit concurrency and completion control
src/orchestrator/factory.ts, src/orchestrator/factory.test.ts
Reconciled exits are prioritized and concurrency-limited; implementer completion probing can prefer the deterministic branch, with coverage for startup and babysitter recovery.
Logical-agent recovery deduplication
src/orchestrator/factory.ts, src/orchestrator/factory.test.ts
Resume and respawn work uses durable logical-agent keys, coalesces in-flight operations, handles collisions, and bounds repeated recovery across rotating sessions, reopened issues, and queued work.
Abandonment lifecycle fencing
src/orchestrator/factory.ts, src/orchestrator/factory.test.ts
Abandoned lifecycle persistence now precedes promotion, with retry and ownership reacquisition after persistence failure.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StartupReconciler
  participant FactoryLoop
  participant FleetClient
  participant DurableState
  StartupReconciler->>FactoryLoop: queue prioritized reconciled exit
  FactoryLoop->>FleetClient: resume or respawn logical agent
  FleetClient-->>FactoryLoop: recovery result
  FactoryLoop->>DurableState: persist abandoned lifecycle before promotion
Loading

Possibly related PRs

Suggested reviewers: khaliqgant, miyaontherelay, willwashburn

Poem

A rabbit bounded every hop,
So spinning sessions had to stop.
Exact branches showed the way,
Abandoned paths were fenced that day.
Queued work rose when space was won.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: bounding startup recovery and freeing durable capacity.
Description check ✅ Passed The description matches the implemented recovery-bounding, abandonment fencing, and startup probing changes.
✨ 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 fix/startup-recovery-progress

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.

@kjgbot
kjgbot requested a review from willwashburn July 21, 2026 06:47

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request updates the orchestrator's factory loop to bound agent recovery by the durable logical agent instead of the session reference, preventing agents that rotate session references or use no-session respawns from indefinitely consuming batch slots. It also ensures terminal dispatches are marked as 'abandoned' in the lifecycle state before promoting queued issues to avoid capacity stalls. The feedback recommends wrapping the await of an existing in-flight recovery promise in a try-catch block to prevent concurrent event handlers from re-throwing rejected promises and causing unhandled rejections.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/orchestrator/factory.ts
@kjgbot
kjgbot marked this pull request as ready for review July 21, 2026 06:51

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/orchestrator/factory.ts (2)

5869-5906: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Abandonment retry is silently dropped when the abandoned-phase save fails, due to a shared retry-timer key.

#saveDispatchLifecycle (Line 3075) unconditionally calls this.#scheduleDispatchLifecycleRetry(record) on every failure path (epoch missing or state save rejected) before returning false. That scheduler and #scheduleAbandonedDispatchRetry (Line 5891) both key off the same map (this.#dispatchLifecycleRetryTimers) using the same issueKey(record.issue). So when the new fencing block's save fails:

if (!await this.#saveDispatchLifecycle(record, 'abandoned', undefined, reason)) {
  this.#increment('abandonedDispatchReleaseRetries')
  this.#scheduleAbandonedDispatchRetry(record, reason)   // no-op: timer already set by saveDispatchLifecycle above
  ...
}

#scheduleAbandonedDispatchRetry's own guard (if (... this.#dispatchLifecycleRetryTimers.has(key)) return) sees the timer already claimed and returns immediately. The retry that actually fires is the generic one from #saveDispatchLifecycle, which calls #driveDispatchLifecycle(key) — and for a non-terminal phase like running, that path just reconciles tracked agents and returns, never re-invoking #abandonStuckDispatch. The durable row can be left stuck non-terminal indefinitely — exactly the durable-capacity leak this PR sets out to fix, in a narrower race window (lease/epoch loss during the abandon-fencing save).

🔧 Suggested fix: clear the stale timer before scheduling the abandon-specific retry
     if (!await this.#saveDispatchLifecycle(record, 'abandoned', undefined, reason)) {
       this.#increment('abandonedDispatchReleaseRetries')
+      const staleKey = issueKey(record.issue)
+      const staleTimer = this.#dispatchLifecycleRetryTimers.get(staleKey)
+      if (staleTimer) {
+        clearTimeout(staleTimer)
+        this.#dispatchLifecycleRetryTimers.delete(staleKey)
+      }
       this.#scheduleAbandonedDispatchRetry(record, reason)
       await this.#writeInFlightRegistry()
       return
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 5869 - 5906, Clear the existing
entry in this.#dispatchLifecycleRetryTimers for the issue key when the
abandoned-phase save in `#abandonStuckDispatch` fails, then schedule the
abandon-specific retry with `#scheduleAbandonedDispatchRetry`(record, reason).
Preserve the retry counter, registry write, and early return, ensuring the retry
invokes `#abandonStuckDispatch` rather than being consumed by the generic
`#saveDispatchLifecycle` retry.

5245-5299: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use a stable recovery key for resumed agents name can change during #resumeTrackedAgent, so issueKey:name is not stable here. If a resume comes back under a new name, the next exit gets a fresh key and can re-enter the recovery loop. Key this off a stable identity (for example sessionRef) or carry the original key through the rename.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 5245 - 5299, The recovery flow
around `#resumeTrackedAgent` must use an identity that remains stable when the
agent is renamed. Replace the recoveryKey construction or tracking logic with
the agent’s stable sessionRef, or preserve the original key through resume, and
ensure subsequent exit handling uses that same key rather than a newly derived
issueKey:name value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/orchestrator/factory.ts`:
- Around line 5223-5236: Scope the recovery marker in the resumption check
around recoveryKey to the current run, preventing resumedExitKeys from leaking
across reopenings and causing later implementer redispatches to terminate
immediately. Incorporate the existing runId into the recovery key, or ensure the
reopen reset clears resumed markers, while preserving the one-recovery-per-run
behavior.

---

Outside diff comments:
In `@src/orchestrator/factory.ts`:
- Around line 5869-5906: Clear the existing entry in
this.#dispatchLifecycleRetryTimers for the issue key when the abandoned-phase
save in `#abandonStuckDispatch` fails, then schedule the abandon-specific retry
with `#scheduleAbandonedDispatchRetry`(record, reason). Preserve the retry
counter, registry write, and early return, ensuring the retry invokes
`#abandonStuckDispatch` rather than being consumed by the generic
`#saveDispatchLifecycle` retry.
- Around line 5245-5299: The recovery flow around `#resumeTrackedAgent` must use
an identity that remains stable when the agent is renamed. Replace the
recoveryKey construction or tracking logic with the agent’s stable sessionRef,
or preserve the original key through resume, and ensure subsequent exit handling
uses that same key rather than a newly derived issueKey:name value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ac09323-4895-443e-bdf4-be9f6d4ec099

📥 Commits

Reviewing files that changed from the base of the PR and between 2434cb6 and 20f4491.

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

Comment thread src/orchestrator/factory.ts
@kjgbot

kjgbot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two outside-diff CodeRabbit findings in 2d4433d:

  • The rename concern was valid, but sessionRef is intentionally not the identity because it can rotate. Recovery is now keyed by durable runId + original tracked spec name, stable across broker renames and fresh across issue reopenings.
  • The shared retry timer race was valid. A pending abandonment reason is now retained so the generic durable retry first re-acquires ownership and then re-enters terminal abandonment, instead of restoring an old running/publishing row. An injected first-save fence rejection proves the old issue becomes abandoned and queued work is promoted.

Validation on 2d4433d: build; 55 files / 1,096 tests; 241-feature map; exact packed package E2E 9/9.

@kjgbot

kjgbot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Final exact-head live proof on 1edd4f8:

Local validation: build; 55 files / 1,097 tests; 241-feature map; exact packed E2E 9/9. All review threads are resolved.

@kjgbot

kjgbot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/orchestrator/factory.ts (2)

5891-5938: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Retry-on-abandon-save-failure can re-release already-released agents.

#abandonStuckDispatch cleans up (releasing/terminating agents) and only afterward persists phase 'abandoned'. If that persist fails (fence rejection, exactly the scenario the new #abandonedDispatchReasons map exists to retry), the durable lifecycle remains in its pre-abandon 'running' phase with no agent marked releasedAtMs. The generic retry (#driveDispatchLifecycle → the new abandonedReason check at L3328) reconstructs record from that stale lifecycle via inFlightRecordFromLifecycle, which includes every agent regardless of release state, and re-invokes #abandonStuckDispatch — re-running #releaseAndTerminateAgents on agents that were already successfully released in the first attempt.

This is exactly the hazard #finishDurableRelease guards against elsewhere in this same file, by filtering agent.releasedAtMs !== undefined before releasing. #abandonStuckDispatch's new retry path has no equivalent guard. The added regression test only passes because the fake FleetClient tolerates a second release() call for the same name; a real broker rejecting an unknown/already-released agent name (the relay#1116 class of issue this file repeatedly guards against elsewhere) could make cleanupComplete false forever, permanently blocking the terminal 'abandoned' persist and the queued-work promotion this PR is meant to guarantee.

🛡️ Suggested fix — skip already-released agents on retry, mirroring `#finishDurableRelease`
 async `#abandonStuckDispatch`(record: InFlightIssue, reason: string): Promise<void> {
     const key = issueKey(record.issue)
     this.#abandonedDispatchReasons.set(key, reason)
-    const agents = [...record.agents]
+    const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key)
+    const released = new Set(lifecycle?.agents
+      .filter((agent) => agent.releasedAtMs !== undefined)
+      .map((agent) => agent.name))
+    const agents = [...record.agents].filter(([name]) => !released.has(name))
     for (const [agentName, tracked] of agents) {
       if (tracked.spec.role === 'implementer') continue
       this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 5891 - 5938, Update
`#abandonStuckDispatch` to exclude agents whose releasedAtMs is already defined
before calling `#releaseAndTerminateAgents`, matching the filtering used by
`#finishDurableRelease`. Preserve cleanup for agents not yet released and ensure
retries after a failed abandoned lifecycle save can proceed to persist the
terminal phase.

1-1: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Abandon-retry path can re-release already-released agents; test relies on the fake tolerating it.

#abandonStuckDispatch releases agents and only afterward persists phase 'abandoned'. When that persist fails (fence rejection), the retry re-runs the whole function against a durable record that still lists every agent as not-yet-released (inFlightRecordFromLifecycle doesn't filter by releasedAtMs), re-invoking #releaseAndTerminateAgents on agents already released in the prior attempt — unlike #finishDurableRelease, which explicitly guards this with a released set. The added tests pass only because the fake FleetClient tolerates a repeat release() call for the same name; a real broker that rejects releasing an unknown/already-released name could stall this retry indefinitely and never reach 'abandoned'.

  • src/orchestrator/factory.ts#L5891-5938: filter record.agents against agent.releasedAtMs !== undefined (read from the durable lifecycle) before re-releasing on retry, mirroring #finishDurableRelease's pattern.
  • src/orchestrator/factory.test.ts#L9895-9972: once the guard is added, confirm (or extend) these tests still pass, and consider asserting release() call counts to lock in the no-double-release behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` at line 1, Update `#abandonStuckDispatch` to
exclude agents whose durable lifecycle has releasedAtMs set before invoking
`#releaseAndTerminateAgents`, mirroring `#finishDurableRelease`’s released-agent
guard. Preserve retries for unreleased agents and ensure the existing
abandonment persistence flow still completes without re-releasing
already-released agents.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/orchestrator/factory.ts`:
- Around line 5891-5938: Update `#abandonStuckDispatch` to exclude agents whose
releasedAtMs is already defined before calling `#releaseAndTerminateAgents`,
matching the filtering used by `#finishDurableRelease`. Preserve cleanup for
agents not yet released and ensure retries after a failed abandoned lifecycle
save can proceed to persist the terminal phase.
- Line 1: Update `#abandonStuckDispatch` to exclude agents whose durable lifecycle
has releasedAtMs set before invoking `#releaseAndTerminateAgents`, mirroring
`#finishDurableRelease`’s released-agent guard. Preserve retries for unreleased
agents and ensure the existing abandonment persistence flow still completes
without re-releasing already-released agents.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 95c7a1f0-efb8-46c0-ae91-0e0f535f5917

📥 Commits

Reviewing files that changed from the base of the PR and between 20f4491 and 1edd4f8.

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

@kjgbot
kjgbot merged commit 052fdf2 into main Jul 21, 2026
3 checks passed
@kjgbot
kjgbot deleted the fix/startup-recovery-progress branch July 21, 2026 08:04
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.

1 participant