Files
gstack/test/session-runner-startup-grace.test.ts
T
Garry TanandClaude Fable 5 5b04f05ba4 test: review-army + adversarial test hardening
- Tripwire scans execFileSync too (ceiling 8: two more grep-needle string
  exemptions); merge-introduced timeout-less spawnSync in
  question-preference-hook fixed — the tripwire caught a site that landed
  on main AFTER the sweep, on its first day.
- gstack-detach gains TWO watchdog kill regression tests: TERM-immune
  grandchild (the killpg-after-grace escalation) and the leader-dies
  variant (the pgid-at-spawn fix — the case the first test cannot see).
- eval-flake-rank gets its unit suite (final-attempt accounting, artifact
  exclusion, shard recursion, recency bound).
- Groupkill/startup-grace shim markers are per-run unique (pid-suffixed
  sleep durations): sibling Conductor worktrees run free suites with no
  machine lock, and fixed markers let one run pgrep/pkill the other's
  shims — a cross-run flake inside the anti-flake tests.
- flake-ledger test pins the project-scoped local default; stale empty
  section headers in touchfiles-data deleted (they invited entries under
  deliberately retired categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 05:38:26 +00:00

103 lines
4.3 KiB
TypeScript

/**
* Two-phase timeout pins for the claude session runner (WS4c).
*
* The old single timer charged API queue latency to the work budget — the
* recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump
* receipts (180→300s, 240→360s, 300→420s, 90→300s). The split:
* startup phase — no NDJSON byte yet; killed at the grace with the
* DISTINCT reason 'timeout_startup' (availability, not behavior);
* work phase — armed on the first byte for the REMAINING budget, so the
* total wall never exceeds `timeout` (tier envelopes are margin-free:
* tests pass `timeout: CAPTURE_MS` and use the same tier as bun budget).
*
* Also pins the TODOS-filed 300s CI startup floor: shared CI runners queue
* harder, and a floor below 300s converts ordinary queueing into false reds.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
runSkillTest,
STARTUP_GRACE_CI_FLOOR_MS,
STARTUP_GRACE_MS,
} from './helpers/session-runner';
describe('session-runner two-phase timeout', () => {
test('CI startup-grace floor is 300s and the local default is sane', () => {
expect(STARTUP_GRACE_CI_FLOOR_MS).toBe(300_000);
expect(STARTUP_GRACE_MS).toBeGreaterThanOrEqual(60_000);
expect(STARTUP_GRACE_MS).toBeLessThanOrEqual(STARTUP_GRACE_CI_FLOOR_MS);
});
test('a run whose first byte arrives late still gets its work budget honored within the total', async () => {
// Fake claude: silent for 2s (startup latency), then streams NDJSON and
// wedges. startupGraceMs=4s tolerates the latency; work budget then
// kills at ~timeout. exitReason must be plain 'timeout' (work phase),
// NOT 'timeout_startup', and the wall must respect the total envelope.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grace-'));
const shimDir = path.join(dir, 'bin');
fs.mkdirSync(shimDir);
fs.writeFileSync(path.join(shimDir, 'claude'), [
'#!/bin/bash',
'sleep 2',
'echo \'{"type":"system","subtype":"init"}\'',
`exec sleep 6071.${process.pid}`,
].join('\n') + '\n', { mode: 0o755 });
const realPath = process.env.PATH;
process.env.PATH = `${shimDir}:${realPath}`;
try {
const started = Date.now();
const result = await runSkillTest({
prompt: 'ignored',
workingDirectory: dir,
maxTurns: 1,
timeout: 5_000,
startupGraceMs: 4_000,
testName: 'grace-probe-work-phase',
});
const wall = Date.now() - started;
expect(result.exitReason).toBe('timeout');
expect(result.firstResponseMs).toBeGreaterThanOrEqual(1_500);
// Total envelope: startup consumed ~2s, work phase gets the remainder —
// wall ≈ timeout (5s) + stderr grace (5s), never grace+timeout stacked.
expect(wall).toBeLessThan(20_000);
} finally {
process.env.PATH = realPath;
Bun.spawnSync(['pkill', '-f', `sleep 6071\\.${process.pid}`], { timeout: 5_000 });
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
test('a silent API is killed at the grace, early, with the startup reason', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grace-'));
const shimDir = path.join(dir, 'bin');
fs.mkdirSync(shimDir);
fs.writeFileSync(path.join(shimDir, 'claude'), `#!/bin/bash\nexec sleep 6072.${process.pid}\n`, { mode: 0o755 });
const realPath = process.env.PATH;
process.env.PATH = `${shimDir}:${realPath}`;
try {
const started = Date.now();
const result = await runSkillTest({
prompt: 'ignored',
workingDirectory: dir,
maxTurns: 1,
timeout: 30_000, // generous work budget…
startupGraceMs: 2_000, // …but startup dies fast when nothing answers
testName: 'grace-probe-startup',
});
const wall = Date.now() - started;
expect(result.exitReason).toBe('timeout_startup');
// The whole point: ~2s + drain grace, NOT the 30s work budget.
expect(wall).toBeLessThan(15_000);
expect(result.costEstimate.turnsUsed).toBe(0);
} finally {
process.env.PATH = realPath;
Bun.spawnSync(['pkill', '-f', `sleep 6072\\.${process.pid}`], { timeout: 5_000 });
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});