Files
Codeman/test/session-pane-exit.test.ts
T
Michael GrundbergandClaude Opus 5 90a95f562b feat(session): publish and persist a local pane's agent exit
The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.

`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.

The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. A remote SSH session's local pane holds the
ssh client, whose death means a transport drop OR an exit, which is the
ambiguity PR #355 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.

`status` and `pid` are untouched. `status: 'error'` belongs to the PTY-exit
circuit breaker and makes the browser offer a restart, and a null `pid` is what
makes the browser re-attach and launch a fresh CLI. A reading that repeats the
previous answer writes nothing and broadcasts nothing.

An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.

`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:01:09 +02:00

313 lines
12 KiB
TypeScript

/**
* @fileoverview `SessionState.paneExit` — Codeman noticing that a pane's agent
* has exited (Ark0N/Codeman#446).
*
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
* CLI while tmux keeps the pane and the `tmux attach-session` process Codeman
* records as the session's pid. No PTY exit handler runs, and the record used to
* keep both its pid and `status: 'idle'`, so the board showed an exited session
* as a live idle one.
*
* Four properties are pinned here, each because getting it wrong costs something
* specific:
*
* 1. **The field is tri-state, and absence means UNKNOWN.** A direct-PTY
* session owns no pane, a remote SSH session's local pane holds the ssh
* client, and a docker case's local pane holds a `docker exec`. In all
* three, a dead local pane is not the agent exiting.
* 2. **`status` and `pid` are never touched.** `status: 'error'` belongs to the
* PTY-exit circuit breaker and makes the browser offer a restart, and a null
* `pid` is what makes the browser re-attach and launch a fresh CLI.
* 3. **It reaches `toState()`**, which is both the `session:updated` payload
* and what `state.json` persists.
* 4. **It round-trips through the store**, because a reboot takes the tmux
* server and the persisted record is the only thing left that can say the
* agent was already gone.
*
* Port: 3187
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import { StateStore } from '../src/state-store.js';
import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
const PORT = 3187;
const EXIT: PaneExit = { status: 0, at: 1_700_000_000_000 };
/** The two mux members `Session` reads when it decides whether the field applies. */
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
/** A local, mux-backed session: the one shape the field applies to. */
function localMuxSession(extra: Record<string, unknown> = {}) {
return new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux: stubMux(),
muxSession: stubMuxSession(),
...extra,
});
}
describe('Session.setPaneExit scoping', () => {
it('accepts an exit for a local mux-backed session', () => {
const session = localMuxSession();
expect(session.setPaneExit(EXIT)).toBe(true);
expect(session.paneExit).toEqual(EXIT);
});
it('stays unknown for a direct-PTY session, which owns no pane at all', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown while the session has no mux session yet', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: true, mux: stubMux() });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown for a remote SSH session, whose pane holds the ssh client', () => {
// A dead ssh client means a transport drop OR an exit, and telling those two
// apart is the whole of PR #355. Publishing it as an agent exit would assert
// the answer Codeman does not have.
const session = localMuxSession({ remote });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown for a docker case, whose pane holds a docker exec', () => {
const session = localMuxSession({ docker });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('clears a stored exit when a later tick reports nothing', () => {
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit(undefined)).toBe(true);
expect(session.paneExit).toBeUndefined();
});
it('reports no change when a tick repeats the same observation', () => {
// The caller persists and broadcasts on a true, and this tick runs every
// 2000 ms for every session.
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit({ ...EXIT })).toBe(false);
});
it('reports a change when the exit status changes', () => {
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit({ status: 137, at: EXIT.at })).toBe(true);
expect(session.paneExit).toEqual({ status: 137, at: EXIT.at });
});
});
describe('Session.toState with an exited agent', () => {
it('publishes the exit and leaves status and pid alone', () => {
const session = localMuxSession();
const before = session.toState();
session.setPaneExit(EXIT);
const after = session.toState();
expect(before.paneExit).toBeUndefined();
expect(after.paneExit).toEqual(EXIT);
expect(after.status).toBe(before.status);
expect(after.pid).toBe(before.pid);
// `status: 'error'` is the PTY-exit breaker's value; the browser answers it
// with a "restart it?" confirm.
expect(after.status).not.toBe('error');
});
it('restores a persisted exit so the first persist after boot cannot blank it', () => {
const session = localMuxSession({ paneExit: EXIT });
expect(session.toState().paneExit).toEqual(EXIT);
});
it('ignores a persisted exit for a session shape the field never applies to', () => {
// The scoping is not only about live ticks: a record written before a
// session was reconfigured must not resurrect an answer that cannot hold.
// The constructor alone has to enforce it, before any tick runs.
expect(new Session({ workingDir: '/tmp', mode: 'claude', useMux: false, paneExit: EXIT }).paneExit).toBeUndefined();
expect(localMuxSession({ remote, paneExit: EXIT }).paneExit).toBeUndefined();
expect(localMuxSession({ docker, paneExit: EXIT }).paneExit).toBeUndefined();
});
});
describe('a relaunch in the same pane forgetting the old exit', () => {
/** A mux whose respawn succeeds, so `restartCli()` reaches its success path. */
const respawningMux = () => {
const cleared: string[] = [];
const mux = {
isAvailable: () => true,
muxSessionExists: () => true,
respawnPane: async () => 4242,
clearPaneExit: (muxName: string) => cleared.push(muxName),
};
return { mux: mux as unknown as TerminalMultiplexer, cleared };
};
it('clears the exit when restartCli relaunches the CLI', async () => {
// restartCli() is the custom-model endpoint switch. Its caller persists and
// broadcasts straight afterwards, so an exit left in place here is written
// back onto a session that is running again.
const { mux, cleared } = respawningMux();
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: stubMuxSession(),
paneExit: EXIT,
});
expect(session.paneExit).toEqual(EXIT);
expect(await session.restartCli()).toBe(true);
expect(session.paneExit).toBeUndefined();
expect(session.toState().paneExit).toBeUndefined();
// Both halves: the record and the mux layer's cache, the latter of which
// also invalidates a pane read already in flight.
expect(cleared).toEqual(['codeman-aaaa']);
});
it('leaves the exit alone when the relaunch fails', async () => {
// A failed respawn means the old command is still what the pane last ran.
const { mux, cleared } = respawningMux();
(mux as unknown as { respawnPane: () => Promise<null> }).respawnPane = async () => null;
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: stubMuxSession(),
paneExit: EXIT,
});
expect(await session.restartCli()).toBe(false);
expect(session.paneExit).toEqual(EXIT);
expect(cleared).toEqual([]);
});
});
describe('paneExit round trip through state.json', () => {
let dir: string | null = null;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = null;
});
it('survives a write and a reload, which is what a reboot restore reads', () => {
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
const file = join(dir, 'state.json');
const stored: SessionState = {
...localMuxSession().toState(),
paneExit: { status: 0, signal: undefined, at: 1_700_000_000_000 },
};
const writer = new StateStore(file);
writer.setSession(stored.id, stored);
writer.saveNow();
const reader = new StateStore(file);
expect(reader.getSession(stored.id)?.paneExit).toEqual({ at: 1_700_000_000_000, status: 0 });
});
it('reads a record written before the field existed as unknown, needing no migration', () => {
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
const file = join(dir, 'state.json');
const legacy = localMuxSession().toState();
delete legacy.paneExit;
const writer = new StateStore(file);
writer.setSession(legacy.id, legacy);
writer.saveNow();
expect(new StateStore(file).getSession(legacy.id)?.paneExit).toBeUndefined();
});
});
describe('a pane read reaching the session record', () => {
// Drives the real `paneExitsUpdated` wiring rather than asserting on source
// text: a fake reading goes into the mux, the event fires, and the session
// record is checked. This is the path findings about the watcher turn on.
let server: WebServer | null = null;
afterEach(async () => {
await server?.stop?.();
server = null;
});
const build = () => {
const web = new WebServer(PORT, false, true);
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
const session = localMuxSession();
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
return { web, mux, session };
};
it('publishes an exit the mux reports for a local pane', () => {
const { web, mux, session } = build();
server = web;
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(session.paneExit).toEqual(EXIT);
expect(session.toState().paneExit).toEqual(EXIT);
});
it('leaves status and pid alone while doing it', () => {
const { web, mux, session } = build();
server = web;
const before = session.toState();
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
const after = session.toState();
expect(after.status).toBe(before.status);
expect(after.pid).toBe(before.pid);
// `status: 'error'` is the PTY-exit breaker's value; it makes the browser
// offer a restart. A null pid makes it launch a fresh CLI.
expect(after.status).not.toBe('error');
});
it('retracts the exit once the mux reports the pane is back', () => {
const { web, mux, session } = build();
server = web;
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
mux.getPaneExit = () => undefined;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(session.paneExit).toBeUndefined();
});
it('publishes nothing for a session the field does not apply to', () => {
const { web, mux } = build();
server = web;
const remoteSession = localMuxSession({ remote });
(web as unknown as { sessions: Map<string, Session> }).sessions.set(remoteSession.id, remoteSession);
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(remoteSession.paneExit).toBeUndefined();
});
});