fix(api): a slept, restorable session is not conclusive runtime death - #1844
Merged
simple-agent-manager[bot] merged 8 commits intoAug 17, 2026
Merged
Conversation
…ime death classifyTaskRuntimeLiveness read only workspaces.status. NodeLifecycle rewrites a slept workspace's 'sleeping' status to 'deleted' 5 minutes after sleep, so the classifier declared conclusive death for sessions whose session_snapshots row was asleep, unexpired and fully restorable -- while loadRecoveryContext, the code that actually wakes a session, never reads workspaces.status at all. Consult the sleep record before terminalizing. Bounded by expires_at so a snapshot can never make a task immortal. Probed only for a workspace that would otherwise be failed, keeping the extra read off the control-loop hot path.
… adapters Exercises the real cron and ProjectData adapters against a real SQLite engine (rule 28 -- the resumability guard is a SQL predicate, so a where()-ignoring mock could not prove it filters). Includes the user-delete control, the expiry bound, and cross-project/cross-workspace attack fixtures.
A shared lifecycle classifier guaranteed the cleanup paths agreed with each other and said nothing about whether they agreed with the resumer. Rule 58 closes that gap; rule 02 now points at it.
Contributor
Specialist review found the resumability predicate mirrored only loadRecoveryContext, while the function that actually authorizes a wake is claimSessionSnapshotRecovery. Its WHERE clause also requires a restorable status/degradation pair and recovery_attempts < max. That made the classifier LOOSER than the resumer — the inverse of the original bug. A snapshot whose wake attempts are exhausted would be preserved for the full 7-day TTL waiting on a wake that can never happen. isSessionResumable now mirrors the claim exactly: - shared isRestorableSnapshot helper (extracted so SQL and in-memory twins cannot drift) - env-configurable SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS ceiling threaded through both adapters, giving a second bounded escape alongside expires_at - in-memory projectId re-check, so both scoping predicates now have the SQL + in-memory pair rule 28 asks for Tests: the degraded-snapshot cases are now genuinely discriminating (the code reads those fields), the running-workspace control asserts the resumability branch was not taken, and the cron adapter's own probe-error path is covered. All 7 new guards verified to fail when removed. Rule 58 amended: find the whole resumer before mirroring it. Co-Authored-By: Claude <noreply@anthropic.com>
The Specialist Review Evidence check reads the PR body from GITHUB_EVENT_PATH,
which is the event snapshot taken at push time. The previous push happened while
all three reviewers were still PENDING, so the check captured that state and a
later body edit cannot update it (ci.yml does not subscribe to `edited`).
Both checkers pass locally against the live body:
Preflight evidence check passed.
Specialist review evidence check passed.
cloudflare-specialist (ADDRESSED), test-engineer (ADDRESSED),
task-completion-validator (ADDRESSED)
Co-Authored-By: Claude <noreply@anthropic.com>
|
simple-agent-manager
Bot
deleted the
sam/fix-production-workspace-reaping-0t2e0e
branch
August 17, 2026 12:50
32 tasks
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
Production has been terminalizing restorable task sessions as
failedsince 2026-08-06 with"Task runtime is conclusively gone after reconciliation grace (workspace_deleted)."— 31+ tasks,still firing on 2026-08-17. Their
session_snapshotsrows were asleep, unexpired, and wakeable foranother seven days at the moment SAM declared them dead.
The chain is composed entirely of individually-correct steps:
end_turn); the task staysin_progress/awaiting_followup."aggressively sleep idle sessions" policy asks for.
workspaces.statusbecomessleeping;session_snapshots.sleep_status='sleeping'withexpires_atseven days out.NodeLifecyclerunsUPDATE workspaces SET status='deleted' ... WHERE status IN ('stopped','sleeping')(
apps/api/src/durable-objects/node-lifecycle.ts:570-573) — rewriting the inconclusivesleepingmarker into the conclusivedeletedmarker.classifyTaskRuntimeLivenessreadsworkspaces.status, seesdeleted, returnsconclusive: true, and the stuck-task sweep writesfailed.This PR fixes only step 4. The sleep predicate in
apps/api/src/services/session-sleep.tsisdeliberately untouched — a parallel task owns it, and sleeping an idle session is correct behaviour.
The core defect
The destroyer and the resumer disagree about what "gone" means.
loadRecoveryContext(apps/api/src/services/session-recovery.ts:85) — the code that actually wakesa slept session — never reads
workspaces.status. A workspace row readingdeletedis stillfully wakeable. The classifier's only workspace signal is
workspaces.status, and it never readsession_snapshots..claude/rules/02-quality-gates.mdalready requires sleep/wake/restore/unknownto be treated as inconclusive; the classifier violated that.
The fix
Teach the shared classifier the one thing it was missing: whether the session is currently asleep and
restorable. When the workspace row exists but is not
running, and a live sleep record exists, theverdict becomes inconclusive (
workspace_<status>_snapshot_resumable) instead of conclusive death.The resumability predicate mirrors the resumer, plus one bound the resumer lacks:
session_snapshotsrow scoped to the sameproject_idandworkspace_id(rule 11)sleeping_at IS NOT NULL— a user delete destroys the snapshot row entirely(
session-snapshot-persistence.ts:deleteSessionSnapshotState), so snapshot presence discriminatesidle-sleep from user deletion without needing a new
deleted_reasoncolumnsleep_status = 'sleeping'— asleep now, not a stale marker from a session that already wokeexpires_atparses and is in the future — the bounded escape (rule 47). An absent orunparseable expiry counts as NOT resumable, so no task can become immortal.
workspace_missing(row absent) stays conclusive, becauseloadRecoveryContextrequires theworkspace row to exist (
session-recovery.ts:91-94). The boundary is chosen to match the resumer exactly.All three terminalization paths (stuck-task cron,
processExpiredCleanups,checkWorkspaceIdleTimeouts)funnel through this one classifier, so fixing it here fixes all three (DRY) rather than bolting separate
guards into each sweep. Both adapters are wired per rule 44.
Validation
pnpm lintpnpm typecheckpnpm testControl-loop I/O budget (rule 47)
This PR does not widen candidate selection — the candidate set is unchanged. It adds at most one
extra D1 point lookup per candidate, gated by
needsSessionResumabilityProbe()so it fires only fora workspace that would otherwise be declared conclusively dead (
status !== 'running', not already inINCONCLUSIVE_WORKSPACE_STATUSES,chat_session_idpresent, workspace readok). Steady-state cost istherefore zero for healthy candidates. The lookup is a point read on the uniquely-indexed
session_snapshots.chat_session_id. Worst case (every candidate about to be terminalized) is one indexedpoint read per candidate, bounded by the sweep's existing candidate cap.
Bounded escape (rule 47): a preserved task is not immortal — preservation ends when
session_snapshots.expires_atpasses, at which point the task terminalizes normally. Absent/unparseableexpiry → terminal. Covered by tests.
Staging Verification (REQUIRED for all code changes — merge-blocking)
32025672280, SHAe1dd5e197(the review-fixed code)token-loginN/A: no infra changes(nopackages/cloud-init/,packages/vm-agent/,scripts/deploy/, DNS, or TLS paths touched)N/A: no UI changesStaging Verification Evidence
The incident chain, reproduced live end-to-end
Submitted a real task on staging (
01M07RYZX0W1GPHK9CBE351CT2, projecthono) and let it run theexact production sequence on real infrastructure — a real Hetzner VM, the real sleep sweep, the real
NodeLifecycleTTL, and the real stuck-task cron. Verdicts read fromGET /api/admin/tasks/:id/reconciliation-diagnostics, which calls the exact function this PRchanges (
getTaskRuntimeLiveness) read-only:sleep_statusrunningconclusive=truetask_acp_session_livesleepingsleepingconclusive=falseworkspace_sleeping_resumabledeletedsleepingconclusive=falseworkspace_deleted_snapshot_resumable12:22:13 is the exact moment the bug used to fire.
NodeLifecyclerewrotesleeping→deleted, and pre-fix the classifier would have returnedconclusive=true/workspace_deleted,causing the sweep to write
failed. Instead the task was preserved, and stayed preserved across~9 minutes of subsequent 5-minute cron sweeps.
Final diagnostics while the workspace was
deleted:{ "status": "in_progress", "executionStep": "awaiting_followup", "decision": "preserve_inconclusive_runtime", "eligible": true, "liveness": { "live": false, "conclusive": false, "reason": "workspace_deleted_snapshot_resumable", "workspaceStatus": "deleted" } }eligible: trueis the load-bearing part — the task was past the reconciliation grace threshold, sothe sweep genuinely evaluated it and chose
preserve_inconclusive_runtime. It was not merelyskipped.
error_messagestayedNULLthroughout. Therunningrow is the owner-path control: ahealthy workspace is never diverted onto the resumability branch.
The bug's fingerprint is present on staging
Staging D1 independently shows the untreated shape: 15 snapshots with
sleep_status='sleeping',all 196 workspaces at
status='deleted', and 11 tasks historically failed withworkspace_deleted— three of whose snapshots wereavailable/unexpired at kill time (one withrecovery_attempts=0,expires_atsix days out). Those are prior victims of exactly this defect.Regression pass (Playwright, staging)
token-login→ HTTP 200, then walked the core surfaces. All render real data (asserted on liveproject names, not just chrome — an early run sampled at 3s and caught the pre-hydration shell, so
the check now waits for the projects list to populate):
Mobile (375×667) dashboard: no horizontal overflow. Total console errors across the run: 0.
Screenshots in
.codex/tmp/playwright-screenshots/pr1844-*.png.Cleanup
Node
01M07RZ3WTVJWJ81WFTF4MMFBSdeleted immediately after verification; staging D1 confirmszero non-deleted nodes, so no Hetzner capacity is held from the shared limit. The test task was
transitioned to
cancelled(notfailed), leaving no misleading lifecycle record.UI Compliance Checklist (Required for UI changes)
N/A: no UI changes— this PR touches onlyapps/api/src/,apps/api/tests/,.claude/rules/, andtasks/.End-to-End Verification (Required for multi-component changes)
Data Flow Trace
Untested Gaps
N/A: full flow covered by automated testsfor the classifier boundary, with one nuance recorded forreviewers: the cross-workspace SQL scoping predicate is defended at two layers — the
AND workspace_id = ?clause inloadSessionResumabilitySnapshot()and an in-memorysnapshot.workspaceId !== workspaceIdguard inisSessionResumable()(rule 28 defence-in-depth). Whenthe SQL predicate is deleted, the cross-workspace test still passes because the in-memory guard catches
it; the cross-project test does go red. Both layers are intentional and both are exercised; the
in-memory guard is what the cross-workspace assertion discriminates on.
Discrimination proof (rule 58 requires this to be verified once)
Simulated pre-fix code by neutralizing the resumability branch and re-ran the suite:
Exactly the 8 incident/preserve assertions go red; all 39 controls stay green — including
user-deleted-no-snapshot, expired snapshot, already-woke,
workspace_missing, cross-project,cross-workspace, and running-workspace. That proves the suite is not simply asserting
"terminalization is disabled".
The seven guards added during review were verified the same way — removing the
isRestorableSnapshot,recoveryAttempts, and in-memoryprojectIdchecks turns exactly thoseseven cases red (
7 failed | 51 passed) and nothing else.Separately, deleting the
AND project_id = ?scoping predicate turnsignores a snapshot belonging to a different projectred, proving that predicate discriminatingagainst a real SQL engine (
better-sqlite3+createSqliteD1+createSchemaTables, rule 28 —not a
.where()-ignoring mock).Real-schema verification (staging D1, read-only)
Local tests build tables from the drizzle definitions, which cannot catch drift against the
applied migrations. Verified directly against staging D1 per
.claude/rules/32:PRAGMA table_info(session_snapshots)— every column the query reads exists and isTEXT(so
Date.parseon ISO strings is correct), withexpires_at NOT NULLidx_session_snapshots_chat_session_idis a UNIQUE index, confirming the lookup is a truepoint read and substantiating the I/O-budget claim above
Post-Mortem (Required for bug fix PRs)
What broke
Task-mode sessions were terminalized as
failedwith"Task runtime is conclusively gone after reconciliation grace (workspace_deleted)."while their workwas intact and restorable. Users saw a red "Task failed" on work that had merely gone to sleep. 31+ tasks
since 2026-08-06, still firing on 2026-08-17 — including the SAM task that was originally writing this
very fix, which was killed mid-flight by the bug it was fixing.
Root cause
classifyTaskRuntimeLivenessdecided recoverability fromworkspaces.statusalone, whileloadRecoveryContext— the code that actually wakes a slept session — decides fromsession_snapshots.sleeping_atand never readsworkspaces.statusat all.NodeLifecyclerewrites aslept workspace's
sleepingstatus todeletedfive minutes after sleep(
node-lifecycle.ts:570-573, predicatestatus IN ('stopped','sleeping')), collapsing the oneinconclusive marker the classifier understood into a conclusive one. From that moment the classifier and
the resumer disagreed, and the classifier won.
Class of bug
Destroyer/resumer signal divergence — two subsystems answering "is this work still recoverable?" from
different records, with a third path (a TTL sweep) mutating only the one the destroyer reads. Not covered
by the existing rules, which addressed cleanup paths disagreeing with each other rather than cleanup
disagreeing with recovery.
Why it wasn't caught
promptingsession. Both DO idle sweeps route through the shared classifier and preserve unless it saysdead. The classifier's logic is sound given its inputs. There was no single wrong line to find in review.
.claude/rules/02already required a single sharedlifecycle classifier, and SAM had one. That guaranteed the cleanup paths agreed with each other — and
said nothing about whether they agreed with the resumer.
but no test slept a session and then asked the classifier about it. The existing
workspace_deletedassertions all used no-snapshot fixtures, so they encoded the buggy verdict as expected behaviour.
The idle detector was innocent; only the production D1 audit trail (
session_snapshotsrows stillsleeping/unexpired at kill time) disproved that framing.Process fix included in this PR
.claude/rules/58-terminal-verdicts-must-match-the-resumer.md(new): any verdict that work isunrecoverable must be derived from the record the resumer requires, must name that resumer function in a
comment, must be bounded by the artifact's own retention (absent bound → terminal, so nothing becomes
immortal), must withhold the verdict when the lookup fails, must keep the probe off the hot path, and
must ship both an incident reproduction and a discriminating control proving terminalization still fires.
.claude/rules/02-quality-gates.mdamended to state that a shared lifecycle classifier is necessarybut not sufficient, pointing at rule 58.
Post-mortem file
tasks/active/2026-08-17-fix-slept-session-classified-as-dead.md(moves totasks/archive/in this PR),including the full production D1 audit trail for both reported kills.
Specialist Review Evidence (Required for agent-authored PRs)
needs-human-reviewlabel added and merge deferred to human — N/A, all three completede1dd5e1: the predicate mirrored onlyloadRecoveryContext, not the real authorizerclaimSessionSnapshotRecovery.e1dd5e1: non-discriminating degraded-snapshot tests, a weak running-workspace control, the cron adapter's untested probe-error path, missing in-memoryprojectIdre-check, and unparseable-expiry only covered at the pure-function boundary.tasks/archive/while the file sat intasks/active/) fixed by archiving the task file ine1dd5e1.What review changed (all fixed in-branch, nothing deferred)
The most valuable finding was that the first implementation was looser than the resumer, not
just tighter.
loadRecoveryContextmerely assembles context; the function that actually authorizesa wake is
claimSessionSnapshotRecovery(
apps/api/src/services/session-snapshot-recovery-lifecycle.ts), whoseWHEREalso requires arestorable
status/degradationpair andrecovery_attempts < max. Mirroring only the firstfunction meant a snapshot whose wake attempts were exhausted would be preserved for the full
7-day TTL waiting on a wake that can never happen — the inverse failure to the original bug.
isSessionResumablenow mirrors the claim exactly:isRestorableSnapshothelper, extracted so the SQL predicate and its in-memory twincannot drift apart
SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTSceiling threaded through bothadapters — a second bounded escape alongside
expires_atprojectIdre-check, so both scoping predicates now have the SQL + in-memory pairrule 28 asks for
The predicate now lines up 1:1 with
claimSessionSnapshotRecovery's own unavailability reasons(
snapshot_missing,snapshot_expired,snapshot_not_complete,recovery_attempts_exhausted).Rule 58 gained a "find the whole resumer before you mirror it" section, since this failure mode
is the natural next mistake after the one the rule was written for.
Exceptions (If any)
Agent Preflight (Required)
Classification
External References
N/A: no external API surface consulted— the change is entirely internal to SAM's own task-livenessclassifier. Grounding evidence came from production Cloudflare D1 (
sam-prod,sam-observability-prod)queried directly per
.claude/rules/32-cf-api-debugging.md:task_status_events,session_snapshots,and
workspacesrows for the two reported kills, plus the 31-taskworkspace_deletedaudit. In-repoofficial documentation consulted:
.claude/rules/02-quality-gates.md,.claude/rules/47,.claude/rules/53,.claude/rules/57,.claude/rules/44,.claude/rules/28,.claude/rules/35.Codebase Impact Analysis
apps/api/src/services/task-runtime-liveness.ts— new shared resumability signal(
SessionResumabilitySnapshot,isSessionResumable,needsSessionResumabilityProbe,loadSessionResumabilitySnapshot) and the new inconclusive branch inclassifyTaskRuntimeLivenessapps/api/src/scheduled/stuck-tasks.ts— adapter 1 (getTaskRuntimeLiveness) wiredapps/api/src/durable-objects/project-data/task-runtime-liveness.ts— adapter 2(
getLocalTaskRuntimeLiveness, feedingprocessExpiredCleanups+checkWorkspaceIdleTimeouts) wiredapps/api/tests/unit/services/task-runtime-liveness.test.ts— classifier unit coverageapps/api/tests/unit/stuck-task-slept-session-liveness.test.ts— new vertical slice against a realSQL engine for both adapters
.claude/rules/58-terminal-verdicts-must-match-the-resumer.md,.claude/rules/02-quality-gates.md— process fixtasks/archive/2026-08-17-fix-slept-session-classified-as-dead.md— task record + post-mortemRead-but-unmodified (boundary of the change):
apps/api/src/services/session-recovery.ts,apps/api/src/services/session-sleep.ts,apps/api/src/scheduled/session-sleep.ts,apps/api/src/durable-objects/node-lifecycle.ts. No changes topackages/,apps/web,scripts/,infra/, orspecs/.Documentation & Specs
.claude/rules/58-terminal-verdicts-must-match-the-resumer.md(new) and.claude/rules/02-quality-gates.md(amended). No
apps/www/src/content/docs/change is required: this fixes an internal control-loop verdictand changes no user-facing or self-hoster-facing behaviour, configuration, env var, or API contract.
Constitution & Risk Check
per-row
session_snapshots.expires_at, which is written from the already-env-configurable snapshot TTL;this PR introduces no new timeout, limit, URL, or identifier.
action is terminalization, so an unknown answer (
resumabilityProbeOutcome === 'error') withholds thedeath verdict rather than proceeding. Rule 58 requirement 4.
expires_atbounded escape, withabsent/unparseable expiry treated as terminal. Covered by tests and by the discriminating control set.
workspace_missingconclusive and by theuser-delete control test — a user-deleted workspace (snapshot row destroyed) terminalizes exactly as before.
needsSessionResumabilityProbe()gating; zeroextra reads for healthy candidates.