Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
331 lines
11 KiB
JavaScript
331 lines
11 KiB
JavaScript
/**
|
|
* ADR-150 P0 — runStreamHandler backstop behavior.
|
|
*
|
|
* Spawns a long-sleeping shell script as the runner stand-in (via
|
|
* RUN_CLAUDE_SCRIPT_OVERRIDE) with `backstopMs=200`. The handler's wall-
|
|
* clock guard MUST fire SIGTERM and the returned summary MUST carry
|
|
* `terminalReason='backstop'` so the dispatcher can persist
|
|
* `run_terminal_reason='backstop'` (vs the old generic 'timeout').
|
|
*
|
|
* Also exercises the `onMeaningfulEvent` callback ordering: it MUST fire
|
|
* AFTER `onEvent` for every well-formed NDJSON line.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
runStreamHandler,
|
|
DEFAULT_BACKSTOP_WARN_RATIO,
|
|
} from '../run-stream-handler.mjs';
|
|
|
|
let scriptDir;
|
|
let sleepScript;
|
|
let echoScript;
|
|
|
|
beforeAll(() => {
|
|
scriptDir = mkdtempSync(join(tmpdir(), 'adr150-runstream-'));
|
|
|
|
// Long-sleep script: ignores stdin, sleeps 30s. The 200ms backstop will
|
|
// SIGTERM the group well before the sleep completes.
|
|
sleepScript = join(scriptDir, 'sleep.sh');
|
|
writeFileSync(sleepScript, '#!/bin/bash\ncat > /dev/null\nsleep 30\n', { mode: 0o755 });
|
|
chmodSync(sleepScript, 0o755);
|
|
|
|
// Echo script: emits 3 NDJSON lines and exits. Used to verify the
|
|
// onMeaningfulEvent callback ordering vs onEvent.
|
|
echoScript = join(scriptDir, 'echo.sh');
|
|
const echoBody = [
|
|
'#!/bin/bash',
|
|
'cat > /dev/null',
|
|
`echo '{"type":"info","message":"starting"}'`,
|
|
`echo '{"type":"output","content":"thinking"}'`,
|
|
`echo '{"type":"result","status":"success"}'`,
|
|
'exit 0',
|
|
].join('\n');
|
|
writeFileSync(echoScript, echoBody, { mode: 0o755 });
|
|
chmodSync(echoScript, 0o755);
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { rmSync(scriptDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
});
|
|
|
|
describe('runStreamHandler — backstop fires terminalReason=backstop', () => {
|
|
it('backstopMs=200 + 30s sleep → SIGTERM, finalStatus=timeout, terminalReason=backstop', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99001,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 200,
|
|
heartbeatMs: 60_000, // suppress heartbeat noise
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(summary.finalStatus).toBe('timeout');
|
|
expect(summary.terminalReason).toBe('backstop');
|
|
// Real wall-clock kill: duration well past 200ms but well under 30s.
|
|
expect(summary.durationMs).toBeGreaterThanOrEqual(200);
|
|
expect(summary.durationMs).toBeLessThan(20_000);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('runStreamHandler — onMeaningfulEvent ordering', () => {
|
|
it('onMeaningfulEvent fires AFTER onEvent for each NDJSON line', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = echoScript;
|
|
|
|
const trace = [];
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99002,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 10_000,
|
|
heartbeatMs: 60_000,
|
|
onEvent: (evt) => { trace.push(['evt', evt.type]); },
|
|
onMeaningfulEvent: (evt) => { trace.push(['meaningful', evt.type]); },
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(summary.finalStatus).toBe('success');
|
|
expect(summary.eventCount).toBe(3);
|
|
// Pairs come back in (evt, meaningful) order per line.
|
|
expect(trace).toEqual([
|
|
['evt', 'info'], ['meaningful', 'info'],
|
|
['evt', 'output'], ['meaningful', 'output'],
|
|
['evt', 'result'], ['meaningful', 'result'],
|
|
]);
|
|
}, 30_000);
|
|
|
|
it('onEvent throwing does not block onMeaningfulEvent', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = echoScript;
|
|
|
|
const meaningfulSeen = [];
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99003,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 10_000,
|
|
heartbeatMs: 60_000,
|
|
onEvent: () => { throw new Error('intentional onEvent failure'); },
|
|
onMeaningfulEvent: (evt) => { meaningfulSeen.push(evt.type); },
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(summary.finalStatus).toBe('success');
|
|
expect(meaningfulSeen).toEqual(['info', 'output', 'result']);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('runStreamHandler — backstop warn (ADR-0042 Task 6)', () => {
|
|
// backstopMs=400 + warnRatio=0.5 → warn at ~200ms, kill at ~400ms.
|
|
// Picked tight values so the whole suite still completes well under
|
|
// the 30s test timeout while leaving room for scheduler jitter.
|
|
it('fires onBackstopWarn exactly once at the configured ratio', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
const warns = [];
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99100,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 400,
|
|
backstopWarnRatio: 0.5,
|
|
heartbeatMs: 60_000,
|
|
onBackstopWarn: (w) => warns.push(w),
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(summary.finalStatus).toBe('timeout');
|
|
expect(summary.terminalReason).toBe('backstop');
|
|
expect(warns).toHaveLength(1);
|
|
const w = warns[0];
|
|
expect(typeof w.at).toBe('string');
|
|
expect(w.ratio).toBeCloseTo(0.5, 5);
|
|
expect(w.thresholdMs).toBe(200);
|
|
expect(w.backstopMs).toBe(400);
|
|
expect(w.elapsedMs).toBeGreaterThanOrEqual(200);
|
|
expect(w.elapsedMs).toBeLessThan(400);
|
|
}, 30_000);
|
|
|
|
it('falls back to DEFAULT_BACKSTOP_WARN_RATIO when ratio is omitted', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
const warns = [];
|
|
try {
|
|
await runStreamHandler({
|
|
ticketId: 99101,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 400,
|
|
// backstopWarnRatio omitted → default
|
|
heartbeatMs: 60_000,
|
|
onBackstopWarn: (w) => warns.push(w),
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(warns).toHaveLength(1);
|
|
expect(warns[0].ratio).toBeCloseTo(DEFAULT_BACKSTOP_WARN_RATIO, 5);
|
|
expect(warns[0].thresholdMs).toBe(Math.floor(400 * DEFAULT_BACKSTOP_WARN_RATIO));
|
|
}, 30_000);
|
|
|
|
it('does not fire when the run finishes before the warn threshold', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = echoScript; // exits ~immediately
|
|
|
|
const warns = [];
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99102,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 60_000,
|
|
backstopWarnRatio: 0.5, // warn at 30s — script exits well before
|
|
heartbeatMs: 60_000,
|
|
onBackstopWarn: (w) => warns.push(w),
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
|
|
expect(summary.finalStatus).toBe('success');
|
|
expect(warns).toHaveLength(0);
|
|
}, 30_000);
|
|
|
|
it('still kills on backstop when onBackstopWarn callback throws', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99103,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 400,
|
|
backstopWarnRatio: 0.5,
|
|
heartbeatMs: 60_000,
|
|
onBackstopWarn: () => { throw new Error('intentional warn failure'); },
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
expect(summary.finalStatus).toBe('timeout');
|
|
expect(summary.terminalReason).toBe('backstop');
|
|
}, 30_000);
|
|
|
|
it('out-of-range ratios silently fall back to the default', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
const warns = [];
|
|
try {
|
|
await runStreamHandler({
|
|
ticketId: 99104,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 400,
|
|
backstopWarnRatio: 2.0, // ≥1 → invalid → fallback to default
|
|
heartbeatMs: 60_000,
|
|
onBackstopWarn: (w) => warns.push(w),
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
expect(warns).toHaveLength(1);
|
|
expect(warns[0].ratio).toBeCloseTo(DEFAULT_BACKSTOP_WARN_RATIO, 5);
|
|
}, 30_000);
|
|
|
|
it('skips warn entirely when no onBackstopWarn callback is provided', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99105,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
backstopMs: 400,
|
|
backstopWarnRatio: 0.5,
|
|
heartbeatMs: 60_000,
|
|
// onBackstopWarn omitted — runner must not throw and kill must still fire.
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
expect(summary.finalStatus).toBe('timeout');
|
|
expect(summary.terminalReason).toBe('backstop');
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('runStreamHandler — backstopMs aliases timeoutMs', () => {
|
|
it('caller-supplied timeoutMs is honored when backstopMs is omitted', async () => {
|
|
const prev = process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = sleepScript;
|
|
|
|
let summary;
|
|
try {
|
|
summary = await runStreamHandler({
|
|
ticketId: 99004,
|
|
workspacePath: scriptDir,
|
|
prompt: 'noop',
|
|
agentId: '1',
|
|
timeoutMs: 200, // deprecated alias still works
|
|
heartbeatMs: 60_000,
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
else process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = prev;
|
|
}
|
|
expect(summary.finalStatus).toBe('timeout');
|
|
expect(summary.terminalReason).toBe('backstop');
|
|
}, 30_000);
|
|
});
|