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
+23
View File
@@ -1371,6 +1371,19 @@ describe('session-routes', () => {
expect(body.success).toBe(false);
});
// Ark0N/Codeman#446: starting a command in the pane retracts `paneExit`, and
// the pane-exit watcher cannot write that retraction for us — its next tick
// finds the field already cleared, reports no change and persists nothing.
// Broadcasting without persisting leaves state.json saying the agent exited.
it('persists the session, not just broadcasts it', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
});
it('returns error if session is busy', async () => {
harness.ctx._session.isBusy.mockReturnValue(true);
const res = await harness.app.inject({
@@ -1447,6 +1460,16 @@ describe('session-routes', () => {
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
});
// Same reason as /interactive above (Ark0N/Codeman#446).
it('persists the session, not just broadcasts it', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/shell`,
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
});
it('returns error if session is busy', async () => {
harness.ctx._session.isBusy.mockReturnValue(true);
const res = await harness.app.inject({
+65 -24
View File
@@ -148,37 +148,47 @@ describe('what colour the status dot ends up', () => {
* a real engine answers, which is what makes a rule moved up the file or a
* selector given one more class fail here.
*
* ⚠ Rules inside an at-rule are skipped, so this describes a desktop-width
* tab strip with motion allowed. jsdom reports a custom property unresolved,
* so the expected values are the `var(--x)` tokens the stylesheet writes.
* ⚠ In styles.css the rules inside an at-rule are skipped, so the desktop
* cases describe a wide viewport with motion allowed. mobile.css is loaded
* separately for the phone cases, and there its @media blocks are FLATTENED
* rather than skipped, because that file is phone-and-tablet-only and its
* whole content sits inside them. jsdom reports a custom property
* unresolved, so the expected values are the `var(--x)` tokens the
* stylesheets write.
*/
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const dotRules: string[] = [];
postcss.parse(css).walkRules((rule) => {
if (!rule.selector.includes('.tab-status')) return;
const parents: string[] = [];
let insideAtRule = false;
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
if (p.type === 'rule') parents.unshift(p.selector);
else insideAtRule = true;
}
if (insideAtRule) return;
const decls: string[] = [];
rule.each((node) => {
if (node.type === 'decl') decls.push(`${node.prop}: ${node.value}${node.important ? ' !important' : ''};`);
const readRules = (file: string, flattenMedia: boolean): string[] => {
const out: string[] = [];
postcss.parse(readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8')).walkRules((rule) => {
if (!rule.selector.includes('.tab-status')) return;
const parents: string[] = [];
let insideAtRule = false;
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
if (p.type === 'rule') parents.unshift(p.selector);
else insideAtRule = true;
}
if (insideAtRule && !flattenMedia) return;
const decls: string[] = [];
rule.each((node) => {
if (node.type === 'decl') decls.push(`${node.prop}: ${node.value}${node.important ? ' !important' : ''};`);
});
if (decls.length === 0) return;
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
out.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
});
if (decls.length === 0) return;
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
dotRules.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
});
return out;
};
const dotRules = readRules('styles.css', false);
// index.html loads mobile.css after styles.css, so it goes last here too.
const phoneRules = [...dotRules, ...readRules('mobile.css', true)];
/** Paint the dot of one tab and read back what the cascade decided. */
const dot = (opts: { tab: string; dotState?: string; rail?: boolean }) => {
const dot = (opts: { tab: string; dotState?: string; rail?: boolean; phone?: boolean }) => {
const railAttrs = opts.rail ? ` data-tab-orientation="vertical" data-tab-rail-detail="rich"` : '';
const container = opts.rail ? 'tab-rail' : 'session-tabs';
const rules = opts.phone ? phoneRules : dotRules;
const dom = new JSDOM(
`<!DOCTYPE html><html${railAttrs}><head><style>${dotRules.join('\n')}</style></head><body>` +
`<!DOCTYPE html><html${railAttrs}><head><style>${rules.join('\n')}</style></head><body>` +
`<div class="${container}"><div class="session-tab ${opts.tab}">` +
`<span id="dot" class="tab-status ${opts.dotState ?? 'idle'}"></span></div></div></body></html>`
);
@@ -240,6 +250,37 @@ describe('what colour the status dot ends up', () => {
expect(dot({ tab: 'tab-agent-exited tab-state-idle', rail: true }).background).toBe('var(--text-muted)');
});
it('leaves an errored dot red, which is the state that offers a restart', () => {
// `status: 'error'` is the PTY-exit breaker's value and the browser answers
// it with a "restart it?" confirm, so it is a needs-you colour by the same
// argument that protects the two alert classes. Reachable when a restart of
// a dead pane keeps failing: the breaker trips while the pane stays dead.
expect(dot({ tab: 'tab-agent-exited', dotState: 'error' }).background).toBe('var(--red)');
});
it('mutes the dot on a phone, glow and all', () => {
// mobile.css enlarges the working dot to 9px and gives it a green glow with
// !important, and `status` stays `busy` for a pane whose agent died
// mid-turn — so without a phone-side rule this renders a grey dot wearing a
// green halo beside a badge reading "exited".
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy', phone: true })).toMatchObject({
background: 'var(--text-muted)',
boxShadow: 'none',
});
});
it('keeps an alert red on a phone as well', () => {
expect(dot({ tab: 'tab-agent-exited tab-alert-action', dotState: 'busy', phone: true }).background).toBe(
'var(--red)'
);
});
it('finds the phone rules it is meant to be resolving', () => {
// Same self-guard as the desktop one: if mobile.css stopped contributing
// rules, every phone case above would pass against the desktop cascade.
expect(phoneRules.length).toBeGreaterThan(dotRules.length);
});
it('still keeps an alert red on the rich tab rail', () => {
expect(
dot({ tab: 'tab-agent-exited tab-alert-action tab-state-working', dotState: 'busy', rail: true }).background
+120
View File
@@ -17,9 +17,11 @@ import {
parsePaneRows,
derivePaneExits,
hasObservablePaneSession,
type PaneRow,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
import type { MuxSession } from '../src/mux-interface.js';
// ============================================================================
// Unit Tests (mocked)
@@ -1122,6 +1124,124 @@ describe('TmuxManager pane-exit bookkeeping', () => {
});
});
describe('the pane-exit watcher tick', () => {
// Every guard in `refreshPaneExits()` used to be unreachable: the method
// began with `if (IS_TEST_MODE) return;`, so deleting the generation check,
// the in-flight suppression, the empty-read rule or the read gate left the
// whole suite green. The tmux read now sits alone in `readPaneRows()`, which
// a subclass can answer for.
const NOW = 1_700_000_000_000;
class TestManager extends TmuxManager {
rows: PaneRow[] = [];
reads = 0;
/** While true, a read parks until releaseAll(), so a test can hold one in flight. */
hold = false;
private pending: (() => void)[] = [];
protected override async readPaneRows(): Promise<PaneRow[]> {
this.reads++;
// Every parked read is tracked, not just the latest: with the in-flight
// guard removed a second one starts, and a harness that could release
// only the last would deadlock instead of failing.
if (this.hold) await new Promise<void>((resolve) => this.pending.push(resolve));
return this.rows;
}
releaseAll(): void {
this.hold = false;
for (const resolve of this.pending.splice(0)) resolve();
}
}
const localSession = (sessionId = 's1'): MuxSession =>
({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
}) as MuxSession;
const withLocalSession = () => {
const manager = new TestManager();
manager.registerSession(localSession());
return manager;
};
it('does not read tmux when no session could answer', async () => {
const manager = new TestManager();
await manager.refreshPaneExits(NOW);
expect(manager.reads).toBe(0);
});
it('reads tmux once a local session exists', async () => {
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW);
expect(manager.reads).toBe(1);
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 0, at: NOW });
});
it('suppresses a second read while one is still in flight', async () => {
// EXEC_TIMEOUT_MS is 5000 against a 2000 ms tick, so a slow read outlives
// two ticks; without this the older one can resolve last and win.
const manager = withLocalSession();
manager.hold = true;
const first = manager.refreshPaneExits(NOW);
const second = manager.refreshPaneExits(NOW);
const reads = manager.reads;
manager.releaseAll();
await Promise.all([first, second]);
expect(reads).toBe(1);
});
it('retracts nothing when the read comes back empty', async () => {
// An empty read is "tmux did not answer". Retracting there would turn a
// transient failure into a silent denial of a death already observed.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|137|');
await manager.refreshPaneExits(NOW);
manager.rows = [];
await manager.refreshPaneExits(NOW + 2000);
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
});
it('discards a read that started before the pane was cleared', async () => {
// The guard that stops an in-flight read from republishing a death over
// the pane that has just replaced it.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
manager.hold = true;
const pending = manager.refreshPaneExits(NOW);
manager.clearPaneExit('codeman-s1');
manager.releaseAll();
await pending;
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
});
it('announces each tick so the server can publish it', async () => {
// Losing this emit, or the server's own startPaneExitWatcher() call,
// disables the whole feature with nothing failing.
vi.useFakeTimers();
try {
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1||9');
const updates: number[] = [];
manager.on('paneExitsUpdated', () => updates.push(1));
manager.startPaneExitWatcher(10);
await vi.advanceTimersByTimeAsync(25);
manager.stopPaneExitWatcher();
expect(updates.length).toBeGreaterThan(0);
expect(manager.getPaneExit('codeman-s1')).toMatchObject({ signal: 9 });
} finally {
vi.useRealTimers();
}
});
});
describe('hasObservablePaneSession', () => {
// The pane-exit watcher is always-on, so a tick with nothing to observe is
// the normal case on an instance running only remote or Docker work. This