Skip to content

Ungraceful backend exit leaves in-flight turns frozen as running → surface as cancelled; auto-resume doesn't recover them #5

Description

@eddy-curly

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

  1. Start one or more agent turns so they are actively running (provider_session_runtime.status = running, projection_turns.state = running).
  2. Terminate the backend ungracefully — e.g. Windows Stop-Process -Force (TerminateProcess), a crash, or an OOM kill (so no SIGTERM/SIGINT is delivered).
  3. 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)

  1. 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.
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions