fix(host-service): prune terminal-agent bindings on pty exit and at startup
Draft
KI
4mo ago+215+215+012345678901234567890123456789-9-9-0123456789
Summary
- Terminal-agent bindings (which drive the sidebar's inline agent chips) are now pruned whenever their pty exits, and stale persisted bindings are drained once at host-service startup. Previously, killed/crashed terminals, reaper disposal, and workspace deletion all leaked bindings — and persistence made the leak survive restarts, so the sidebar kept showing agents for terminals that no longer exist.
Why / Context
Observed while testing #5439's activity strip: agents stayed in the sidebar after their terminal was removed. Root cause is upstream of the UI — the TerminalAgentStore only pruned in two places: an agent exit hook firing (Detached/exit/error via the notifications route), or the renderer's explicit terminal-dispose tRPC route. Every other end of life leaked:
- agent process killed or crashed (no hook fires)
- terminal exits on its own —
onExitbroadcastterminal:lifecyclebut never touched the store, so the renderer refetched and got the stale binding right back - reaper orphan cleanup and
disposeSessionsByWorkspaceIdcalldisposeSessionAndWaitdirectly, bypassing the route'smarkTerminalExited - bindings are persisted to SQLite and rehydrated on start (
load()only skipsdisposed, notexited), so all of the above survived restarts
How It Works
Two layers, deliberately minimal:
- Prune on pty exit.
EventBusgains an in-processonTerminalLifecyclelistener (the existing broadcast only reaches ws clients);app.tsregisters one that callsmarkTerminalExited(terminalId). Every in-process teardown path ends at the ptyonExithandler, so this covers kills, crashes, self-exit, and dispose routes — and the same handler's ws broadcast makes the sidebar refetch the now-pruned list. - Startup drain.
reconcileTerminalAgentBindingsruns once after store hydration and drops bindings whose session row is missing,exited/disposed, or workspace-less (the reaper's orphan criteria). This clears bindings leaked before this fix and terminals that died while the host-service was down. Supporting tweak:deleteTerminaldeletes the persisted row even without an in-memory binding, so the drain can purge rows whose sessions were never hydrated.
An earlier revision integrated reconciliation into the terminal reaper's periodic pass (with a daemon-liveness check); it was dropped to keep the maintenance surface small — see the PR comments for the trade-off. This version leaves reaper.ts and serve.ts untouched, which also avoids conflicting with the pending port-changes revert.
Manual QA Checklist
- With an agent bound, kill the terminal process externally (
kill -9the pty child) → agent chip disappears from the sidebar - Close the terminal from the UI → chip disappears
- Restart host-service with a stale persisted binding (terminal long gone) → chip does not come back
Testing
bun test src/terminal-agents(16 pass). New tests: persisted-row deletion without an in-memory binding, and a real in-memory-SQLite reconcile test covering live/exited/disposed/workspace-less sessions- Both new tests verified to fail against a mutated implementation (delete ordering, status filter)
bun run typecheck(host-service) and Biome clean
Known Limitations
- No periodic sweep: a session row left stale-
activeby a terminal that died while the host-service was down isn't caught until the next restart's drain. Accepted for surface area; the reaper-integrated version (see history) is the escalation path if this shows up in practice. - A drain-driven prune reaches an already-connected sidebar lazily (its cache invalidates on lifecycle events); in practice the startup drain runs before any renderer connects.
Summary by CodeRabbit
- Bug Fixes
- Improved recovery after interruptions: the host now reconciles persisted terminal bindings on startup and marks exited terminals based on terminal lifecycle events.
- Terminal lifecycle handling is more consistent, including in-process updates and reduced stale terminal state after downtime.
- Terminal binding listings now exclude defunct entries to prevent returning unusable/stale bindings.
- Persisted deletions are applied reliably even if a terminal was never loaded into memory.
- Tests
- Added coverage for binding reconciliation and reliable deletion behavior.