Repository navigation
Make the rail's view of terminal-backed sessions truthful - #23
Conversation
Three ways the rail lied about the sessions it was supervising, all of which present as "integrating terminal threads in the sidebar doesn't work". A closed session stayed in the rail. `observe` fires on `notify()`; opening and closing a session is an `emit` (`ThreadOpened` / `ThreadClosed`), and the store does not also notify. The Agent Threads panel subscribes and repaints on both; the rail only observed, so the two surfaces this fork documents as unable to disagree disagreed on exactly add and remove. The rail now subscribes as well as observing, so a closed session's row leaves instead of lingering as something the user can click into nothing. A dead session kept its last status dot. Agent terminals are spawned with `HideStrategy::Never`, so an exiting agent leaves its tab, its `TerminalView`, and its store entry alive -- and nothing observed `ProcessExited`, so the stored classification from while the agent was alive (usually a green `Running`) stood indefinitely. Agent threads now reclassify on exit. Plain shells already re-derive their status from the screen tail on every read, but nothing woke them to do it, so the registry's subscription now observes the exit too. A new `AttentionTrigger::ProcessExited` exists because the existing triggers both leave an inconclusive classification alone, which is right for mid-turn output and wrong for a process that no longer exists: there, "not working" is the only possible answer. A row that could not be focused did so silently. `focus_thread` fails with "agent thread no longer exists" / "pane closed" precisely when a row holds a stale `terminal_item_id`, which is the case worth diagnosing, and `let _ =` made that click indistinguishable from one the user simply missed. Both call sites now log. No state-machine change: `reclassify_attention` already owned the `Working`/`Blocked`/`Idle` decision, so the exit case reuses it rather than adding a parallel path.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Graphify review — findings
Makes the rail track session lifecycle. It now subscribes to AgentThreadStoreEvent, so opened and closed sessions repaint immediately instead of leaving stale rows, and it repaints on terminal::Event::ProcessExited. An exited agent's inconclusive classification now falls back to ThreadAttention::Idle via a new ProcessExited trigger rather than keeping its last Running dot. Rail clicks also log_err failures from focus_thread and focus_priority_terminal instead of discarding them, so a stale terminal_item_id leaves a diagnostic.
Worth a look
- Exited agent keeps a Running status when its final screen tail still classifies as working —
crates/agent_threads/src/store.rs:1467· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 450 functions depend on the 297 functions this change touches.
Health — grade A; 10 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):
spawn_live_codex_thread()— 20 callers, 6 callees (high)dispatch_terminal()— 10 callers, 10 callees (high)prepare_managed_agent()— 4 callers, 7 callees (medium)dispatch()— 4 callers, 6 callees (medium)retie_thread()— 6 callers, 4 callees (medium)start()— 2 callers, 9 callees (medium)init_test_with_state()— 1 callers, 16 callees (high)launch_seeded_thread_at()— 2 callers, 7 callees (medium)- …and 2 more
Verification — 450 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 450 function(s) in the blast radius were not formally verified this run
Routing ProcessExited through reclassify_attention was wrong twice over: the trigger variant existed only to force an inconclusive classification to Idle, and the function's tail raises a desktop notification whenever a thread lands in a non-Working state. A thread already flagged as blocked and then killed therefore notified twice -- once for the bell that flagged it, once for the exit that ended it. Caught by panel::tests::bell_does_not_request_attention_for_the_active_window, whose echo agent exits during the test. The exit is now its own transition. It settles the thread to Idle and emits ThreadUpdated, so the rail and the rollup reflect the change, but raises no notification: a process exiting is not a fresh "look at me" signal, and the user has already been told if the thread was blocked.
mark_exited settles a thread whose process exited to Idle, and this test's echo agent exits inside the 500ms debounce window it waits on -- so its blanket `None` assertion now fails on a transition that has nothing to do with Wakeup. Assert `Blocked` specifically instead. That is the invariant the test and its comment actually describe: Wakeup fires on every line of ordinary streaming output, so it must not raise the thread the way a deliberate bell does. `Idle` was never what this test was guarding against -- a classifiable Working-to-Idle transition already produced it via Wakeup -- and settling on exit is a separate transition with its own reasoning. The bell test, which asserts on notification counts, needed no change and is green.
Conflict was imports only: main brought in the rail search's `HighlightedLabel` and `FocusWorkspaceSidebarSearch`, this branch brought in `util::ResultExt` for the focus-error logging. Kept both. The ledger conflict dropped main's rail-search row, so it is restored -- both rows belong there.
The two commits between v0.11.4 and here were squash merges whose messages carry no `Release notes:` bullet, which is what script/draft-release-notes reads, so 0.11.5 would have shipped with the generic placeholder body. Supplying them here instead. Cargo.lock was also stale on main: #23 added `util` to dez_sidebar, and CI verifies the build without committing the lock, so the resolved dependency never landed. `cargo metadata --offline` picks it up without compiling. Release notes: - Added a search field to the workspace rail that filters by workspace name, session title, agent kind, and working directory, highlighting matches in place. Bound to `cmd-f` / `ctrl-f`. - Fixed closed agent sessions lingering in the workspace rail, agent sessions showing a stale status after their process exited, and rail session rows failing to focus silently.
Summary
Three ways the rail lied about the sessions it supervises. All three present as "integrating terminal threads in the sidebar doesn't work".
1. A closed session stayed in the rail.
observefires onnotify(). Opening and closing a session is anemit(ThreadOpened/ThreadClosed), and the store does not also notify. The Agent Threads panel subscribes and repaints on both (panel.rs:741-758); the rail only observed (sidebar.rs:317-322). So the two surfaces this fork documents as unable to disagree disagreed on exactly add and remove. The rail now subscribes as well as observing.2. A dead session kept its last status dot. Agent terminals are spawned with
HideStrategy::Never, so whencodexquits the tab, theTerminalView, and the store entry all survive — and nothing observedProcessExited, so the stored classification from while the agent was alive (usually a greenRunning) stood indefinitely. A newAgentThreadStore::mark_exitedsettles the thread toIdleand emitsThreadUpdated; plain shells already re-derive status from the screen tail on every read, so the registry's subscription just needed to observe the exit too.mark_exiteddeliberately does not route throughreclassify_attention. My first attempt did, and it failedpanel::tests::bell_does_not_request_attention_for_the_active_window: that function's tail raises a desktop notification whenever a thread lands in a non-Workingstate, so a thread already flagged blocked and then killed notified twice. A process exiting is not a fresh "look at me" signal — the user has already been told if it was blocked — so the exit updates state and emits, without notifying.3. A row that could not be focused did so silently.
focus_threadfails with"agent thread no longer exists"/"pane closed"precisely when a row holds a staleterminal_item_id— the case worth diagnosing — andlet _ =made that click indistinguishable from one the user simply missed. The panel logs these (panel.rs:1260-1264); the rail did not.Rules
No state-machine change:
mark_exitedsets the sameThreadAttention::Idle/finished_seenpairreclassify_attentionsets for a finished turn, so the rail's existingFinishedvsIdleprojection is untouched. Additiveutildependency forResultExt::log_err.Known gap, deliberately not in this PR
AgentThreadStore::restore_attempted(store.rs:431, inserted atstore.rs:3316, never removed) looked like it would make a reloaded workspace unable to restore, because it is keyed ondatabase_id(), which survives a reload while the guard is meant to be per-app-session.I traced this and it is not the cause.
workspace::reloadis a full process restart —reload()→prepare_to_close→cx.restart()(workspace.rs:8867-8921) → platform re-exec (gpui_macos/src/platform.rs:528-567).AgentThreadStoreis created fresh ininit_global, sorestore_attemptedis empty after every reload. Session-id keying across the restart is also correct: writes usesession.id()(current), reads uselast_session_id()(previous), and pruning keeps exactly{current, previous}. Every option for re-keying the guard is either a no-op on this path or a regression — dropping it would let two restores of one session id race in the window between selection andregister, and would resurrect a thread the user deliberately closed. Leaving it alone.One real defect the guard does have, not fixed here: it is consumed even when the attempt selects zero records (empty snapshot, kind hidden in settings, tie mismatch, all resumes failed), and nothing ever retries for that workspace in that process. The likely producer is
records_to_restore_for_workspace's tie routing, which reads linked worktrees synchronously while that scan is cold right after a restart. I have not proven the causal chain, so I am not claiming it as a fix — tracking separately.Verification
Not built locally by request.
rustfmt --checkclean on all touched files.clippygreen;testsis the gate (the exit path is covered by the existing agent_threads panel tests).Suggested .rules additions
cx.observe(&entity, ..)fires only onnotify(). Anything that opens or removes children must alsocx.subscribe, becausecx.emitdoes not notify —crates/agent_threads/src/store.rsemits on register/shutdown with nonotify(), so an observer-only view keeps rows for dead sessions.HideStrategy::Never(store.rs:3097-3113), so a terminal surviving its process is normal here. Anything rendering a session's liveness must observeterminal::Event::ProcessExited;WakeupandBellstop at process exit.reclassify_attentionunless the transition should also raise a desktop notification. It notifies on every non-Workinglanding; a dedicated transition is usually what you want.workspace::reloadis a process restart (cx.restart()), not an in-process teardown. In-memory state — includingAgentThreadStore::restore_attempted— does not survive it, so don't reason about reload as if it does.Release Notes: