Skip to content

Commit 9e094c9

Browse files
authored
Retry judge timeouts with halved input caps, fix #12
Previously a 120s subprocess timeout in invokeJudge rejected past the schema-retry loop and surfaced as judge.status = "errored" with zero retries — a transient flaky-day Haiku call left the user with no scores even though a second shot would likely have succeeded. - spawnJudge now rejects with a typed JudgeTimeoutError; the retry loop treats it the same as a schema-invalid response. - Retry prompt is rebuilt with bytesCapMultiplier = 0.5 so Haiku has ~half the untrusted input to chew on. The multiplier is clamped to a finite positive number (NaN/Infinity/negative fall back to 1). - Retry budget stays at one — same cap as schema retries, so cost is bounded. - invokeJudge is exported with a spawnFn injection point; new tests cover timeout-then-success, both-timeout, and parse-failure paths without launching a real subprocess.
1 parent 8457e46 commit 9e094c9

2 files changed

Lines changed: 300 additions & 53 deletions

File tree

src/server/judge.ts

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,30 @@ export interface JudgeInput {
3030
outputs: OutputFile[];
3131
}
3232

33-
export async function runJudge(input: JudgeInput): Promise<JudgeFile> {
34-
const { prompt: inputSummary, canary } = buildJudgePrompt(input);
33+
export class JudgeTimeoutError extends Error {
34+
constructor(message: string) {
35+
super(message);
36+
this.name = 'JudgeTimeoutError';
37+
}
38+
}
39+
40+
export const JUDGE_SUBPROCESS_TIMEOUT_MS = 120_000;
41+
42+
export type SpawnJudgeFn = (
43+
claudeBin: string,
44+
prompt: string,
45+
opts: SpawnJudgeOptions,
46+
) => Promise<string>;
47+
48+
export interface RunJudgeOptions {
49+
// Test-only: override the subprocess spawn so the retry/timeout paths can
50+
// be exercised without launching real Haiku.
51+
spawnFn?: SpawnJudgeFn;
52+
}
53+
54+
export async function runJudge(input: JudgeInput, opts: RunJudgeOptions = {}): Promise<JudgeFile> {
3555
try {
36-
const parsed = await invokeJudge(input.claudeBin, inputSummary, canary, input.runDir);
56+
const parsed = await invokeJudge(input, opts.spawnFn);
3757
const file: JudgeFile = {
3858
runFolder: input.runConfig.runFolder,
3959
createdAt: new Date().toISOString(),
@@ -96,8 +116,27 @@ export interface JudgePromptArtifacts {
96116
canary: string;
97117
}
98118

99-
export function buildJudgePrompt(input: JudgeInput): JudgePromptArtifacts {
119+
export interface BuildJudgePromptOptions {
120+
// Multiplies every byte/char cap on untrusted sections. Used on timeout retry
121+
// (e.g. 0.5) to shrink the prompt so the second judge call has less to chew on.
122+
bytesCapMultiplier?: number;
123+
}
124+
125+
export function buildJudgePrompt(
126+
input: JudgeInput,
127+
opts: BuildJudgePromptOptions = {},
128+
): JudgePromptArtifacts {
100129
const { runConfig, transcript, variantContent, outputs } = input;
130+
// Default to 1 if the option is missing or non-finite/non-positive; otherwise
131+
// multiplier values like NaN or Infinity would propagate through Math.floor and
132+
// produce empty or runaway prompt sections.
133+
const rawMultiplier = opts.bytesCapMultiplier ?? 1;
134+
const m = Number.isFinite(rawMultiplier) && rawMultiplier > 0 ? rawMultiplier : 1;
135+
// Floors keep retries useful even if a future caller passes a very small multiplier.
136+
const promptCap = Math.max(256, Math.floor(JUDGE_PROMPT_CAP_BYTES * m));
137+
const variantCap = Math.max(512, Math.floor(JUDGE_VARIANT_CAP_BYTES * m));
138+
const finalMessageCap = Math.max(256, Math.floor(JUDGE_FINAL_MESSAGE_CAP_BYTES * m));
139+
const toolSummaryCap = Math.max(80, Math.floor(JUDGE_TOOL_SUMMARY_CAP_CHARS * m));
101140
// 64 bits of entropy each: too large to brute-force a guess from inside the
102141
// sandboxed variant, so the data fences and canary cannot be forged.
103142
const nonce = randomBytes(8).toString('hex');
@@ -136,22 +175,22 @@ export function buildJudgePrompt(input: JudgeInput): JudgePromptArtifacts {
136175
lines.push('');
137176

138177
lines.push('## Prompt given to the variant');
139-
lines.push(fence('prompt', bytesCap(runConfig.prompt, JUDGE_PROMPT_CAP_BYTES)));
178+
lines.push(fence('prompt', bytesCap(runConfig.prompt, promptCap)));
140179
lines.push('');
141180

142181
const variantLabel =
143182
`variant ${runConfig.variantType}` +
144183
(runConfig.skillOrAgentName ? ` ${runConfig.skillOrAgentName}` : '');
145184
lines.push('## Variant body');
146-
lines.push(fence(variantLabel, bytesCap(variantContent, JUDGE_VARIANT_CAP_BYTES)));
185+
lines.push(fence(variantLabel, bytesCap(variantContent, variantCap)));
147186
lines.push('');
148187

149188
const finalMessage = extractFinalAssistantMessage(transcript);
150189
lines.push('## Final assistant message');
151-
lines.push(fence('assistant message', midEllipsis(finalMessage, JUDGE_FINAL_MESSAGE_CAP_BYTES)));
190+
lines.push(fence('assistant message', midEllipsis(finalMessage, finalMessageCap)));
152191
lines.push('');
153192

154-
const tools = extractToolSummary(transcript);
193+
const tools = extractToolSummary(transcript, toolSummaryCap);
155194
lines.push('## Tool calls (summary)');
156195
lines.push(fence('tool summary', tools.length === 0 ? '(none)' : tools.join('\n')));
157196
lines.push('');
@@ -240,7 +279,10 @@ interface PendingToolUse {
240279
id?: string;
241280
}
242281

243-
export function extractToolSummary(transcript: TranscriptFile): string[] {
282+
export function extractToolSummary(
283+
transcript: TranscriptFile,
284+
toolSummaryCap: number = JUDGE_TOOL_SUMMARY_CAP_CHARS,
285+
): string[] {
244286
const out: string[] = [];
245287
// Primary pairing: by tool_use_id. Real-claude and fake-claude both emit ids,
246288
// so this is the path that actually runs in production.
@@ -269,17 +311,13 @@ export function extractToolSummary(transcript: TranscriptFile): string[] {
269311
}
270312
const tool = pair?.tool ?? e.tool;
271313
const args = pair?.argsSummary ?? '';
272-
const res = truncate(e.resultSummary, JUDGE_TOOL_SUMMARY_CAP_CHARS);
273-
out.push(
274-
`${tool}(${truncate(args, JUDGE_TOOL_SUMMARY_CAP_CHARS)}) → ${res}${e.isError ? ' [error]' : ''}`,
275-
);
314+
const res = truncate(e.resultSummary, toolSummaryCap);
315+
out.push(`${tool}(${truncate(args, toolSummaryCap)}) → ${res}${e.isError ? ' [error]' : ''}`);
276316
}
277317
}
278318
// Unmatched tool uses (no result captured): emit so the judge sees the gap.
279319
for (const t of fifo) {
280-
out.push(
281-
`${t.tool}(${truncate(t.argsSummary, JUDGE_TOOL_SUMMARY_CAP_CHARS)}) → (no result observed)`,
282-
);
320+
out.push(`${t.tool}(${truncate(t.argsSummary, toolSummaryCap)}) → (no result observed)`);
283321
}
284322
return out;
285323
}
@@ -304,42 +342,87 @@ function truncate(s: string, cap: number): string {
304342
return s.slice(0, cap - 1) + '…';
305343
}
306344

307-
async function invokeJudge(
345+
type AttemptResult =
346+
| { ok: true; value: JudgeModelOutput }
347+
| { ok: false; kind: 'parse'; error: string }
348+
| { ok: false; kind: 'timeout'; error: string };
349+
350+
async function attemptJudge(
351+
spawnFn: SpawnJudgeFn,
308352
claudeBin: string,
309353
prompt: string,
310354
canary: string,
311355
runDir: string,
312-
): Promise<JudgeModelOutput> {
313-
const firstAttempt = await spawnJudge(claudeBin, prompt, { jsonSchema: true });
314-
await writeRawResponse(runDir, 'first', firstAttempt);
315-
if (detectCanaryLeak(firstAttempt, canary)) {
316-
log.warn('judge.canary-leak', { attempt: 'first' });
356+
label: string,
357+
): Promise<AttemptResult> {
358+
let raw: string;
359+
try {
360+
raw = await spawnFn(claudeBin, prompt, { jsonSchema: true });
361+
} catch (err) {
362+
if (err instanceof JudgeTimeoutError) {
363+
log.warn('judge.attempt-timeout', { attempt: label, error: err.message });
364+
return { ok: false, kind: 'timeout', error: err.message };
365+
}
366+
throw err;
367+
}
368+
await writeRawResponse(runDir, label, raw);
369+
if (detectCanaryLeak(raw, canary)) {
370+
log.warn('judge.canary-leak', { attempt: label });
317371
throw new Error(
318-
'judge output contained the canary token, indicating prompt injection from variant or transcript content; scores discarded',
372+
`judge output contained the canary token on ${label} attempt, indicating prompt injection from variant or transcript content; scores discarded`,
319373
);
320374
}
321-
const firstParsed = tryParseJudgeOutput(firstAttempt);
322-
if (firstParsed.ok) return firstParsed.value;
323-
324-
log.warn('judge.first-attempt-invalid', { error: firstParsed.error });
325-
const retryPrompt =
326-
`${prompt}\n\n# Retry required\n` +
327-
`Your previous response did not match the required shape. Parser said: "${firstParsed.error}"\n` +
328-
`Emit ONLY the JSON object described above — no markdown, no prose, no code fences. ` +
329-
`The first character MUST be "{" and the last MUST be "}". Include all four score keys (accuracy, completeness, adherence, clarity) and the rationale field.`;
330-
const secondAttempt = await spawnJudge(claudeBin, retryPrompt, { jsonSchema: true });
331-
await writeRawResponse(runDir, 'retry', secondAttempt);
332-
if (detectCanaryLeak(secondAttempt, canary)) {
333-
log.warn('judge.canary-leak', { attempt: 'retry' });
375+
const parsed = tryParseJudgeOutput(raw);
376+
if (parsed.ok) return { ok: true, value: parsed.value };
377+
return { ok: false, kind: 'parse', error: parsed.error };
378+
}
379+
380+
export async function invokeJudge(
381+
input: JudgeInput,
382+
spawnFn: SpawnJudgeFn = spawnJudge,
383+
): Promise<JudgeModelOutput> {
384+
const { claudeBin, runDir } = input;
385+
const built = buildJudgePrompt(input);
386+
387+
const first = await attemptJudge(spawnFn, claudeBin, built.prompt, built.canary, runDir, 'first');
388+
if (first.ok) return first.value;
389+
390+
// Build the retry prompt. A timeout is treated like a schema failure for retry
391+
// purposes (issue #12), but the retry shrinks the input to give Haiku a real
392+
// chance to finish in the next 120s window. Schema-failure retries keep the
393+
// original prompt and append a hint about the parse error.
394+
let retryPrompt: string;
395+
let retryCanary: string;
396+
if (first.kind === 'timeout') {
397+
log.warn('judge.first-attempt-invalid', {
398+
reason: 'timeout',
399+
error: first.error,
400+
retryStrategy: 'halve-input-caps',
401+
});
402+
const rebuilt = buildJudgePrompt(input, { bytesCapMultiplier: 0.5 });
403+
retryPrompt = rebuilt.prompt;
404+
retryCanary = rebuilt.canary;
405+
} else {
406+
log.warn('judge.first-attempt-invalid', { reason: 'parse', error: first.error });
407+
retryPrompt =
408+
`${built.prompt}\n\n# Retry required\n` +
409+
`Your previous response did not match the required shape. Parser said: "${first.error}"\n` +
410+
`Emit ONLY the JSON object described above — no markdown, no prose, no code fences. ` +
411+
`The first character MUST be "{" and the last MUST be "}". Include all four score keys (accuracy, completeness, adherence, clarity) and the rationale field.`;
412+
retryCanary = built.canary;
413+
}
414+
415+
const second = await attemptJudge(spawnFn, claudeBin, retryPrompt, retryCanary, runDir, 'retry');
416+
if (second.ok) return second.value;
417+
418+
if (second.kind === 'timeout') {
334419
throw new Error(
335-
'judge output contained the canary token on retry, indicating prompt injection from variant or transcript content; scores discarded',
420+
`judge timed out on retry after ${first.kind === 'timeout' ? 'an initial timeout' : 'a schema failure'}. ` +
421+
`Raw responses (if any) saved to ${join(runDir, 'judge.raw-response.log')}.`,
336422
);
337423
}
338-
const secondParsed = tryParseJudgeOutput(secondAttempt);
339-
if (secondParsed.ok) return secondParsed.value;
340-
341424
throw new Error(
342-
`judge output invalid after retry: ${secondParsed.error}. Raw responses saved to ${join(runDir, 'judge.raw-response.log')}.`,
425+
`judge output invalid after retry: ${second.error}. Raw responses saved to ${join(runDir, 'judge.raw-response.log')}.`,
343426
);
344427
}
345428

@@ -353,7 +436,7 @@ async function writeRawResponse(runDir: string, label: string, raw: string): Pro
353436
}
354437
}
355438

356-
interface SpawnJudgeOptions {
439+
export interface SpawnJudgeOptions {
357440
jsonSchema: boolean;
358441
}
359442

@@ -396,8 +479,12 @@ function spawnJudge(claudeBin: string, prompt: string, opts: SpawnJudgeOptions):
396479
proc.stderr.on('data', (d) => (stderr += d.toString()));
397480
const timer = setTimeout(() => {
398481
proc.kill('SIGKILL');
399-
reject(new Error(`judge subprocess timed out after 120s`));
400-
}, 120_000);
482+
reject(
483+
new JudgeTimeoutError(
484+
`judge subprocess timed out after ${Math.round(JUDGE_SUBPROCESS_TIMEOUT_MS / 1000)}s`,
485+
),
486+
);
487+
}, JUDGE_SUBPROCESS_TIMEOUT_MS);
401488
proc.on('error', (err) => {
402489
clearTimeout(timer);
403490
reject(err);

0 commit comments

Comments
 (0)