feat(orchestration): coordinator resume-on-boot (F3, #14) - #21
Conversation
The Coordinator is an in-memory instance with no boot hook, so after an app
restart a leftover `coordinator_runs.status='running'` row is a zombie: it trips
F1's per-target active-run guard and blocks a fresh run for that target while
nothing drives the old one. F3 adds a startup reconciler that, on boot, scans
every running run and converges it.
MUST (the floor — zombie fix):
- `reconcileCoordinatorRunsOnBoot` (boot-resume.ts) classifies each running run:
finalize (work already done → completed/failed), resume, or — the floor —
mark failed. A run that cannot be resumed always converges to failed, never
left running with no loop. Idempotent (a second pass finds no running rows),
F1-isolation aware (run-scoped + target_key), wired into desktop boot behind
the experimental flag, fire-and-forget so it can never block startup.
SHOULD (real resume + worktree re-adoption, per F2-DESIGN §8):
- Persist the in-memory-only coordinator options (schema v9: max_concurrent,
worktree_backed, worker_agent) so a restart rebuilds the SAME run instead of
guessing — a legacy-mode guess of a worktree-backed run would dispatch into a
shell that never completes (a fresh zombie).
- `resumeCoordinatorRunOnBoot` rebuilds the coordinator, reclaims dead in-flight
dispatches so the loop is guaranteed to converge, re-adopts existing track
worktrees by scanning the director's lineage children (parentWorktreeId ===
directorWorktreeId, same run — the data Mission Control uses) via
`buildAdoptedTrackWorktrees` + `Coordinator.seedAdoptedTrackWorktrees`, and
relaunches the worker agent in the existing checkout rather than forking a new
worktree/branch. Declines (→ failed) when the director worktree is gone.
Tests:
- boot-resume.test.ts: orphaned running run reconciled to failed AND a fresh run
for the target then starts (guard unblocked); idempotency; finalize-when-done;
resume decision (succeed/decline/throw); target isolation; reclaim; track
re-adoption from lineage.
- coordinator.test.ts: a seeded track is re-adopted on resume (no duplicate
worktree; agent relaunched in the existing checkout).
- Hardened a pre-existing ~40%-flaky test ("respects maxConcurrent limit") that
completed tasks in a fixed array order while the cap picks 2 of 3 by random id
— now completes tasks as actually dispatched and always drains the loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review panel — not merge-ready (verified against source)3-lens panel; the load-bearing findings re-verified against the F3 source by the coordinator. No blockers, but the HARD RULE (no run left 🟠 Must-fix
🟠 Should-fix (same pass)
NitsDistinguish |
…14, round 2) Round-2 review found a real HARD-RULE violation (a run left running with no loop) and that the production resume path was untested. This closes both plus the should-fixes. Must-fix: 1. Catch-less detached loop → fresh zombie. `executeLoop` ran `adoptUnownedTasks` (+ the pre-read) OUTSIDE its `try`, and the resume's `runFromExistingRun(...)` had no `.catch`. A throw there rejected the loop un-finalized and the reconciler had already skipped its failed-fallback → run stuck `running` with no loop. Fix (belt + suspenders): moved the pre-loop work INSIDE `executeLoop`'s try, and added a `.catch` on the resumed loop that force-marks the run `failed`. 2. Real resume path now tested. New `orchestration-resume-on-boot.test.ts` drives the production `resumeCoordinatorRunOnBoot` via `runOrchestrationBootReconcile` with a fake runtime + real DB: resumable run → loop starts + track re-adopted (no duplicate worktree); the catch-less-loop case (throwing `adoptUnownedTasks` → run ends `failed`, never `running`); and every declines→`failed` branch. The hard-rule + NULL tests FAIL against round-1 code (verified). Should-fix (same pass): 3. Pre-v9 `worktree_backed = NULL` with a worktree target is now treated as not-safely-resumable → `failed` (guessing legacy would dispatch into a bare shell that never converges → unbounded re-zombie). Only an explicit `worktree_backed = 0` resumes in legacy mode. 4. Cross-process double-drive closed. Schema v10 adds `coordinator_runs.resumed_at`, a boot-time-fenced atomic claim (`tryClaimRunForResume`, BEGIN IMMEDIATE). The resume callback claims FIRST; a loser returns `contended` → the run is left running (a live owner drives it), never double-driven and never failed out from under its owner. A strictly-greater later fence reclaims a crashed resumer's stale claim, so a crash mid-resume can't strand the run. Makes the (intentionally redundant) serve-mode boot reconcile safe without a serve guard. 5. Per-run try/catch in the reconcile loop: one row's transient error (e.g. SQLITE_BUSY) no longer strands every later running row (`reconcile-error` disposition, logged, continues). Nits: claim-based idempotency replaces "idempotent by construction" (a second pass is `contended`→`skipped`); director-resolve failure documented as fail-closed (favoring the hard rule) with transient-vs-not-found distinction noted as follow-up. Verification: vitest orchestration + MC + RPC suites green (297 passed, 5x stable); typecheck node/cli/web clean (only 4 pre-existing TuiAgent errors on main); oxlint clean; electron-vite build green. New resume tests verified red against round-1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round 2 — hard-rule escape hatches closed (commit 31f7542)All must-fix + should-fix landed; the previously-untested production resume path now has end-to-end coverage that fails against round-1 code. Must-fix
Should-fix
Nits
Verificationvitest orchestration + MC + RPC green (297 passed, 5× stable) · typecheck node/cli/web clean (only 4 pre-existing Still do not merge — review. |
Part of epic #5. Closes #14. Depends on F1 (#12) + F2 (#13), both merged to
main.Problem
The
Coordinatoris an in-memory instance with no boot hook. After an app restart, a leftovercoordinator_runs.status='running'row is a zombie: it trips F1's per-target active-run guard (startCoordinatorRun'sBEGIN IMMEDIATEcheck) and refuses a freshorchestration.runfor that target — while nothing drives the old run. With a headless/recipe director there's no LLM agent to re-issue the run, so the target is blocked forever.MUST — the floor (zombie fix) ✅
New
reconcileCoordinatorRunsOnBoot(boot-resume.ts) scans everystatus='running'run on boot and converges each:completed/failed.failedso the guard unblocks.Hard rule honored: a run that can't be resumed always converges to
failed, never leftrunningwith no loop (resume decline/throw both fall through to failed). Idempotent (a second pass finds no running rows). F1-isolation aware (run-scoped +target_key). Wired into desktop boot behind theexperimentalOrchestratorsflag, fire-and-forget so a reconcile error can never block startup, and runs before any window is shown (before a user could hit the guard).SHOULD — real resume + worktree re-adoption (per F2-DESIGN §8) ✅
max_concurrent,worktree_backed,worker_agent) so a restart rebuilds the same run instead of guessing. Guessing legacy-mode for a worktree-backed run would dispatch into a bare shell that never reports done — a fresh zombie. Additive/nullable; pre-v9 rows read NULL → resume falls back to defaults.resumeCoordinatorRunOnBootrebuilds the coordinator, reclaims dead in-flight dispatches (dispatched → readyvia the existing breaker) so the loop is guaranteed to converge, and re-adopts existing track worktrees:buildAdoptedTrackWorktreesscans the director's lineage children (parentWorktreeId === directorWorktreeId, same run — the exact data Mission Control uses) andCoordinator.seedAdoptedTrackWorktreespre-seeds the track map. The next same-track dispatch is a hit — the worker agent is relaunched in the existing checkout (preserving commits), no duplicate worktree/branch.Cross-cutting
Cross-platform / SSH-aware: resume routes through the existing runtime (
resolveOrchestrationTargetKey,listWorktreeLineage,createManagedWorktree), inheriting the SSH/relay machinery — no local-only assumptions. Provider-neutral.Tests (fail without the fix)
boot-resume.test.ts: orphaned running run reconciled to failed AND a fresh run for the target then starts (proves the guard unblocked — it throwsCoordinatorRunConflictErrorbefore the reconcile); idempotency; finalize-when-done (completed / failed / zero-tasks); resume decision (succeed / decline / throw → never left running); target isolation; reclaim; lineage-based track re-adoption (dedupe by track, cross-run/cross-parent children ignored).coordinator.test.ts: a seeded track is re-adopted on resume — nocreateWorktree, agent relaunched in the existing checkout.db.test.ts: v9 options round-trip (worktree_backed=false→0, unset → NULL).respects maxConcurrent limit) that completed tasks in fixed array order while the cap picks 2-of-3 by random id; now completes tasks as actually dispatched and always drains the loop. Verified 0/20 + combined suite 0/5.Verification
vitestorchestration + Mission Control + RPC + CLI suites green (288 passed, 5× stable).typecheck(node/cli/web) clean — only 4 pre-existingTuiAgenterrors inorca-runtime.test.ts(present onmain).oxlintclean;electron-vite buildgreen.Deferred / follow-ups
Do not merge — review first.
🤖 Generated with Claude Code