fix(session): persist an exit retraction, and let tests reach the watcher

Ten findings from a two-model review of this branch. Both reviewers cleared the
detection logic itself; everything here is a gap around it.

A route that starts a command in a pane now PERSISTS as well as broadcasts.
`/interactive` and `/shell` did neither before, and the pane-exit watcher cannot
cover for them: its next tick finds `paneExit` already cleared in memory,
reports no change and writes nothing, so `state.json` kept saying the agent had
exited for as long as the session stayed quiet. Nothing reads that record for a
decision yet, which is exactly why it had to be fixed now — part 2 is designed
to read it. The `clearPaneExitForNewPane()` docstring claimed its callers
already persisted; that claim was false for these two, and now says what the
caller owes instead.

The watcher's four guards were unreachable by any test. `refreshPaneExits()`
opened with `if (IS_TEST_MODE) return;`, so the read gate, the in-flight
suppression, the generation counter and the empty-read rule could each be
deleted with the whole suite green. The tmux call moves into `readPaneRows()`,
which a test subclass overrides — the shape `runRemoteReconnectTick` already
uses in this file for the same reason — and the test-mode gate moves with it, so
what a test cannot do is spawn a process rather than exercise the bookkeeping.
Each of the four guards now has a test that fails when it is deleted.

The muted status dot turned out to be a specificity fight on three surfaces, not
two. `.tab-status.error` was not excluded, so a session whose agent exited and
whose PTY-exit breaker then tripped lost its red dot to the mute — the state the
browser answers with a "restart it?" confirm, and a needs-you colour by the same
argument that protects the two alert classes. And mobile.css gives a `busy` dot
a 9px size and a green glow with `!important`, while `status` stays `busy` for a
pane whose agent died mid-turn, so a phone rendered a grey dot still wearing the
green halo beside a badge reading "exited". Both measured against the real
stylesheets, both now excluded, and the CSS test reads mobile.css too instead of
being structurally blind to half the problem.

Six comments said things that were not true. Two named the stats collector as
what replaces a restored reading, which is the opposite of the design. The
interval constant argued that 2000 ms keeps a read inside a tick, when the
5000 ms exec timeout means it cannot — which is why the in-flight guard exists.
`MuxSession.discovered` did not say the flag is permanent, though `saveSessions()`
serializes it. The empty-read docstring claimed a distinction that `|| true`
makes impossible. The invariants doc promised more than its drift test delivers.
And CLAUDE.md had no pointer at all, leaving its two hardest prohibitions
("never set `status: 'error'`", "never null the pid") only in the file it is
meant to route people to.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 15:24:09 +02:00
co-authored by Claude Opus 5
parent 90fd0a5a15
commit 9c286eeddf
12 changed files with 316 additions and 61 deletions
+14 -9
View File
@@ -2458,18 +2458,23 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
`error` is the PTY-exit breaker's value and makes the browser offer a restart
— so this is a rendering rule only, and it matches `.tab-status.ended`.
⚠ The two alert classes are excluded BY HAND rather than by cascade, the same
way the rich-rail dot rules below do it: a dot turning red or yellow because
a session is blocked on a human outranks "the agent exited", and this
selector is specific enough (0,5,0) to have beaten those (0,3,0) rules
otherwise. The spinner ring is a pseudo-element the skin blocks cannot reach,
so it needs hiding explicitly rather than by unsetting the animation. */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status {
⚠ Three states are excluded BY HAND rather than by cascade, the same way the
rich-rail dot rules below do it: a dot turning red or yellow because a
session is blocked on a human outranks "the agent exited", and this selector
is specific enough (0,5,0) to have beaten those (0,3,0) rules otherwise. The
third is `.tab-status.error` (0,2,0), which is the PTY-exit breaker's state
and the one the browser answers with a "restart it?" confirm — the same
argument that protects the two alert classes, and it reaches the dot rather
than the tab, which is why the `:not()` sits on `.tab-status` here and on
`.session-tab` there. The spinner ring is a pseudo-element the skin blocks
cannot reach, so it needs hiding explicitly rather than by unsetting the
animation. */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
background: var(--text-muted);
opacity: 0.5;
animation: none;
}
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status::after {
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
display: none;
}
@@ -18511,7 +18516,7 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status {
.tab-status:not(.error) {
background: var(--text-muted);
opacity: 0.5;
box-shadow: none;