Skip to content

Commit 4827e63

Browse files
fix(evals): keep timeout status, cancel in-flight work, aggregate gate suspensions (#1047)
A run that times out after emitting a failure signal kept the timeout status instead of being recorded failed. The deadline now aborts the in-flight send and the runner quiesces the agent pump and collector before grading, so verify never runs on partial state. Gate suspensions are summed into completion totals and printed in the human summary instead of being collected and dropped.
1 parent 77b8c30 commit 4827e63

4 files changed

Lines changed: 144 additions & 28 deletions

File tree

‎evals/completion/lib.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ describe("completion totals", () => {
7777
expect(totals.meanTurnsToCompletion).toBe(0);
7878
expect(totals.meanAgentDurationMs).toBe(0);
7979
});
80+
81+
test("aggregates gate suspensions across runs", () => {
82+
const totals = computeTotals([
83+
result({ gateSuspensions: 2 }),
84+
result({ taskId: "stall-read", gateSuspensions: 3 }),
85+
]);
86+
expect(totals.totalGateSuspensions).toBe(5);
87+
});
8088
});
8189

8290
describe("boundary parsing", () => {
@@ -136,4 +144,26 @@ describe("human summary", () => {
136144
expect(summary).toContain("version-endpoint r0: complete");
137145
expect(summary).toContain("stall-read r0: incomplete");
138146
});
147+
148+
test("prints the aggregated gate suspensions", () => {
149+
const results = [
150+
result({ gateSuspensions: 2 }),
151+
result({ taskId: "stall-read", gateSuspensions: 3 }),
152+
];
153+
const report: CompletionReportType = CompletionReport.assert({
154+
harness: "completion-baseline",
155+
version: 1,
156+
startedAt: "2026-09-14T00:00:00.000Z",
157+
finishedAt: "2026-09-14T00:01:00.000Z",
158+
commitSha: "deadbeef",
159+
provider: "stub-scripted",
160+
model: "completion-baseline-v1",
161+
repeats: 1,
162+
taskSetVersion: 1,
163+
taskIds: ["version-endpoint", "stall-read"],
164+
results,
165+
totals: computeTotals(results),
166+
});
167+
expect(formatSummary(report)).toContain("gate suspensions 5");
168+
});
139169
});

‎evals/completion/lib.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const CompletionTotals = type({
8585
totalCompactionEvents: "number.integer >= 0",
8686
totalDoomLoopInterventions: "number.integer >= 0",
8787
totalThrashInterventions: "number.integer >= 0",
88+
totalGateSuspensions: "number.integer >= 0",
8889
});
8990
export type CompletionTotals = typeof CompletionTotals.infer;
9091

@@ -158,6 +159,7 @@ export function computeTotals(
158159
totalThrashInterventions: sum(
159160
results.map((result) => result.thrashInterventions),
160161
),
162+
totalGateSuspensions: sum(results.map((result) => result.gateSuspensions)),
161163
};
162164
}
163165

@@ -172,7 +174,7 @@ export function formatSummary(report: CompletionReport): string {
172174
`task set v${report.taskSetVersion}: ${report.taskIds.join(", ")}`,
173175
`completion rate ${formatRate(report.totals.completionRate)} (${report.totals.completedRuns}/${report.totals.runsTotal} runs)`,
174176
`mean turns to completion ${report.totals.meanTurnsToCompletion.toFixed(1)} mean agent time ${Math.round(report.totals.meanAgentDurationMs)}ms`,
175-
`retries ${report.totals.totalRetries} compaction events ${report.totals.totalCompactionEvents} doom-loop interventions ${report.totals.totalDoomLoopInterventions} thrash interventions ${report.totals.totalThrashInterventions}`,
177+
`retries ${report.totals.totalRetries} compaction events ${report.totals.totalCompactionEvents} doom-loop interventions ${report.totals.totalDoomLoopInterventions} thrash interventions ${report.totals.totalThrashInterventions} gate suspensions ${report.totals.totalGateSuspensions}`,
176178
"",
177179
...report.results.map(
178180
(result) =>

‎scripts/eval-completion.test.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { withTimeout } from "./eval-completion.js";
2+
import { resolveRunStatus, withTimeout } from "./eval-completion.js";
33

44
describe("withTimeout", () => {
55
test("rejects a hung run after the timeout", async () => {
@@ -28,4 +28,47 @@ describe("withTimeout", () => {
2828
withTimeout(Promise.reject(new Error("boom")), 1000, "task"),
2929
).rejects.toThrow("boom");
3030
});
31+
32+
test("invokes onTimeout when the deadline fires", async () => {
33+
const hung = new Promise<never>(() => undefined);
34+
let calls = 0;
35+
const onTimeout = () => {
36+
calls += 1;
37+
};
38+
const error = await withTimeout(hung, 50, "task", { onTimeout }).catch(
39+
(err: unknown) => err,
40+
);
41+
expect(error).toBeInstanceOf(Error);
42+
expect(calls).toBe(1);
43+
});
44+
45+
test("skips onTimeout when the inner promise settles first", async () => {
46+
let calls = 0;
47+
const onTimeout = () => {
48+
calls += 1;
49+
};
50+
await expect(
51+
withTimeout(Promise.resolve("done"), 50, "task", { onTimeout }),
52+
).resolves.toBe("done");
53+
const failing = Promise.reject(new Error("boom"));
54+
const rejected = withTimeout(failing, 50, "task", { onTimeout });
55+
await expect(rejected).rejects.toThrow("boom");
56+
expect(calls).toBe(0);
57+
});
58+
});
59+
60+
describe("resolveRunStatus", () => {
61+
test("timeout keeps status over a failure signal", () => {
62+
const status = resolveRunStatus({ timedOut: true, failed: true });
63+
expect(status).toBe("timeout");
64+
});
65+
66+
test("resolves the remaining outcomes", () => {
67+
const timeoutOnly = resolveRunStatus({ timedOut: true, failed: false });
68+
const failedOnly = resolveRunStatus({ timedOut: false, failed: true });
69+
const clean = resolveRunStatus({ timedOut: false, failed: false });
70+
expect(timeoutOnly).toBe("timeout");
71+
expect(failedOnly).toBe("failed");
72+
expect(clean).toBe("completed");
73+
});
3174
});

‎scripts/eval-completion.ts‎

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export async function withTimeout<T>(
117117
promise: Promise<T>,
118118
ms: number,
119119
label: string,
120+
opts?: { onTimeout?: () => void },
120121
): Promise<T> {
121122
let timer: ReturnType<typeof setTimeout> | undefined;
122123
try {
@@ -126,17 +127,39 @@ export async function withTimeout<T>(
126127
return await Promise.race([
127128
promise,
128129
new Promise<never>((_, reject) => {
129-
timer = setTimeout(
130-
() => reject(new Error(`${label} timed out after ${ms}ms`)),
131-
ms,
132-
);
130+
timer = setTimeout(() => {
131+
// Cancel first so the caller's in-flight work settles instead of
132+
// lingering past the deadline; the race rejects below regardless.
133+
opts?.onTimeout?.();
134+
reject(new Error(`${label} timed out after ${ms}ms`));
135+
}, ms);
133136
}),
134137
]);
135138
} finally {
136139
if (timer !== undefined) clearTimeout(timer);
137140
}
138141
}
139142

143+
/**
144+
* Timeout keeps status over failure signals: a run that timed out did not
145+
* fail, it ran out of time — even when the partial stream already carries
146+
* a failure event.
147+
*/
148+
export function resolveRunStatus(options: {
149+
timedOut: boolean;
150+
failed: boolean;
151+
}): RunStatus {
152+
if (options.timedOut) return "timeout";
153+
if (options.failed) return "failed";
154+
return "completed";
155+
}
156+
157+
// Backstop for the post-timeout quiesce below: the abort plus agent close
158+
// settle the live paths promptly, so this only bites when the mock pump
159+
// itself is stuck — and then it keeps a stuck pump from re-hanging the
160+
// harness at the deadline it just enforced.
161+
const SETTLE_GRACE_MS = 5_000;
162+
140163
interface PersistedTurn {
141164
role: string;
142165
}
@@ -241,31 +264,46 @@ async function runTask(
241264
if (turnComplete && event.type === "message.run.ended") return;
242265
}
243266
})().catch(() => undefined);
267+
// Abort the in-flight send when the deadline fires so its promise
268+
// settles instead of lingering past the timeout.
269+
const controller = new AbortController();
270+
const runWork = (async () => {
271+
const sendResult = await Promise.all([
272+
session.agent
273+
.send(task.prompt, { signal: controller.signal })
274+
.then((result) => {
275+
turnComplete = true;
276+
return result;
277+
}),
278+
session.harness.run({ wallClockBudgetMs: Infinity }),
279+
collect,
280+
]).then(([result]) => result);
281+
if (sendResult.type !== "reply") {
282+
throw new Error(`unexpected send outcome: ${sendResult.type}`);
283+
}
284+
})();
285+
let timedOut = false;
244286
try {
245-
await withTimeout(
246-
(async () => {
247-
const sendResult = await Promise.all([
248-
session.agent.send(task.prompt).then((result) => {
249-
turnComplete = true;
250-
return result;
251-
}),
252-
session.harness.run({ wallClockBudgetMs: Infinity }),
253-
collect,
254-
]).then(([result]) => result);
255-
if (sendResult.type !== "reply") {
256-
throw new Error(`unexpected send outcome: ${sendResult.type}`);
257-
}
258-
})(),
259-
timeoutMs,
260-
`task ${task.id}`,
261-
);
287+
await withTimeout(runWork, timeoutMs, `task ${task.id}`, {
288+
onTimeout: () => controller.abort(),
289+
});
262290
} catch (err) {
263-
runStatus =
264-
err instanceof Error && err.message.includes("timed out")
265-
? "timeout"
266-
: "failed";
291+
timedOut = err instanceof Error && err.message.includes("timed out");
267292
error = err instanceof Error ? err.message : String(err);
268293
}
294+
if (timedOut) {
295+
// Quiesce before grading: the aborted send settles at once, but the
296+
// pump and collector lag behind. Closing the agent aborts the reactor
297+
// and terminates the stream so the collector settles, then awaiting
298+
// the inner work keeps verify below off partial state.
299+
await session.agent.close().catch(() => undefined);
300+
await Promise.race([
301+
runWork.catch(() => undefined),
302+
new Promise<void>((resolve) => {
303+
setTimeout(resolve, SETTLE_GRACE_MS);
304+
}),
305+
]);
306+
}
269307
const agentDurationMs = Date.now() - agentStart;
270308
const signals = deriveSignals(events);
271309
// A guard trip rejects send() without a run.ended event; recover the
@@ -277,7 +315,10 @@ async function runTask(
277315
) {
278316
signals.doomLoopInterventions = 1;
279317
}
280-
if (signals.runFailed) runStatus = "failed";
318+
runStatus = resolveRunStatus({
319+
timedOut,
320+
failed: error !== undefined || signals.runFailed,
321+
});
281322

282323
const verifyStart = Date.now();
283324
const verify = spawnSync("bash", [resolve(COMPLETION_ROOT, task.verify)], {

0 commit comments

Comments
 (0)