fix: reconcile dead TaskRunner DO/D1 task lifecycle promptly (Priority 5)#1567
Open
simple-agent-manager[bot] wants to merge 11 commits into
Open
fix: reconcile dead TaskRunner DO/D1 task lifecycle promptly (Priority 5)#1567simple-agent-manager[bot] wants to merge 11 commits into
simple-agent-manager[bot] wants to merge 11 commits into
Conversation
Contributor
Remove dead awaiting_followup task-STATUS handling (it is a TaskExecutionStep, never written to tasks.status; the OR also defeated the candidate index). Add rule-47 timeout (TASK_LIVENESS_PROBE_TIMEOUT_MS) bounding the ProjectData DO liveness probe (inconclusive on timeout, never fatal) and cache the per-candidate liveness so both gates probe at most once. Fix two pre-existing workers-pool tests silently broken by the code change, add CI-verified node-pool regressions (live awaiting_followup preserved; aborted_by_recovery in_progress branch), fix lint, and document the liveness-gated recovery semantic. File backlog for workers-pool tests not running in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- C-1: node healthy + recent heartbeat but task-scoped ACP session gone -> task is failed (proves node heartbeat alone cannot suppress reconciliation — the exact production regression; previously every failure path short-circuited at node_not_live so listAcpSessions was never on a failing path). - H-1: make the custom-hard-timeout test discriminating (assert the overridden ceiling surfaces as the failure threshold, not the default 480 min). - M-1: assert the idempotent terminal callback does NOT re-write task status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ition Security review: failTask read status then wrote WHERE id=? — a concurrent terminal transition landing between the check and the write could be clobbered. Add the terminal-status predicate to the UPDATE so an already-terminal row is never overwritten (defense-in-depth on top of the existing pre-check). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7e3585c to
efcd974
Compare
Contributor
Author
|
Staging wrap-up attempt for PR #1567 stopped before merge. Completed:
Blocker:
Cleanup:
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Repairs TaskRunner Durable Object / D1 task lifecycle reconciliation so dead work no longer sits
in_progress/awaiting_followupuntil the 8-hour hard timeout. Adds a separate configurable 24-hour absolute runaway-cost ceiling that fails even demonstrably live tasks, bounding unbounded compute without changing the liveness-gated semantics ofTASK_RUN_HARD_TIMEOUT_MS.Production evidence (2026-07-11, read-only D1/observability): Since 2026-06-01 there were 57
TaskRunner DO completed but task still in 'in_progress'warnings and 37awaiting_followup480-minute hard-timeout failures. All 34 D1 rows with the exact 480-minute message were conversation-mode tasks whose workspaces are now deleted; 32 still hadagent_sessions.status='running', proving that status is stale liveness. Soft-timeout recovery kept skipping them on shared-node heartbeat, which proves host — not task — liveness. Dominant exit path is normal post-handoff liveness drift (no bounded-windowaborted_by_recoveryrecords found), but the recovery race is code-grounded and closed here too.Changes:
getTaskRuntimeLiveness,stuck-tasks.ts): requires live workspace (running/recovery) and healthy node with recent heartbeat and a task-scoped ACP session with a recent heartbeat. A shared-node heartbeat is never sufficient. Returns{ live, conclusive, reason }; inconclusive/errored liveness never fails a task (fail-safe protects genuinely-running long work).reasononly when composite liveness is conclusively dead, gated by a configurableTASK_DO_MISMATCH_GRACE_MS(5 min) instead of waiting for the 8-hour timeout.awaiting_followupis now covered alongsidein_progress.aborted_by_recoveryrace closure (state-machine.ts): the zero-row optimistic delegated→in_progress update now re-reads authoritative D1 status —in_progress→ DO marks running/done; already-terminal/missing → DO completes without clobbering a legitimate terminal callback; still-transient (superseded) →failTaskwith a clear message.callback.ts): a repeated same-terminal callback reusescleanupTerminalTaskResourcesOrThrowand returns the task instead of throwing a transition conflict.DEFAULT_*constants + env fallbacks; candidate query isLIMIT-bounded and the ACP read is bounded per candidate.TASK_RUN_ABSOLUTE_CEILING_MSdefaults to 24 hours and terminates even a live-runtime task before any ACP liveness probe, providing a pure runaway-cost backstop.getTaskNodeId/isNodeHeartbeatRecenthelpers; corrected diagnostic elapsed-time to usestarted_atforin_progress/awaiting_followup.configuration.md,.env.example, and the canonical env-reference source document the new configuration, includingTASK_RUN_ABSOLUTE_CEILING_MS=86400000.Validation
pnpm typecheck(api clean after shared/providers/cloud-init build)pnpm linton changed files — 0 errors (2 pre-existing non-null-assertion warnings outside this diff)stuck-tasks,task-runner-state-machine,recovery-resilience,task-callback-recoverable-error— 90 passed (node pool + separate callback pool)tests/workers/scheduled-stuck-tasks.test.ts— passed in the rebased local fullpnpm testrun and in CITest.STUCK_TASK_MAX_CANDIDATES_PER_SWEEP(default 100) viaLIMIT. Worst-case per-candidate cost: one indexed workspace+node D1 read; the more expensive ACP-session read (listAcpSessions, bounded byTASK_LIVENESS_MAX_ACP_SESSIONS=5) only runs after cheap workspace/node liveness checks pass, so dead workspaces short-circuit before it. DOgetStatus()mismatch probe is gated byTASK_DO_MISMATCH_GRACE_MS. Every selected candidate now has a terminal escape path: without the absolute ceiling, a live-forever task with staleupdated_atwould sort to the front of theLIMITwindow on every tick, burn a liveness probe, and crowd out genuinely stuck work. The ceiling terminates that rule-47 immortal candidate before the DO probe.Staging Verification (REQUIRED for all code changes — merge-blocking)
N/A: no infra changes(no cloud-init / VM agent / DNS / TLS / scripts/deploy changes)N/A: no UI changesStaging Verification Evidence
Staging verification is being completed by follow-up task
01KXAX6D6F5AFKJQDC6WT7DGZKafter rebase onto currentmainand force-push. Theneeds-human-reviewlabel remains until staging evidence is recorded and all gates are green.End-to-End Verification
Data Flow Trace
scheduled()→recoverStuckTasks(env)(apps/api/src/scheduled/stuck-tasks.ts) selects bounded active/awaiting_followup candidates.getTaskRuntimeLiveness(env, task)readsworkspaces+nodes(D1) then task-scopedprojectDataService.listAcpSessions(ProjectData DO).env.TASK_RUNNER.get(...).getStatus(); ifcompleted&& D1 non-terminal && liveness conclusive-dead → mark stuck.failTaskpath writes terminal D1 status +taskStatusEvents+cleanupTerminalTaskResourcesOrThrow.state-machine.ts:transitionToInProgresszero-row update re-readstasks.statusand converges DO/D1.Untested Gaps
Full cron→DO→D1 reconciliation is covered by the Miniflare workers-pool test
scheduled-stuck-tasks.test.ts(recovery interleaving, callback idempotency, dead-mismatch, live-negative) — runs in CI. Live-agent negative case andaborted_by_recoveryinterleave have node-pool unit coverage that passed locally.Post-Mortem
What broke
Dead conversation-mode tasks (workspace deleted, agent gone) remained
in_progress/awaiting_followupfor up to 8 hours because the reconciler only logged DO-completed/D1-active mismatches and its soft-timeout grace trusted shared-node heartbeat as task liveness.Root cause
stuck-tasks.tsused node heartbeat (host liveness) as a proxy for task liveness and treated the DO/D1 mismatch as informational; thestate-machine.tszero-row optimistic update completed the DO without re-reading D1.Class of bug
Reporter/liveness-scope mismatch — a coarse-grained signal (shared-node heartbeat, DO "done") used as authority for a finer-grained entity (per-task/workspace/agent liveness), with a check-then-act race across the DO/D1 boundary.
Why it wasn't caught
No test asserted the DO-completed + D1-non-terminal reconciliation against composite liveness; existing coverage stopped at node heartbeat.
Process fix included in this PR
Adds cross-runtime regression coverage (workers-pool) for DO-completed+D1-active, recovery interleaving, live-negative, and callback idempotency, exercising the exact liveness contract that was violated. (No new
.claude/rulesfile — the existing rules 02/11/47 already cover this class; tests are the concrete guard.)Post-mortem file
Task record
tasks/active/2026-07-11-taskrunner-d1-lifecycle-reconciliation.md; idea01KT90PKF6167SXZ9YZY0R26MM.Specialist Review Evidence (Required for agent-authored PRs)
needs-human-reviewremains for Raphael handoffLIMIT-bounded, cheap workspace/node reads precede ProjectData DO ACP-session reads, ACP reads are bounded by count + timeout, inconclusive probes are fail-safe, and no D1 migrations/wrangler changes were introduced.pnpm testpassed.DEFAULT_*constants with env overrides and are documented in.env.example, env schema, env-reference, and public configuration docs. No new hardcoded URLs or deployment identifiers.RESOLVED: Option 2 implemented
A separate
TASK_RUN_ABSOLUTE_CEILING_MSbackstop now defaults to 24 hours and fails a task even when its ACP session keeps a fresh heartbeat. This intentionally deviates from the task literal "prevent early failure regardless of elapsed duration" wording.Justification:
updated_atsorts it to the front of theLIMITwindow every tick, burning a probe and crowding out genuinely stuck tasks. The ceiling check runs before the liveness probe.TASK_RUN_HARD_TIMEOUT_MSsemantics are unchanged; the new, higher ceiling is independent.awaiting_followupis not exempt.Exceptions (If any)
pnpm testand CI.Agent Preflight (Required)
Classification
External References
N/A: internal reconciliation logic. Grounded in read-only production D1/observability evidence (documented in the task file + idea01KT90PKF6167SXZ9YZY0R26MM) and existing.claude/rules/47-control-loop-io-budget.md,.claude/rules/11-fail-fast-patterns.md.Codebase Impact Analysis
apps/api/src/scheduled/stuck-tasks.ts(reconciler + liveness probe)apps/api/src/durable-objects/task-runner/state-machine.ts(race closure)apps/api/src/routes/tasks/callback.ts(idempotent terminal callback)apps/api/src/env.ts,packages/shared/src/constants/task-execution.ts+index.ts(config)apps/www/.../reference/configuration.md,apps/api/.env.example(docs)Documentation & Specs
Updated
apps/www/src/content/docs/docs/reference/configuration.mdandapps/api/.env.examplefor the three new env vars.Constitution & Risk Check
DEFAULT_*constants with env overrides.LIMITbound, tiered/gated per-candidate reads, terminal/skip escape path for every candidate.conclusivefail-safe.