Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions cli/__tests__/adapters.process-group.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* The claude and codex adapters start their child in its own process group
* only when the run supervisor asks (ctx.ownProcessGroup), because only the
* supervisor kills the group. Live seats run unsupervised, and their spawn
* options must stay exactly what they were.
*/
import { jest } from '@jest/globals';
import { EventEmitter } from 'events';

await jest.unstable_mockModule('child_process', () => ({
spawnSync: jest.fn(() => ({ status: 1, stdout: '', stderr: '' })),
spawn: jest.fn(),
}));

const claude = (await import('../src/lib/adapters/claude.js')).default;
const codex = (await import('../src/lib/adapters/codex.js')).default;
const { LEADS_PROCESS_GROUP, OWN_PROCESS_GROUP } = await import('@commonlyai/seats/process-group');

const makeSpawnImpl = () => {
const calls = [];
const impl = (cmd, args, opts) => {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = jest.fn();
calls.push({ cmd, args, opts, proc });
setTimeout(() => {
proc.stdout.emit('data', Buffer.from('ok'));
proc.emit('close', 0);
}, 0);
return proc;
};
return { impl, calls };
};

const ADAPTERS = [['claude', claude], ['codex', codex]];
const POSIX = process.platform !== 'win32';

describe.each(ADAPTERS)('%s adapter — process group', (name, adapter) => {
test('unsupervised: spawns with exactly cwd, env and stdio, no detached, no marker', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', { environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl });
expect(calls).toHaveLength(1);
expect(Object.keys(calls[0].opts).sort()).toEqual(['cwd', 'env', 'stdio']);
expect(calls[0].opts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(calls[0].proc[LEADS_PROCESS_GROUP]).toBeUndefined();
});

(POSIX ? test : test.skip)('supervised: spawns detached and marks the child as leading its group', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', {
environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl, [OWN_PROCESS_GROUP]: true,
});
expect(calls).toHaveLength(1);
expect(calls[0].opts.detached).toBe(true);
expect(Object.keys(calls[0].opts).sort()).toEqual(['cwd', 'detached', 'env', 'stdio']);
expect(calls[0].proc[LEADS_PROCESS_GROUP]).toBe(true);
});

test('a truthy non-true value does not opt in', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', {
environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl, [OWN_PROCESS_GROUP]: 'yes',
});
expect(calls[0].opts).not.toHaveProperty('detached');
});
});
136 changes: 136 additions & 0 deletions cli/__tests__/seat-stop.process.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* seat-stop with real processes, wired the way `agent run` wires it: SIGTERM
* calls requestStop, and the exit path calls onExit. The supervised child
* leads its own group and holds a grandchild (`sleep 300 &`), the shape of a
* CLI holding an MCP server.
*
* - A launchd seat (its plist exists): the groups die a margin before
* ExitTimeOut, while the turn is still running.
* - A daemon-spawned seat (no plist): nothing is armed, the turn runs past the
* 15 s a default deadline would have cut it at, and the groups die on exit.
*/
import { spawn } from 'child_process';
import { once } from 'events';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const HERE = dirname(fileURLToPath(import.meta.url));
const SEAT_STOP = pathToFileURL(join(HERE, '..', 'src', 'lib', 'seat-stop.js')).href;
const SEATS = join(HERE, '..', '..', 'packages', 'seats', 'src');
const seats = (file) => JSON.stringify(pathToFileURL(join(SEATS, file)).href);
const POSIX = process.platform !== 'win32';

const isRunning = (pid) => {
try { process.kill(pid, 0); } catch (error) { return error.code !== 'ESRCH'; }
try { return !/^\d+ \(.*\) Z/.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return true; }
};
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
const waitFor = async (predicate, ms = 5000) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (predicate()) return true;
await sleep(25);
}
return predicate();
};
const reap = (pid) => { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } };

const HOST = ({ home, pidFile, goFile }) => `
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { createSeatStop } from ${JSON.stringify(SEAT_STOP)};
import { superviseRun } from ${seats('supervisor.js')};
import { createTraceStore } from ${seats('trace/store.js')};
import { observeSpawn } from ${seats('runtime-observation.js')};
import { spawnInOwnGroup, wantsOwnProcessGroup } from ${seats('process-group.js')};
let stopping = false;
const seatStop = createSeatStop({ name: 'probe', stop: () => { stopping = true; }, home: ${JSON.stringify(home)} });
process.on('SIGTERM', (signal) => { process.stdout.write('stopping deadline=' + seatStop.requestStop(signal) + '\\n'); });
process.once('exit', seatStop.onExit);
const script = "sleep 300 & echo $! > '${pidFile}'; while [ ! -f '${goFile}' ]; do sleep 0.05; done; echo done; wait";
const adapter = { name: 'sh', spawn: (prompt, ctx) => new Promise((resolve, reject) => {
const child = spawnInOwnGroup(spawn, 'sh', ['-c', script], { stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup: wantsOwnProcessGroup(ctx) });
observeSpawn(child, ctx.onSpawn, {});
child.stdout.on('data', (chunk) => { if (String(chunk).includes('done')) resolve({ text: 'done' }); });
child.on('close', (code, signal) => reject(new Error('sh ended ' + (code ?? signal))));
child.once('spawn', () => {
const poll = setInterval(() => {
if (!existsSync('${pidFile}') || !/^\\d+\\s*$/.test(readFileSync('${pidFile}', 'utf8'))) return;
clearInterval(poll);
process.stdout.write('ready ' + child.pid + '\\n');
}, 20);
});
}) };
const out = await superviseRun({ adapter, prompt: 'p', ctx: {}, store: createTraceStore({ root: ${JSON.stringify(join(home, 'trace'))} }),
policy: { stallMs: 0, timeoutMs: 0 } });
process.stdout.write('turn ' + out.outcome + '\\n');
if (stopping) process.exit(0);
`;

let home;
let state;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'seat-stop-proc-'));
state = null;
});
afterEach(() => {
if (state) {
if (state.grandchild) reap(state.grandchild);
if (state.leader) reap(state.leader);
state.host.kill('SIGKILL');
}
rmSync(home, { recursive: true, force: true });
});

const startHost = async () => {
const pidFile = join(home, 'grandchild.pid');
const goFile = join(home, 'go');
const script = join(home, 'host.mjs');
writeFileSync(script, HOST({ home, pidFile, goFile }));
const host = spawn(process.execPath, [script], { stdio: ['ignore', 'pipe', 'inherit'] });
state = { host, goFile, out: '' };
host.stdout.on('data', (chunk) => { state.out += chunk; });
state.exited = once(host, 'exit');
expect(await waitFor(() => /ready \d+/.test(state.out))).toBe(true);
state.leader = Number(state.out.match(/ready (\d+)/)[1]);
expect(await waitFor(() => existsSync(pidFile) && /^\d+\s*$/.test(readFileSync(pidFile, 'utf8')))).toBe(true);
state.grandchild = Number(readFileSync(pidFile, 'utf8'));
return state;
};

const describePosix = POSIX ? describe : describe.skip;

describePosix('seat-stop — real processes', () => {
test('a seat with a plist: the groups die a margin before ExitTimeOut, mid-turn', async () => {
// ExitTimeOut 2 s → deadline 1.5 s.
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true });
writeFileSync(join(home, 'Library', 'LaunchAgents', 'me.commonly.seat-probe.plist'),
'<?xml version="1.0"?><plist version="1.0"><dict><key>ExitTimeOut</key><integer>2</integer></dict></plist>');
await startHost();
const sent = Date.now();
state.host.kill('SIGTERM');
expect(await waitFor(() => /stopping deadline=1500/.test(state.out))).toBe(true);
await sleep(750);
expect(isRunning(state.grandchild)).toBe(true);
expect(await waitFor(() => !isRunning(state.grandchild), 2000)).toBe(true);
expect(Date.now() - sent).toBeLessThan(2000);
expect(existsSync(state.goFile)).toBe(false);
}, 30000);

test('a seat with no plist: nothing is armed, the turn runs past 15 s, and the groups die on exit', async () => {
await startHost();
state.host.kill('SIGTERM');
expect(await waitFor(() => /stopping deadline=null/.test(state.out))).toBe(true);
await sleep(16000);
expect(isRunning(state.grandchild)).toBe(true);
expect(isRunning(state.leader)).toBe(true);
writeFileSync(state.goFile, '');
const [code] = await Promise.race([state.exited, sleep(5000).then(() => ['timeout'])]);
expect(state.out).toMatch(/turn ok/);
expect(code).toBe(0);
expect(await waitFor(() => !isRunning(state.grandchild))).toBe(true);
expect(await waitFor(() => !isRunning(state.leader))).toBe(true);
}, 40000);
});
128 changes: 128 additions & 0 deletions cli/__tests__/seat-stop.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* seat-stop: SIGTERM keeps the turn, and arms a supervised-group kill a margin
* before the seat plist's ExitTimeOut (launchd's SIGKILL). The plist is only
* read. The process-level behaviour is covered with real processes in
* packages/seats/test/supervisor-tree-and-deadline.test.js.
*/
import { jest } from '@jest/globals';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
DEFAULT_EXIT_TIMEOUT_S, createSeatStop, groupKillDeadlineMs, readExitTimeOut, readSeatPlist, seatPlistPath,
} from '../src/lib/seat-stop.js';

const plist = (body) => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>me.commonly.seat-anvil</string>
<key>KeepAlive</key>
<true/>
${body}
</dict>
</plist>
`;

let home;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'seat-stop-'));
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true });
});
afterEach(() => { rmSync(home, { recursive: true, force: true }); });

const writePlist = (name, body) => {
const path = seatPlistPath(name, home);
writeFileSync(path, plist(body));
return path;
};

describe('seat-stop — ExitTimeOut', () => {
test('the plist path is the seat launch agent', () => {
expect(seatPlistPath('anvil', home)).toBe(join(home, 'Library', 'LaunchAgents', 'me.commonly.seat-anvil.plist'));
expect(seatPlistPath('../x', home)).toBeNull();
expect(seatPlistPath('a/b', home)).toBeNull();
});

test('reads ExitTimeOut from an XML plist, and only reads it', () => {
const path = writePlist('anvil', ' <key>ExitTimeOut</key>\n <integer>40</integer>');
const before = readFileSync(path, 'utf8');
expect(readExitTimeOut(path)).toBe(40);
expect(readFileSync(path, 'utf8')).toBe(before);
});

test('unset, zero, missing file and no path all read as unset', () => {
expect(readExitTimeOut(writePlist('a', ''))).toBeNull();
expect(readExitTimeOut(writePlist('b', ' <key>ExitTimeOut</key>\n <integer>0</integer>'))).toBeNull();
expect(readExitTimeOut(join(home, 'nope.plist'))).toBeNull();
expect(readExitTimeOut(null)).toBeNull();
});

test('a binary plist is read through plutil -extract', () => {
const run = jest.fn(() => ({ status: 0, stdout: '30\n' }));
const value = readExitTimeOut('/x.plist', { readFile: () => Buffer.from('bplist00...'), run });
expect(value).toBe(30);
expect(run).toHaveBeenCalledWith('plutil', ['-extract', 'ExitTimeOut', 'raw', '-o', '-', '/x.plist'], expect.any(Object));
});

test('the deadline is ExitTimeOut less min(5 s, a quarter); unset means 20 s', () => {
expect(DEFAULT_EXIT_TIMEOUT_S).toBe(20);
expect(groupKillDeadlineMs(null)).toBe(15000);
expect(groupKillDeadlineMs(40)).toBe(35000);
expect(groupKillDeadlineMs(4)).toBe(3000);
for (const s of [1, 4, 20, 40, 120]) expect(groupKillDeadlineMs(s)).toBeLessThan(s * 1000);
});
});

describe('seat-stop — requestStop', () => {
const make = (name = 'anvil') => {
const stop = jest.fn();
const arm = jest.fn();
const kill = jest.fn();
return { stop, arm, kill, seatStop: createSeatStop({ name, stop, home, deps: { arm, kill } }) };
};

test('SIGTERM stops the loop and arms the group kill before ExitTimeOut', () => {
writePlist('anvil', ' <key>ExitTimeOut</key>\n <integer>8</integer>');
const { stop, arm, kill, seatStop } = make();
expect(seatStop.requestStop('SIGTERM')).toBe(6000);
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).toHaveBeenCalledWith(6000);
expect(kill).not.toHaveBeenCalled();
});

test('SIGTERM on a seat with no plist (daemon-spawned) arms nothing: the exit path alone kills', () => {
const { stop, arm, seatStop } = make('daemon-seat');
expect(seatStop.requestStop('SIGTERM')).toBeNull();
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).not.toHaveBeenCalled();
});

test('SIGTERM on a seat whose plist has no ExitTimeOut uses launchd\'s 20 s default', () => {
writePlist('anvil', '');
const { arm, seatStop } = make();
expect(seatStop.requestStop('SIGTERM')).toBe(15000);
expect(arm).toHaveBeenCalledWith(15000);
});

test('a plist that exists but cannot be read still counts as present, at the default', () => {
expect(readSeatPlist('/p.plist', { exists: () => true, readFile: () => { throw new Error('EACCES'); } }))
.toEqual({ present: true, exitTimeOut: null });
expect(readSeatPlist(join(home, 'nope.plist'))).toEqual({ present: false, exitTimeOut: null });
expect(readSeatPlist(null)).toEqual({ present: false, exitTimeOut: null });
});

test('SIGINT stops the loop and arms nothing', () => {
const { stop, arm, seatStop } = make();
expect(seatStop.requestStop('SIGINT')).toBeNull();
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).not.toHaveBeenCalled();
});

test('the exit path kills the supervised groups', () => {
const { kill, seatStop } = make();
seatStop.onExit();
expect(kill).toHaveBeenCalledTimes(1);
});
});
9 changes: 7 additions & 2 deletions cli/src/commands/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { detectBwrap } from '../lib/sandbox/bwrap.js';
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
import { createSeatStop } from '../lib/seat-stop.js';
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
import {
Expand Down Expand Up @@ -3612,12 +3613,16 @@ Docs:
// SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops
// future polls but lets the in-flight turn finish and ack before the
// event loop drains; exiting here would cut a model turn in half.
const requestStop = () => {
// seat-stop also arms the supervised-group deadline before launchd's
// SIGKILL, and kills any group still live when the process exits.
const seatStop = createSeatStop({ name, stop });
const requestStop = (signal) => {
console.log(`\n${stamp()} [${name}] stopping after the current turn...`);
stop();
seatStop.requestStop(signal);
};
process.on('SIGINT', requestStop);
process.on('SIGTERM', requestStop);
process.once('exit', seatStop.onExit);
});

// ── detach (ADR-005) ──────────────────────────────────────────────────────
Expand Down
Loading
Loading