fix(host-service): prune terminal-agent bindings on pty exit and at startup

Draft
KI
4mo ago
+215-9

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 — onExit broadcast terminal:lifecycle but never touched the store, so the renderer refetched and got the stale binding right back
  • reaper orphan cleanup and disposeSessionsByWorkspaceId call disposeSessionAndWait directly, bypassing the route's markTerminalExited
  • bindings are persisted to SQLite and rehydrated on start (load() only skips disposed, not exited), so all of the above survived restarts

How It Works

Two layers, deliberately minimal:

  • Prune on pty exit. EventBus gains an in-process onTerminalLifecycle listener (the existing broadcast only reaches ws clients); app.ts registers one that calls markTerminalExited(terminalId). Every in-process teardown path ends at the pty onExit handler, 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. reconcileTerminalAgentBindings runs 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: deleteTerminal deletes 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 -9 the 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-active by 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.