mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #466 from irisitymichaelgrundberg/feat/pane-exit-reporting
feat(tmux): report that a pane's agent has exited (#446, part 1)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @fileoverview The exited-agent badge on a session tab (Ark0N/Codeman#446).
|
||||
*
|
||||
* The server publishes `session.paneExit` when the agent inside a local tmux
|
||||
* pane has exited while `remain-on-exit` kept the pane. These cover the three
|
||||
* things the browser owns: turning that field into a label, getting the label
|
||||
* onto and off a tab, and what colour the tab's status dot ends up once the
|
||||
* exit, the alert rules and the rich rail's own rules have all had a say.
|
||||
*
|
||||
* The incremental render path is the only one a live session ever reaches.
|
||||
* Going from live to exited adds and removes no tab, so the full rebuild never
|
||||
* runs for it, which is why `applyPaneExitBadge()` is a named function rather
|
||||
* than a block inside the render loop.
|
||||
*
|
||||
* Port: N/A
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('the exited-agent tab label', () => {
|
||||
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const load = <T>(name: string) => {
|
||||
const source = appJs.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))?.[0];
|
||||
if (!source) throw new Error(`${name} not found in app.js`);
|
||||
return new Function(`${source}\nreturn ${name};`)() as T;
|
||||
};
|
||||
const paneExitLabel = load<(p: unknown) => string>('paneExitLabel');
|
||||
|
||||
it('renders nothing for an unknown answer, which must never read as alive', () => {
|
||||
expect(paneExitLabel(undefined)).toBe('');
|
||||
expect(paneExitLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
it('names the exit code', () => {
|
||||
expect(paneExitLabel({ status: 137, at: 1 })).toBe('exited (137)');
|
||||
});
|
||||
|
||||
it('shows a clean exit as 0 rather than hiding it', () => {
|
||||
expect(paneExitLabel({ status: 0, at: 1 })).toBe('exited (0)');
|
||||
});
|
||||
|
||||
it('names a signal death, which the maintainer wants kept on screen', () => {
|
||||
expect(paneExitLabel({ signal: 9, at: 1 })).toBe('exited (signal 9)');
|
||||
});
|
||||
|
||||
it('says only "exited" when tmux knew the pane died but not how', () => {
|
||||
// Measured on tmux 3.2a: a SIGKILLed pane reports neither status nor signal.
|
||||
// Showing that as "exited (0)" would make an unexplained death look clean.
|
||||
expect(paneExitLabel({ at: 1 })).toBe('exited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the exited-agent badge in a tab', () => {
|
||||
// The incremental render path is the only one a live session reaches: going
|
||||
// from live to exited adds and removes no tab, so the full rebuild never runs
|
||||
// for it. These drive that path's DOM work against a real tab element.
|
||||
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const source = [
|
||||
appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0],
|
||||
appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0],
|
||||
].join('\n');
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const applyPaneExitBadge = new Function('document', `${source}\nreturn applyPaneExitBadge;`)(dom.window.document) as (
|
||||
tab: unknown,
|
||||
paneExit: unknown
|
||||
) => void;
|
||||
|
||||
const makeTab = () => {
|
||||
const tab = dom.window.document.createElement('div');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML = '<span class="tab-name">w1-case</span>';
|
||||
return tab;
|
||||
};
|
||||
const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) =>
|
||||
tab.querySelector('.tab-exited-badge');
|
||||
|
||||
it('draws no badge while the answer is unknown', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(badge(tab)).toBeNull();
|
||||
});
|
||||
|
||||
it('adds the badge after the name once the agent exits', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 137, at: 1 });
|
||||
expect(badge(tab)?.textContent).toBe('exited (137)');
|
||||
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
|
||||
});
|
||||
|
||||
it('marks the badge data-i18n-skip, like the other generated status text', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the text in place rather than stacking a second badge', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
const first = badge(tab);
|
||||
applyPaneExitBadge(tab, { status: 137, at: 2 });
|
||||
expect(tab.querySelectorAll('.tab-exited-badge')).toHaveLength(1);
|
||||
expect(badge(tab)).toBe(first);
|
||||
expect(badge(tab)?.textContent).toBe('exited (137)');
|
||||
});
|
||||
|
||||
it('marks the tab so the status dot can be quieted', () => {
|
||||
// The dot renders from `status`, which stays `idle` or `busy` for an exited
|
||||
// pane by design, so the tab carries the exit as a class and CSS does the
|
||||
// rest. Without it a green or pulsing dot sits beside the badge.
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
expect(tab.classList.contains('tab-agent-exited')).toBe(true);
|
||||
});
|
||||
|
||||
it('unmarks the tab when the pane comes back', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the badge when the pane comes back', () => {
|
||||
// The retraction half: a respawned pane must not keep reading "exited".
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(badge(tab)).toBeNull();
|
||||
});
|
||||
|
||||
it('is what the incremental render path calls', () => {
|
||||
expect(appJs).toContain('applyPaneExitBadge(tab, session.paneExit)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what colour the status dot ends up', () => {
|
||||
/*
|
||||
* The dot renders from `status`, which stays `idle` or `busy` for an exited
|
||||
* pane, so the mute is a CSS rule keyed on the `tab-agent-exited` class. It
|
||||
* competes with two other families of rule over the same dot, and this tree
|
||||
* has lost that competition before: the alert rules and the rich-rail state
|
||||
* rules already exclude each other by hand rather than by cascade.
|
||||
*
|
||||
* So the cascade is resolved rather than asserted from selector text. Every
|
||||
* rule in styles.css that paints `.tab-status` goes into a real document and
|
||||
* a real engine answers, which is what makes a rule moved up the file or a
|
||||
* selector given one more class fail here.
|
||||
*
|
||||
* ⚠ 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 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(' ')} }`);
|
||||
});
|
||||
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; 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>${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>`
|
||||
);
|
||||
const style = dom.window.getComputedStyle(dom.window.document.getElementById('dot')!);
|
||||
return {
|
||||
background: style.background,
|
||||
opacity: style.opacity,
|
||||
boxShadow: style.boxShadow,
|
||||
animation: style.animation,
|
||||
};
|
||||
};
|
||||
|
||||
it('finds the rules it is meant to be resolving', () => {
|
||||
// A selector rename that emptied this list would make every case below pass
|
||||
// against a stylesheet with no rules in it.
|
||||
expect(dotRules.some((rule) => rule.includes('tab-agent-exited'))).toBe(true);
|
||||
expect(dotRules.some((rule) => rule.includes('tab-alert-action'))).toBe(true);
|
||||
});
|
||||
|
||||
it('mutes the dot of an exited session', () => {
|
||||
expect(dot({ tab: 'tab-agent-exited' })).toMatchObject({ background: 'var(--text-muted)', opacity: '0.5' });
|
||||
});
|
||||
|
||||
it('leaves a live session green', () => {
|
||||
expect(dot({ tab: '' }).background).toBe('var(--green)');
|
||||
});
|
||||
|
||||
it('keeps a pending permission dialog RED on an exited session', () => {
|
||||
// The one the maintainer asked for: the exit must not quiet an alert. A
|
||||
// board that says two things at once is a board people stop trusting, and
|
||||
// between "the agent is gone" and "this session is blocked on you", the
|
||||
// one that needs a human wins.
|
||||
expect(dot({ tab: 'tab-agent-exited tab-alert-action' }).background).toBe('var(--red)');
|
||||
});
|
||||
|
||||
it('keeps a pending idle alert YELLOW on an exited session', () => {
|
||||
expect(dot({ tab: 'tab-agent-exited tab-alert-idle' }).background).toBe('var(--yellow)');
|
||||
});
|
||||
|
||||
it('mutes a dot the exit caught mid-turn, and stops it pulsing', () => {
|
||||
// `.tab-status.busy` animates `pulse`, so muting the colour alone would
|
||||
// leave a grey dot breathing as if the agent were still working.
|
||||
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy' })).toMatchObject({
|
||||
background: 'var(--text-muted)',
|
||||
opacity: '0.5',
|
||||
animation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('mutes the dot on a rich tab rail too, halo included', () => {
|
||||
// The rail's own state rules are far more specific than the strip's mute
|
||||
// (measured: an exited session kept a full green dot AND the working halo),
|
||||
// so the mute carries a rail twin that must stay below them in source order.
|
||||
expect(dot({ tab: 'tab-agent-exited tab-state-working', dotState: 'busy', rail: true })).toMatchObject({
|
||||
background: 'var(--text-muted)',
|
||||
opacity: '0.5',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
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
|
||||
).toBe('var(--red)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
+345
-17
@@ -14,10 +14,14 @@ import {
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
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)
|
||||
@@ -910,41 +914,44 @@ describe('TmuxManager (unit)', () => {
|
||||
// exec without TTY). See PR #71.
|
||||
// ============================================================================
|
||||
|
||||
describe('parsePaneList', () => {
|
||||
describe('parsePaneRows', () => {
|
||||
/** Pull the name → pid map reconciliation builds, so these cases read as they used to. */
|
||||
const pids = (output: string) => new Map(parsePaneRows(output).map((row) => [row.sessionName, row.pid]));
|
||||
|
||||
it('parses well-formed output into name → pid', () => {
|
||||
const out = 'codeman-aaaa|1234\ncodeman-bbbb|5678\nclaudeman-cccc|9999';
|
||||
const result = parsePaneList(out);
|
||||
const result = pids(out);
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('codeman-aaaa')).toBe(1234);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
expect(result.get('claudeman-cccc')).toBe(9999);
|
||||
});
|
||||
|
||||
it('returns an empty map for empty output', () => {
|
||||
expect(parsePaneList('').size).toBe(0);
|
||||
it('returns no rows for empty output', () => {
|
||||
expect(parsePaneRows('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips blank lines', () => {
|
||||
const result = parsePaneList('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
|
||||
const result = pids('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('codeman-aaaa')).toBe(100);
|
||||
expect(result.get('codeman-bbbb')).toBe(200);
|
||||
});
|
||||
|
||||
it('skips lines without the separator', () => {
|
||||
const result = parsePaneList('codeman-aaaa 1234\ncodeman-bbbb|5678');
|
||||
const result = pids('codeman-aaaa 1234\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
|
||||
it('skips lines with a non-numeric pid', () => {
|
||||
const result = parsePaneList('codeman-aaaa|notapid\ncodeman-bbbb|5678');
|
||||
const result = pids('codeman-aaaa|notapid\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
|
||||
it('skips lines with an empty session name', () => {
|
||||
const result = parsePaneList('|1234\ncodeman-bbbb|5678');
|
||||
const result = pids('|1234\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
@@ -955,15 +962,336 @@ describe('parsePaneList', () => {
|
||||
// tab byte. With the '|' separator, such literals must not be silently
|
||||
// treated as a delimiter — the line is discarded because there is no '|'.
|
||||
const literalBackslashT = 'codeman-aaaa\\t1234';
|
||||
const result = parsePaneList(literalBackslashT);
|
||||
expect(result.size).toBe(0);
|
||||
expect(parsePaneRows(literalBackslashT)).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits on the first separator only', () => {
|
||||
// Numeric trailing junk after the pid is tolerated by parseInt — proves
|
||||
// that splitting on the first '|' leaves the pid extractable even if a
|
||||
// future tmux ever appended extra fields.
|
||||
const result = parsePaneList('codeman-aaaa|1234|extra-field');
|
||||
expect(result.get('codeman-aaaa')).toBe(1234);
|
||||
it('keeps a row whose pane_dead fields are missing, and calls its deadness unknown', () => {
|
||||
// A tmux old enough to have shipped the previous two-field format, or one
|
||||
// that dropped the trailing fields, must still yield its pid.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234');
|
||||
expect(row.pid).toBe(1234);
|
||||
expect(row.dead).toBeUndefined();
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a live pane as not dead, with no status or signal', () => {
|
||||
// Measured against tmux 3.2a: a live pane leaves both numeric fields blank.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|0|||1');
|
||||
expect(row.dead).toBe(false);
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a dead pane with its exit status', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1|7|');
|
||||
expect(row.dead).toBe(true);
|
||||
expect(row.exitStatus).toBe(7);
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a dead pane with its killing signal', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1||9');
|
||||
expect(row.dead).toBe(true);
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBe(9);
|
||||
});
|
||||
|
||||
it('leaves a status of 0 as 0 rather than dropping it', () => {
|
||||
// The whole point of the field: a clean exit is the case part 2 acts on.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1|0|');
|
||||
expect(row.exitStatus).toBe(0);
|
||||
});
|
||||
|
||||
it('calls a non-numeric dead flag unknown rather than false', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|?||');
|
||||
expect(row.dead).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns one row per pane of a split session, in tmux order', () => {
|
||||
const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|');
|
||||
expect(rows.map((row) => row.pid)).toEqual([100, 200]);
|
||||
expect(rows.map((row) => row.sessionName)).toEqual(['codeman-aaaa', 'codeman-aaaa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePaneExits', () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it('reports a single dead pane with its exit status', () => {
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1|0|'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { status: 0, at: NOW } });
|
||||
});
|
||||
|
||||
it('reports a signalled death without inventing a status', () => {
|
||||
// Folding an absent status into 0 would turn an unexplained death into the
|
||||
// clean exit part 2 closes on sight.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||9'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { signal: 9, at: NOW } });
|
||||
});
|
||||
|
||||
it('reports a death tmux could not explain at all', () => {
|
||||
// Measured on tmux 3.2a: a SIGKILLed pane reports pane_dead=1 and nothing else.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { at: NOW } });
|
||||
});
|
||||
|
||||
it('says nothing about a live pane', () => {
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|0|||'), NOW);
|
||||
expect(exits.has('codeman-aaaa')).toBe(false);
|
||||
});
|
||||
|
||||
it('says nothing about a pane whose deadness tmux did not report', () => {
|
||||
expect(derivePaneExits(parsePaneRows('codeman-aaaa|1234'), NOW).size).toBe(0);
|
||||
});
|
||||
|
||||
it('says nothing about a session with more than one pane, even when all are dead', () => {
|
||||
// A session the user split by hand has no single "the agent" to report on,
|
||||
// and guessing which pane speaks for it could call a live session exited.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-aaaa|200|1|0|'), NOW);
|
||||
expect(exits.size).toBe(0);
|
||||
});
|
||||
|
||||
it("answers per session, so one session's split does not silence another", () => {
|
||||
const exits = derivePaneExits(
|
||||
parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-bbbb|200|1|0|\ncodeman-bbbb|201|0|||'),
|
||||
NOW
|
||||
);
|
||||
expect([...exits.keys()]).toEqual(['codeman-aaaa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TmuxManager pane-exit bookkeeping', () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it('reports nothing before any tick has run', () => {
|
||||
const manager = new TmuxManager();
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the timestamp of the FIRST tick that saw an unchanged exit', () => {
|
||||
// The stamp says when the agent was found gone, so a pane that stays dead
|
||||
// must not have its age reset every two seconds.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW });
|
||||
});
|
||||
|
||||
it('starts a new observation when the exit status changes', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|137|'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 137, at: NOW + 2000 });
|
||||
});
|
||||
|
||||
it('forgets the exit once the same session reports a live pane', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prunes an exit for a session an authoritative read did not mention', () => {
|
||||
// `list-panes -a` lists every pane on the socket, so a session missing from
|
||||
// a successful read has no pane at all and no exit to report. Keeping the
|
||||
// entry would grow the map forever as tmux sessions come and go outside
|
||||
// killSession(). A FAILED or empty read never reaches here — refreshPaneExits
|
||||
// returns before calling this, which is the case the next test covers.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-bbbb|200|1|0|'), NOW));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
expect(manager.getPaneExit('codeman-bbbb')).toEqual({ status: 0, at: NOW });
|
||||
});
|
||||
|
||||
it('starts a new observation when the same status comes from a different pane pid', () => {
|
||||
// A second command in the same pane that also exited 0 is a NEW death, and
|
||||
// its `at` must say so. Only reachable when the respawn bypassed
|
||||
// respawnPane() — a hand-run `tmux respawn-pane` — since every Codeman path
|
||||
// clears the entry outright.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 60_000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW + 60_000 });
|
||||
});
|
||||
|
||||
it('forgets an exit on request, which is what a respawned pane needs', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.clearPaneExit('codeman-aaaa');
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// predicate is what keeps that tick from exec'ing tmux to find out.
|
||||
const base = {
|
||||
sessionId: 's1',
|
||||
muxName: 'codeman-aaaa',
|
||||
pid: 100,
|
||||
createdAt: 0,
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: true,
|
||||
};
|
||||
|
||||
it('says no for an empty manager', () => {
|
||||
expect(hasObservablePaneSession([])).toBe(false);
|
||||
});
|
||||
|
||||
it('says yes for a local session, which is the whole reason the watcher runs', () => {
|
||||
expect(hasObservablePaneSession([base])).toBe(true);
|
||||
});
|
||||
|
||||
it('says no for a remote session, whose local pane holds the ssh client', () => {
|
||||
expect(hasObservablePaneSession([{ ...base, remote: { host: 'box', user: 'me' } }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says no for a Docker case, whose local pane holds a `docker exec`', () => {
|
||||
expect(hasObservablePaneSession([{ ...base, docker: { containerName: 'c1' } }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says no for a record rebuilt from the socket, which carries no provenance', () => {
|
||||
// `reconcileSessions()` gives it a synthetic id that matches no state.json
|
||||
// entry, so a remote session rediscovered that way looks local. Session
|
||||
// forces UNKNOWN for it, so reading tmux for it buys nothing.
|
||||
expect(hasObservablePaneSession([{ ...base, discovered: true }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says yes when one local session sits among sessions that cannot answer', () => {
|
||||
// The read is one batched call for the whole socket, so a single local
|
||||
// session is enough to make the tick worth paying for.
|
||||
expect(
|
||||
hasObservablePaneSession([
|
||||
{ ...base, sessionId: 's1', remote: { host: 'box', user: 'me' } },
|
||||
{ ...base, sessionId: 's2', discovered: true },
|
||||
{ ...base, sessionId: 's3' },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The predicate has to agree with `Session.paneExitApplies`, which is where
|
||||
// the rule is enforced; that pairing is pinned in session-pane-exit.test.ts,
|
||||
// where a real Session can answer for itself.
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user