Summary
When the desktop backend exits ungracefully (force-kill, crash, or OOM), any in-flight agent turns are left frozen at state = "running" in state.sqlite. On the next launch nothing reconciles them, and when such a session is later resumed the provider reports its cut-off turn as cancelled / aborted — so the user sees their threads as "cancelled" rather than resumed. Critically, the auto-resume feature does not cover this case, which is arguably the most important crash-recovery scenario.
A clean quit does the right thing; only ungraceful exit is affected.
Impact
- After any hard app/backend termination while agents are mid-turn, those threads surface as cancelled with no automatic recovery.
- This is the exact scenario auto-resume looks like it should handle, but doesn't.
Steps to reproduce
- Start one or more agent turns so they are actively running (
provider_session_runtime.status = running, projection_turns.state = running).
- Terminate the backend ungracefully — e.g. Windows
Stop-Process -Force (TerminateProcess), a crash, or an OOM kill (so no SIGTERM/SIGINT is delivered).
- Relaunch the app.
Expected: interrupted turns are recovered/resumed, or at minimum settled to a resumable interrupted state.
Actual: turns stay frozen at running; on resume the agent reports the old turn as cancelled/aborted, and auto-resume never re-arms them.
Root cause (traced at b8323ef5)
- The graceful-shutdown settle is an Effect finalizer that only runs on clean scope close:
ProviderService.runStopAll (apps/server/src/provider/Layers/ProviderService.ts:1011-1069, finalizer at :1061) → adapter.stopAll() → completeTurn(..., "interrupted", "Session stopped.") (apps/server/src/provider/Layers/ClaudeAdapter.ts:3055-3056). It is wired to SIGTERM with a 2s grace (apps/desktop/src/backend/DesktopBackendManager.ts:348-349) and the server's NodeRuntime SIGTERM/SIGINT handlers (apps/server/src/bin.ts:58-63).
- A
TerminateProcess/SIGKILL/OOM delivers no signal, so the finalizer never runs. Turn rows stay running with no completed_at; provider_session_runtime rows stay running with a non-null active_turn_id.
- No startup reconciliation transitions leftover
running rows: apps/server/src/serverRuntimeStartup.ts:340-347 only starts reactors; apps/server/src/orchestration/Services/ProjectionPipeline.ts:18-24 replays from a stored cursor (a persisted running stays running); apps/server/src/provider/Layers/ProviderSessionReaper.ts:36-104 is an idle sweep that explicitly skips sessions with an active turn (:64-71).
- The durable projection has no
"cancelled" state (apps/server/src/persistence/Services/ProjectionTurns.ts:27-34); "cancelled" originates at the provider boundary on resume (apps/server/src/provider/Layers/CodexAdapter.ts:193-205,788-798; ClaudeAdapter.ts:997-1010; apps/server/src/provider/acp/AcpSessionRuntime.ts:731-732).
Why auto-resume doesn't catch it
- The reactor only triggers on a live Claude
account.rate-limits.updated status:"rejected" event (apps/server/src/t3x/autoResume/Reactor.ts:126-133) — there is no such event after a crash.
- The guard
threadIsProgressing treats latestTurn.state === "running" as "progressing" and skips resume (apps/server/src/t3x/autoResume/guards.ts:106-135) — which is exactly the state a force-killed turn is frozen in.
Suggested remediation (directions)
- Boot reconciliation: on server start, scan
provider_session_runtime / projection_turns for running rows with no live process and settle them to interrupted (resumable), mirroring what runStopAll would have done.
- Optionally distinguish "ungracefully interrupted" turns so they can be auto-resumed on next launch (extend auto-resume beyond the rate-limit path to a crash-recovery path).
- Consider a heartbeat /
last_seen_at staleness check so a frozen running turn (e.g. last_seen_at far in the past while status is running) is treated as interrupted rather than in-progress.
Observed evidence
On an affected machine, ~/.t3/userdata/state.sqlite showed multiple turns at state='running' with completed_at = NULL and provider_session_runtime.last_seen_at frozen minutes in the past, while the corresponding threads presented as "cancelled" in the UI. No cancel/abort/error event was recorded in orchestration_events for the affected window — consistent with the missing finalizer path rather than an explicit cancellation.
Environment
- Fork
radroid/t3code, desktop build at commit b8323ef5, version 0.0.28, Windows x64 (NSIS), provider claudeAgent, "this device" backend.
Summary
When the desktop backend exits ungracefully (force-kill, crash, or OOM), any in-flight agent turns are left frozen at
state = "running"instate.sqlite. On the next launch nothing reconciles them, and when such a session is later resumed the provider reports its cut-off turn ascancelled/aborted— so the user sees their threads as "cancelled" rather than resumed. Critically, the auto-resume feature does not cover this case, which is arguably the most important crash-recovery scenario.A clean quit does the right thing; only ungraceful exit is affected.
Impact
Steps to reproduce
provider_session_runtime.status = running,projection_turns.state = running).Stop-Process -Force(TerminateProcess), a crash, or an OOM kill (so noSIGTERM/SIGINTis delivered).Expected: interrupted turns are recovered/resumed, or at minimum settled to a resumable
interruptedstate.Actual: turns stay frozen at
running; on resume the agent reports the old turn ascancelled/aborted, and auto-resume never re-arms them.Root cause (traced at
b8323ef5)ProviderService.runStopAll(apps/server/src/provider/Layers/ProviderService.ts:1011-1069, finalizer at:1061) →adapter.stopAll()→completeTurn(..., "interrupted", "Session stopped.")(apps/server/src/provider/Layers/ClaudeAdapter.ts:3055-3056). It is wired toSIGTERMwith a 2s grace (apps/desktop/src/backend/DesktopBackendManager.ts:348-349) and the server'sNodeRuntimeSIGTERM/SIGINT handlers (apps/server/src/bin.ts:58-63).TerminateProcess/SIGKILL/OOM delivers no signal, so the finalizer never runs. Turn rows stayrunningwith nocompleted_at;provider_session_runtimerows stayrunningwith a non-nullactive_turn_id.runningrows:apps/server/src/serverRuntimeStartup.ts:340-347only starts reactors;apps/server/src/orchestration/Services/ProjectionPipeline.ts:18-24replays from a stored cursor (a persistedrunningstaysrunning);apps/server/src/provider/Layers/ProviderSessionReaper.ts:36-104is an idle sweep that explicitly skips sessions with an active turn (:64-71)."cancelled"state (apps/server/src/persistence/Services/ProjectionTurns.ts:27-34);"cancelled"originates at the provider boundary on resume (apps/server/src/provider/Layers/CodexAdapter.ts:193-205,788-798;ClaudeAdapter.ts:997-1010;apps/server/src/provider/acp/AcpSessionRuntime.ts:731-732).Why auto-resume doesn't catch it
account.rate-limits.updatedstatus:"rejected"event (apps/server/src/t3x/autoResume/Reactor.ts:126-133) — there is no such event after a crash.threadIsProgressingtreatslatestTurn.state === "running"as "progressing" and skips resume (apps/server/src/t3x/autoResume/guards.ts:106-135) — which is exactly the state a force-killed turn is frozen in.Suggested remediation (directions)
provider_session_runtime/projection_turnsforrunningrows with no live process and settle them tointerrupted(resumable), mirroring whatrunStopAllwould have done.last_seen_atstaleness check so a frozenrunningturn (e.g.last_seen_atfar in the past while status isrunning) is treated as interrupted rather than in-progress.Observed evidence
On an affected machine,
~/.t3/userdata/state.sqliteshowed multiple turns atstate='running'withcompleted_at = NULLandprovider_session_runtime.last_seen_atfrozen minutes in the past, while the corresponding threads presented as "cancelled" in the UI. Nocancel/abort/errorevent was recorded inorchestration_eventsfor the affected window — consistent with the missing finalizer path rather than an explicit cancellation.Environment
radroid/t3code, desktop build at commitb8323ef5, version0.0.28, Windows x64 (NSIS), providerclaudeAgent, "this device" backend.