fix: adversarial-review runtime fixes across the telemetry + kill paths

- session-runner: exit-labeling keys off 'exit', not 'close' — an orphan
  holding the pipes could relabel a REAL exit (auth failure) as
  'timeout_startup' availability noise; the kill path still always
  group-kills and cancels the reader (labeling and unblocking are separate
  concerns). Work phase arms on a flag, not firstResponseMs===0 (a same-ms
  first byte left the startup timer live all run). The CI startup grace is
  now a real FLOOR (Math.max), matching its name and pinning test.
- gstack-detach: pgid captured AT SPAWN (== child pid under
  start_new_session) — resolving it after the grace raised ESRCH once the
  leader died on SIGTERM, orphaning TERM-immune grandchildren forever.
- test-free-shards: ledger entries carry branch + git_sha (rev-parse split:
  '--abbrev-ref HEAD HEAD' printed the branch twice and recorded it as the
  sha); local ledger default is per-PROJECT, not the machine-global tmpdir.
- eval-flake-rank: per-LINE ledger parse (one torn JSONL line vanished the
  whole series), 60-day recency bound (transcript-bearing files are MBs),
  shared isFinalizedEvalResultFile predicate (the artifact-taxonomy rule
  lived in three places); eval-store exports the predicate and finalize
  stops computing flakyRetries twice; paid-shards cleanup uses async rm
  (a SIGKILLed shard's git-workspace teardown blocked every sibling's
  stream classification on the parent event loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-31 05:38:26 +00:00
co-authored by Claude Fable 5
parent 5b04f05ba4
commit 513111a166
6 changed files with 116 additions and 21 deletions
+17 -1
View File
@@ -192,6 +192,21 @@ export function isPartialEval(data: unknown, filename: string): boolean {
return Boolean((data as { _partial?: unknown } | null)?._partial);
}
/**
* Is this path a FINALIZED eval-store result file? Single owner of the
* filename taxonomy (manifest.json / slice-N.json are runner artifacts,
* _partial* are in-progress accumulators) — the paid runner's report mode
* and eval-flake-rank both consume this instead of re-encoding the rule
* (review finding: the rule lived in three places).
*/
export function isFinalizedEvalResultFile(relPath: string): boolean {
const base = path.basename(relPath);
if (!base.endsWith('.json')) return false;
if (base === 'manifest.json' || /^slice-\d+\.json$/.test(base)) return false;
if (base.startsWith('_partial')) return false;
return true;
}
/**
* List eval JSON files in `evalDir` plus one level of `<evalDir>/shards/<slug>/`
* subdirectories (where the sharded paid runner points each shard's collector).
@@ -900,6 +915,7 @@ export class EvalCollector {
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
const passed = this.tests.filter(t => t.passed).length;
const flaky = this.flakyRetries();
const result: EvalResult = {
schema_version: SCHEMA_VERSION,
version,
@@ -917,7 +933,7 @@ export class EvalCollector {
wall_clock_ms: Date.now() - this.createdAt,
tests: this.tests,
...(this.shard ? { shard: this.shard } : {}),
...(this.flakyRetries().length > 0 ? { flaky_retries: this.flakyRetries() } : {}),
...(flaky.length > 0 ? { flaky_retries: flaky } : {}),
};
// Write eval file
+32 -6
View File
@@ -162,8 +162,13 @@ export async function runSkillTest(options: {
runId,
env: extraEnv,
} = options;
// The CI floor is a FLOOR, not a default: an explicit startupGraceMs below
// 300s in CI would re-open the queueing-becomes-false-red hole the floor
// exists for (review finding — the name promised a clamp the code lacked).
// Local runs honor the caller verbatim; timeout still caps everything.
const requestedGrace = options.startupGraceMs ?? (process.env.CI ? STARTUP_GRACE_CI_FLOOR_MS : STARTUP_GRACE_MS);
const startupGraceMs = Math.min(
options.startupGraceMs ?? (process.env.CI ? STARTUP_GRACE_CI_FLOOR_MS : STARTUP_GRACE_MS),
process.env.CI ? Math.max(requestedGrace, STARTUP_GRACE_CI_FLOOR_MS) : requestedGrace,
timeout,
);
const model = options.model ?? process.env.EVALS_MODEL ?? 'claude-sonnet-4-6';
@@ -222,9 +227,17 @@ export async function runSkillTest(options: {
proc.stdin!.end();
const stdoutWeb = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
const stderrWeb = Readable.toWeb(proc.stderr!) as ReadableStream<Uint8Array>;
// 'exit' vs 'close' matters here: 'close' waits for stdout/stderr to
// drain, which an orphaned grandchild can hold open long after claude
// itself died with a REAL exit code — labeling must key off 'exit' or an
// auth failure gets triaged as 'timeout_startup' availability noise
// (claude adversarial finding). procExited stays 'close'-based (streams
// complete) for the drain race below.
let childExited = false;
const procExited: Promise<number> = new Promise((resolve) => {
proc.on('close', (code) => resolve(code ?? 1));
proc.on('error', () => resolve(1));
proc.on('exit', () => { childExited = true; });
proc.on('close', (code) => { childExited = true; resolve(code ?? 1); });
proc.on('error', () => { childExited = true; resolve(1); });
});
// Two-phase timeout. Phase 1 (startup): no NDJSON byte yet — a shorter
@@ -240,8 +253,16 @@ export async function runSkillTest(options: {
let phaseTimer: ReturnType<typeof setTimeout>;
const killRun = (startupPhase: boolean): void => {
timedOut = true;
timedOutInStartup = startupPhase;
// Labeling and unblocking are SEPARATE concerns: a timer firing after
// the child already exited must not relabel a real exit (auth error,
// crash) as a timeout — but it must STILL group-kill and cancel the
// reader, or an orphan holding the pipes re-creates the exact
// blocked-drain hang this runner fixed (an early `return` here was the
// bug the adversarial pass caught in the first version of this guard).
if (!childExited) {
timedOut = true;
timedOutInStartup = startupPhase;
}
// Group SIGKILL (mirrors runShardChild): claude AND every tool
// subprocess it spawned die together — a bare proc.kill() left orphans
// that inherited our stdout/stderr pipes and kept the API burning
@@ -264,6 +285,7 @@ export async function runSkillTest(options: {
let liveTurnCount = 0;
let liveToolCount = 0;
let firstResponseMs = 0;
let workPhaseArmed = false;
let lastToolTime = 0;
let maxInterTurnMs = 0;
const stderrPromise = new Response(stderrWeb).text();
@@ -284,7 +306,11 @@ export async function runSkillTest(options: {
collectedLines.push(line);
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
if (firstResponseMs === 0) {
if (!workPhaseArmed) {
// Flag, not `firstResponseMs === 0`: a first line landing in the
// same millisecond as spawn would read as "not yet seen" and leave
// the startup timer live for the whole run (claude adversarial).
workPhaseArmed = true;
firstResponseMs = Date.now() - startTime;
// First byte: startup phase over — arm the work phase for the
// REMAINING budget (total wall stays <= timeout).