feat(desktop,host-service): auto-resume interrupted agent sessions idempotently
Summary
- Panes with an agent whose terminal died (killed daemon, reboot, crashed pty) now resume the session automatically instead of showing a "Resume" button — the pane re-points itself at the relaunched session with a passive "Resuming…" pill.
- Idempotency is anchored host-side: a durable atomic claim plus in-flight coalescing guarantee exactly one resumed session no matter how many panes, windows, StrictMode re-effects, or retries race.
Why / Context
The TerminalAgentResumeBanner made users click to restore a session that they almost always want back — and the click-driven flow was the only thing preventing duplicate resumes. Making resume automatic requires making it idempotent, since every mount/re-render/second-window becomes a potential trigger.
How It Works
- New
TerminalAgentEndReasonvalue"resumed"marks a consumed candidate.claimResumeCandidateBindingflipsterminal-exited → resumedin one atomic UPDATE guarded by the full candidate predicate (shared withfindResumeCandidateBindingviaresumeCandidatePredicate), so exactly one claimer gets the binding back.unclaimResumeCandidateBindingrolls back a claim whose relaunch failed. - New
terminalAgents.resumemutation runs the whole flow host-side: claim → relaunch via the agent's resume args (runAgentInWorkspacewith the savedagentSessionId) → dispose the dead terminal. Concurrent callers for the same terminal coalesce onto one in-flight promise (host-service is one process per host, so this covers multiple desktop windows) and share the same result. - The claim is sticky:
markTerminalAgentBindingEndedno-ops on already-ended rows, so late terminal-death marks can't resurrect a consumed candidate. - Renderer:
TerminalAgentResumeBanner→TerminalAgentAutoResume. Fires once per candidate session (ref-guarded), re-points the pane on success, and only surfaces UI on failure (retry + dismiss). The old renderer-sideagents.run+killSessionorchestration is deleted. The v1→v2 migration's seeded candidates flow through the same path, so migrated panes auto-resume too. - The mutation body is an exported
resumeTerminalAgentSession(deps, input)with the launch/dispose injected (production wrapper passes the real ones) so the orchestration is testable without spawning terminals.
Manual QA Checklist
Core scenario verified live (2026-08-17) in the dev desktop via CDP: staged a real claude session with a codeword in context, SIGKILLed its zsh+claude, and the pane re-pointed to a relaunched claude --resume <sessionId> in ~1s with zero clicks; asking the resumed session for the codeword returned it correctly. Ground truth in host.db: old binding flipped endReason=resumed, new binding attached with the same agent_session_id. Screen recording attached in the PR conversation.
- Kill an agent terminal's pty → pane re-points at the relaunched session with the agent label as title (resume was faster than the "Resuming…" pill could meaningfully show)
- Concurrent callers, same terminal (the app is single-window, so the real concurrency source is multiple clients): two parallel
resumeHTTP calls returned the identical new terminalId in 7ms — one launch, shared result; a repeat call after completion returned{resumed: false} - Mass kill: 3 agents SIGKILLed at once, candidates formed, parallel resumes each launched exactly one distinct terminal (3 sessions, 3 live
claude --resumeprocesses, no duplicates) - Death-gasp path (SIGHUP so claude runs SessionEnd before the terminal dies) → candidate still forms as
terminal-exited - No pane watching → no resume: a dead headless session's candidate sits unconsumed until a pane mounts (by design; matches the old banner's reach)
- Resume failure (e.g. agent config removed mid-flight) → failure banner with working Retry (host rollback path is unit- and mutation-tested; banner not yet exercised live)
- Cleanly exited agent (
SessionEnd) → no auto-resume (covered by unit tests on the candidate predicate) - v1→v2 migrated pane with a captured session auto-resumes on first open
Edge found live and fixed in this PR
Testing surfaced a real resurrection bug: killSession marked bindings terminal-exited — indistinguishable from a crash — so a deliberately killed session became a resume candidate, and a pane still pointed at it auto-relaunched the agent at the next app boot (observed: a teardown-killed agent came back 2 hours later). Fixed with a sticky "disposed" end reason stamped before the kill; verified live that killSession'd bindings now record disposed and never resume, with a unit test asserting neither the SIGHUP death-gasp nor the pty-exit event can upgrade it back.
Testing
bun test src/terminal-agents src/trpc/router/terminal-agents— 48 pass, including 10 new: 4 claim/unclaim persistence tests (single consumption, non-candidate refusal, unclaim-for-retry, claim survives late death-marking) and 6 orchestration tests (happy path, repeat-call idempotency, concurrent-caller coalescing, unclaim-on-failure + retry, no-candidate, resume-unsupported keeps the candidate)- Mutation-tested: removing the claim's
endReasonguard fails 3 tests; removing unclaim-on-failure fails the retry test bun run typecheck(host-service + desktop),biome checkclean
Design Decisions
- Host-side claim instead of renderer guards: only the host can serialize across windows and survive re-mounts; a durable DB flip also protects against a stale pane (in another window still pointed at the dead terminal) re-triggering after success.
resumedend reason instead of deleting the binding: keeps the session id recoverable (unclaim on launch failure) and keepsterminal-exitedsemantics intact for every other consumer.- Failure keeps the candidate: config-gone/resume-unsupported and launch failures un-claim rather than consume, so a session id is never silently destroyed.
Known Limitations
- No renderer unit test for
TerminalAgentAutoResume(thin glue over the mutation; the orchestration it triggers is covered host-side). - After a mass outage (reboot), every dead agent pane resumes at once — that's N parallel agent launches, same as N manual clicks, but now unprompted.
Summary by cubic
Auto-resumes interrupted terminal-agent sessions and prevents resurrecting deliberately killed sessions. Previously panes showed a “Resume” button and some kills became resume candidates; now panes auto-repoint with a passive “Resuming…” pill, and deliberate kills are marked non-resumable.
- Host (
packages/host-service): addsterminalAgents.resumeto claim a candidate atomically (terminal-exited → resumed), relaunch with saved resume args, coalesce concurrent callers across windows, dispose the dead terminal, and unclaim on failure so retries work. Introduces sticky end reasondisposedstamped before kills, so deliberate kills never become candidates and late death events cannot revive them. - DB/types: adds
TerminalAgentEndReasonvaluesresumedanddisposed; candidate predicate excludes clean exits,disposed, and never-started conversations. - Renderer (
apps/desktop): replacesTerminalAgentResumeBannerwithTerminalAgentAutoResume. Attempts once per candidate, re-points pane data/title on success, announces status viaaria-live, logs normalized failures, invalidates candidates on{ resumed: false }, and shows only failure UI (Retry + Dismiss). Failure/dismiss state is scoped to the candidate and resets when a new candidate appears. - Migration: v1→v2 migrated panes with seeded candidates now auto-resume; no action required.
Summary by CodeRabbit
-
New Features
- Automatically resumes eligible terminal-agent sessions after reconnection.
- Restores sessions using saved state and updates the terminal pane and title after successful resumption.
- Displays progress, retry, and dismiss controls when resumption fails.
- Prevents duplicate resume attempts and preserves sessions for retry after unsupported or failed launches.
- Deliberately terminated sessions no longer resume automatically.
-
Tests
- Added coverage for successful, concurrent, unsupported, failed, and retryable resume scenarios.