mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Four changes the maintainer asked for on Ark0N/Codeman#446 before merging. The pane-exit watcher stays always-on, but a tick now costs nothing when there is nothing to observe. `hasObservablePaneSession()` skips the tmux exec while every session on the manager is one of the shapes `Session.paneExitApplies` already forces to UNKNOWN: a remote SSH session (its local pane holds the ssh client), a docker case (a `docker exec` into the container's own tmux), and a record rebuilt from the socket (no provenance at all). The timer is untouched. Skipping retracts nothing, for the same reason a failed read does not: the map still holds the last real reading, and every path that puts a new command in a pane calls `clearPaneExit()` itself. The two copies of that rule are pinned against each other in `test/session-pane-exit.test.ts`, because drift between them is silent in both directions. `DEFAULT_PANE_EXIT_INTERVAL_MS` was already a constant beside the stats and remote-reconnect intervals; its comment now says why the watcher owns its own cadence and why the number is what it is. The never-default-an-absent-status rule is written where `PaneExit` is declared. It names `status ?? 0` as the thing never to write, and says that an agent the OOM killer took would otherwise read as a user typing `/exit` — which is what absent-stays-absent keeps a later clean-exit sweep away from. Nothing fails when somebody adds that `??`, which is why the sentence is there rather than a test. Checking the dot's specificity found a second fight, and it was losing. On the tab strip the alert rules win as intended: a session that exits with a permission dialog pending still renders red, and yellow for an idle alert. On the rich vertical tab rail they did not — that rail's own `tab-state-*` dot rules are (0,9,1) against the strip's mute at (0,5,0), so an exited session there kept a full green dot AND the working halo beside a badge reading "exited". The rail twin matches that specificity exactly and therefore must stay below those rules in source order; it clears the halo as well, which the strip's rule never had to think about. `test/session-pane-exit-ui.test.ts` now resolves the real stylesheet in jsdom rather than matching selector text: postcss collects every rule that paints `.tab-status`, a real engine decides, and the tests read back the answer. Two mutations were run against it to prove it has teeth — dropping the hand-written alert exclusions fails three cases, and moving the rail twin above the state rules fails one. Refs Ark0N/Codeman#446. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
TypeScript
351 lines
14 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';
|
|
import { hasObservablePaneSession } from '../src/tmux-manager.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("the watcher's read gate agrees with the session's scoping", () => {
|
|
// `hasObservablePaneSession()` decides whether a watcher tick execs tmux at
|
|
// all, and `Session.paneExitApplies` decides whether the answer is kept. They
|
|
// are two copies of one rule, and drift between them is silent: too narrow
|
|
// and a session that could report an exit never gets read, too wide and every
|
|
// tick pays for an answer the session throws away.
|
|
const muxSession = (extra: Partial<MuxSession> = {}): MuxSession =>
|
|
({
|
|
sessionId: 'aaaa',
|
|
muxName: 'codeman-aaaa',
|
|
pid: 100,
|
|
createdAt: 0,
|
|
workingDir: '/tmp',
|
|
mode: 'claude',
|
|
attached: true,
|
|
...extra,
|
|
}) as MuxSession;
|
|
|
|
const cases: { shape: string; mux: MuxSession; session: () => Session }[] = [
|
|
{ shape: 'local', mux: muxSession(), session: () => localMuxSession() },
|
|
{ shape: 'remote SSH', mux: muxSession({ remote }), session: () => localMuxSession({ remote }) },
|
|
{ shape: 'docker', mux: muxSession({ docker }), session: () => localMuxSession({ docker }) },
|
|
{
|
|
shape: 'rebuilt from the socket',
|
|
mux: muxSession({ discovered: true }),
|
|
session: () => localMuxSession({ discoveredMuxSession: true }),
|
|
},
|
|
];
|
|
|
|
for (const { shape, mux, session } of cases) {
|
|
it(`agrees for a ${shape} session`, () => {
|
|
const sessionKeepsIt = session().setPaneExit(EXIT);
|
|
expect(hasObservablePaneSession([mux])).toBe(sessionKeepsIt);
|
|
});
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|