Skip to content

fix(recovery): decide stuck-task recovery under the dispatch state lock (#977) - #980

Merged
markhayden merged 2 commits into
mainfrom
fix/recovery-under-dispatch-lock
Oct 17, 2026
Merged

markhayden merged 2 commits into
mainfrom
fix/recovery-under-dispatch-lock

Conversation

@markhayden

@markhayden markhayden commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #977 — the residual recorded in #975's round-3 review.

Both recovery engines judged a workflow task stranded from a scope read before awaiting hooks. A nested/map child spawned and claimed between that walk and the synchronous check was invisible (parent row untouched, child run keyed on the child id), so the parent could be recovered or blocked while a child was executing.

Every ledger claim is already made under withStateLock, so the decision and the write now happen under that lock:

  • Watchdog: recoverStuckTask runs per task under the lock — re-reads liveness (re-walks the workflow topology; a scope that changed since the scan is left for the next tick), validates the row fresh (inProgress, same version), then supersede / recover / block with nothing awaiting between the read and the store write. The scan's read stays as the pre-filter and feeds alert-only mode.
  • Restart recovery: each candidate is decided in settleCandidate under the lock, so a claim in flight (HTTP is live during startup recovery) lands before the re-assessment reads the ledger.

Deadlock fixed on the way (Codex review of this PR)

resolveTeamAssignmentForStep runs inside dispatchWorkflowTask, which the cycle/single dispatch already call under withStateLock; on a transient team-routing failure it awaited withStateLock again to record the ladder entry. The lock is a non-reentrant promise queue, so that dispatch cycle never finished — on main today — and with recovery queued on the same lock the watchdog and startup recovery would have hung too. The resolver now records into the caller's state (new StepTeamResolutionArgs.state) and never takes the lock; the lock holder saves as it already did.

dispatch-state.ts imports only the logger — check:cycles unchanged.

Verification

  • New tests (fail against the previous engines): a child spawned and claimed during the scan leaves the parent alone; the watchdog and restart recovery park behind a held lock and see the claim its holder made; a topology that changed with nothing live is skipped that tick and recovered on the next; a claim racing the decision queues behind recovery's lock; the step resolver completes a transient failure while the caller holds the lock.
  • tests/core/{watchdog,restart-recovery,dispatch,dispatch-handoff,dispatch-team-step,continuation}.test.ts, tests/plugins/workflows/team-step-token.test.ts, health checks route suite green; typecheck, lint (3 pre-existing warnings), check:cycles green.
  • Isolated boot on 3799 with a 1.5 s stuck threshold: a task moved to In Progress with no run is auto-recovered to Todo by the lock-guarded path.

Docs: .claude/knowledge/dispatch.md § Restart recovery, .claude/knowledge/agent-system.md § Watchdog, .claude/knowledge/team-aware-assignment.md (step ladder lives in the caller's state), spec D9 residual marked closed.

🤖 Generated with Claude Code

markhayden and others added 2 commits October 16, 2026 15:31
…h state lock

Closes #977. Both recovery engines judged a workflow task stranded from a
scope read BEFORE awaiting hooks (active steps + fanned-out task ids). A
nested/map child spawned AND claimed between that walk and the synchronous
check was invisible: the parent row is untouched by a child spawn (so the
`version` compare cannot see it) and the child's run is keyed on the child
id. The parent was recovered — or blocked — while a child was executing.

Every ledger claim is already made under `withStateLock` (dispatch-cycle /
-single / -team → claimDispatchRun), so:

- The watchdog's per-task decision moves into `recoverStuckTask`, run under
  the lock: liveness is read again inside it (a workflow's topology
  re-walked — a scope that changed since the scan is left for the next
  tick), then the row is validated fresh (inProgress, same `version`) and
  everything from that read to the store write is synchronous. The scan's
  own read stays as the pre-filter and feeds alert-only mode.
- Restart recovery decides each candidate in `settleCandidate` under the
  same lock, so a claim in flight (HTTP is live during startup recovery)
  lands before the re-assessment reads the ledger.

Tests: a child spawned and claimed during the scan leaves the parent alone;
both engines park behind a held lock and see the claim its holder made.
All three fail against the previous engines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tate — never re-takes the dispatch lock

Codex review of #980 found a deadlock already on main that the recovery
lock would have widened: `resolveTeamAssignmentForStep` runs inside
`dispatchWorkflowTask`, which the cycle/single dispatch call under
`withStateLock`; on a transient routing failure it awaited `withStateLock`
again to record the ladder entry. The lock is a non-reentrant promise
queue, so that cycle never finished — and with recovery queued on the same
lock, neither would the watchdog or startup recovery.

The resolver now takes the caller's `state` (`StepTeamResolutionArgs.state`)
and reads/records the ladder there; the lock holder saves, as it already
did (a reload-and-save would have been overwritten by that save anyway).
It never takes the lock itself.

Also from the review: two more watchdog tests — a topology that changed
during the scan with nothing live is skipped that tick and recovered once
it holds still (pins scopeKey independently of the live-run read), and a
claim racing the decision queues behind recovery's lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@markhayden
markhayden merged commit 3abdd59 into main Oct 17, 2026
17 checks passed
@markhayden
markhayden deleted the fix/recovery-under-dispatch-lock branch October 17, 2026 02:42
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.

Recovery engines miss a workflow child spawned and claimed during the scan — run the liveness check under the dispatch state lock

1 participant