mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 08:29:04 +02:00
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:
co-authored by
Claude Fable 5
parent
5b04f05ba4
commit
513111a166
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user