Skip to content

Commit f747b52

Browse files
authored
fix(worker): carry the real cause when the pre-PR checks step throws (#316)
1 parent 435151f commit f747b52

2 files changed

Lines changed: 305 additions & 12 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
getCurrentPrePrCheckConfig: vi.fn(),
5+
runPrePrChecksWithFixes: vi.fn(),
6+
info: vi.fn(),
7+
warn: vi.fn(),
8+
error: vi.fn(),
9+
}));
10+
11+
vi.mock("../db/client.js", () => ({ getDb: () => ({ kind: "db" }) }));
12+
vi.mock("../pre-pr-checks/store.js", () => ({
13+
getCurrentPrePrCheckConfig: (...args: any[]) =>
14+
mocks.getCurrentPrePrCheckConfig(...args),
15+
}));
16+
vi.mock("../pre-pr-checks/runner.js", () => ({
17+
runPrePrChecksWithFixes: (...args: any[]) =>
18+
mocks.runPrePrChecksWithFixes(...args),
19+
}));
20+
vi.mock("../lib/logger.js", () => ({
21+
logger: { info: mocks.info, warn: mocks.warn, error: mocks.error },
22+
}));
23+
vi.mock("../../env.js", () => ({
24+
env: { DASHBOARD_ORIGIN: "https://dashboard.example.com" },
25+
}));
26+
27+
import {
28+
PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
29+
PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH,
30+
prePrChecksFailureMustPropagate,
31+
prePrChecksFailureReport,
32+
runPrePrChecksStep,
33+
} from "./agent.js";
34+
import { isDurationAbortError } from "./run-budget.js";
35+
import { isRunControlError } from "./run-control-error.js";
36+
import { runControlErrorCases } from "./blocks/test-support.js";
37+
38+
const MESSAGE_LEAD = "The Pre-PR checks step failed: ";
39+
40+
function namedError(name: string, message: string): Error {
41+
const error = new Error(message);
42+
error.name = name;
43+
return error;
44+
}
45+
46+
function runStep() {
47+
return runPrePrChecksStep("sbx-test-123", "codex", "gpt-5");
48+
}
49+
50+
describe("pre-PR checks step failure cause", () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
mocks.getCurrentPrePrCheckConfig.mockResolvedValue({
54+
version: 7,
55+
config: { repositories: [] },
56+
});
57+
});
58+
59+
it("returns the checks result and the loaded version when nothing throws", async () => {
60+
mocks.runPrePrChecksWithFixes.mockResolvedValue({
61+
outcome: "passed",
62+
passed: true,
63+
fixCycles: 0,
64+
fixCycleUsages: [],
65+
budgetFailure: null,
66+
summary: "All checks passed.",
67+
});
68+
69+
await expect(runStep()).resolves.toEqual({
70+
outcome: "passed",
71+
passed: true,
72+
fixCycles: 0,
73+
fixCycleUsages: [],
74+
budgetFailure: null,
75+
summary: "All checks passed.",
76+
configurationVersion: 7,
77+
});
78+
expect(mocks.error).not.toHaveBeenCalled();
79+
});
80+
81+
it("names the thrown cause instead of leaving Workflow's wrapper to speak alone", async () => {
82+
// Production runs wrun_01M0CBQNAX24STRMN5SGCKKGB2 and
83+
// wrun_01M0CAZKV3YMNFBCZJA8MT95GW died here reading only "exceeded max
84+
// retries", with no sanitized output captured and nothing in the runtime
85+
// logs. Whatever the step throws has to reach the operator-facing text.
86+
mocks.runPrePrChecksWithFixes.mockRejectedValue(
87+
new Error("sandbox connection reset"),
88+
);
89+
90+
await expect(runStep()).rejects.toThrow(
91+
`${MESSAGE_LEAD}sandbox connection reset`,
92+
);
93+
expect(mocks.error).toHaveBeenCalledTimes(1);
94+
const [record, msg] = mocks.error.mock.calls[0] as [
95+
{ version: number | null; name: string; cause: string; stackTail: string },
96+
string,
97+
];
98+
expect(msg).toBe("pre_pr_checks_step_failed");
99+
expect(record.version).toBe(7);
100+
expect(record.name).toBe("Error");
101+
expect(record.cause).toBe("sandbox connection reset");
102+
// The tail, so a very deep stack keeps its innermost-to-outermost end
103+
// rather than growing the log record without bound. The message itself is
104+
// already in `cause`, so nothing is lost when the head is clipped.
105+
expect(record.stackTail).toMatch(/\bat\b/);
106+
expect(record.stackTail.length).toBeLessThanOrEqual(
107+
PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH,
108+
);
109+
});
110+
111+
it("prefers a system error code over a class name that says nothing", async () => {
112+
mocks.runPrePrChecksWithFixes.mockRejectedValue(
113+
Object.assign(new Error("connect ECONNREFUSED 10.0.0.1:443"), {
114+
code: "ECONNREFUSED",
115+
}),
116+
);
117+
118+
await expect(runStep()).rejects.toThrow(
119+
`${MESSAGE_LEAD}ECONNREFUSED: connect ECONNREFUSED 10.0.0.1:443`,
120+
);
121+
});
122+
123+
it("bounds a runaway cause instead of embedding it whole", async () => {
124+
const thrownMessage = "sandbox refused the launch. ".repeat(200);
125+
mocks.runPrePrChecksWithFixes.mockRejectedValue(new Error(thrownMessage));
126+
127+
const thrown = await runStep().then(
128+
() => null,
129+
(err: unknown) => err as Error,
130+
);
131+
132+
expect(thrown?.message).toContain(MESSAGE_LEAD);
133+
expect(thrown?.message).not.toContain(thrownMessage);
134+
// Sentence plus the bound the cause is clamped to, and nothing more, so a
135+
// runaway error text cannot become the run status.
136+
expect(thrown?.message.length).toBeLessThanOrEqual(
137+
MESSAGE_LEAD.length + PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
138+
);
139+
const logged = mocks.error.mock.calls[0]?.[0] as { cause: string };
140+
expect(logged.cause.length).toBe(PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH);
141+
});
142+
143+
it.each(runControlErrorCases())(
144+
"rethrows %s untouched so the call site still recognizes it",
145+
async (_label, error) => {
146+
mocks.runPrePrChecksWithFixes.mockRejectedValue(error);
147+
148+
await expect(runStep()).rejects.toBe(error);
149+
const thrown = await runStep().then(
150+
() => null,
151+
(err: unknown) => err,
152+
);
153+
expect(isRunControlError(thrown)).toBe(true);
154+
expect(mocks.error).not.toHaveBeenCalled();
155+
},
156+
);
157+
158+
it.each([["AbortError"], ["TimeoutError"]])(
159+
"rethrows a %s untouched so the duration budget stop survives",
160+
async (name) => {
161+
const error = namedError(name, "The operation was aborted.");
162+
mocks.runPrePrChecksWithFixes.mockRejectedValue(error);
163+
164+
await expect(runStep()).rejects.toBe(error);
165+
const thrown = await runStep().then(
166+
() => null,
167+
(err: unknown) => err,
168+
);
169+
expect(isDurationAbortError(thrown)).toBe(true);
170+
expect(mocks.error).not.toHaveBeenCalled();
171+
},
172+
);
173+
174+
it("wraps only what wrapping cannot break", () => {
175+
// Why the two predicates gate the wrap at all: both match structurally on
176+
// `name`, so a control error re-thrown as `new Error(message)` stops being
177+
// one, and a duration budget stop would report as a generic failure.
178+
const budgetStop = namedError("RunBudgetError", "budget exceeded");
179+
const abort = namedError("AbortError", "The operation was aborted.");
180+
const identity = (value: string) => value;
181+
182+
expect(prePrChecksFailureMustPropagate(budgetStop)).toBe(true);
183+
expect(prePrChecksFailureMustPropagate(abort)).toBe(true);
184+
expect(prePrChecksFailureMustPropagate(new Error("sandbox died"))).toBe(false);
185+
186+
const rewrappedBudget = new Error(
187+
prePrChecksFailureReport(budgetStop, identity).message,
188+
);
189+
expect(isRunControlError(rewrappedBudget)).toBe(false);
190+
const rewrappedAbort = new Error(
191+
prePrChecksFailureReport(abort, identity).message,
192+
);
193+
expect(isDurationAbortError(rewrappedAbort)).toBe(false);
194+
});
195+
196+
it("redacts the cause and the stack tail through the caller's redactor", () => {
197+
const report = prePrChecksFailureReport(
198+
new Error("auth failed for token=abcd1234"),
199+
(value) => value.replace("abcd1234", "[REDACTED]"),
200+
);
201+
202+
expect(report.cause).toBe("auth failed for token=[REDACTED]");
203+
expect(report.stackTail).not.toContain("abcd1234");
204+
});
205+
206+
it("names a non-Error throw rather than dropping it", () => {
207+
const report = prePrChecksFailureReport("sandbox vanished", (v) => v);
208+
209+
expect(report.message).toBe(`${MESSAGE_LEAD}sandbox vanished`);
210+
expect(report.name).toBe("string");
211+
expect(report.stackTail).toBe("");
212+
});
213+
});

apps/worker/src/workflows/agent.ts

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,7 +2369,64 @@ async function resolveHarnessRuntimesStep(
23692369
}
23702370
resolveHarnessRuntimesStep.maxRetries = 0;
23712371

2372-
async function runPrePrChecksStep(
2372+
/** Longest failure cause carried into the Pre-PR checks step error message.
2373+
* Same bound the Pre-PR repair launch failure puts on its carried cause
2374+
* (#309): long enough for a sandbox, kill or stream verdict, short enough that
2375+
* the composed block failure stays a detail rather than a payload. */
2376+
export const PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH = 200;
2377+
2378+
/** Stack tail kept for the log record only, never for the operator message:
2379+
* frames leak internal paths and are what turns a detail into a firehose. */
2380+
export const PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH = 600;
2381+
2382+
/**
2383+
* Errors `runPrePrChecksStep` must rethrow untouched.
2384+
*
2385+
* Both predicates identify an error structurally, by `name`
2386+
* (run-budget.ts:52, run-budget.ts:280) or by a sentinel in its message
2387+
* (run-control-errors.ts:16), because Workflow serializes step errors across
2388+
* VMs. Wrapping one in a new Error therefore destroys the identity the call
2389+
* site depends on, and a budget stop would start reporting as a generic
2390+
* failure.
2391+
*/
2392+
export function prePrChecksFailureMustPropagate(error: unknown): boolean {
2393+
return isRunControlError(error) || isDurationAbortError(error);
2394+
}
2395+
2396+
/**
2397+
* What a Pre-PR checks step failure is allowed to say, composed in one pure
2398+
* place so the bound and the wording can be pinned directly.
2399+
*
2400+
* `redact` is a parameter rather than an import: the redactor lives in
2401+
* `sandbox/agents/protocol.js`, which must stay out of the workflow module
2402+
* scope.
2403+
*/
2404+
export function prePrChecksFailureReport(
2405+
error: unknown,
2406+
redact: (value: string) => string,
2407+
): { name: string; cause: string; stackTail: string; message: string } {
2408+
const name = error instanceof Error ? error.name : typeof error;
2409+
const message = error instanceof Error ? error.message : String(error);
2410+
// Prefer a system error code over the class name: `ECONNREFUSED` names the
2411+
// cause where `Error` names nothing.
2412+
const code = (error as { code?: unknown }).code;
2413+
const label =
2414+
typeof code === "string" && code ? code : error instanceof Error ? name : "";
2415+
const cause = redact(
2416+
label && label !== "Error" ? `${label}: ${message}` : message,
2417+
).slice(0, PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH);
2418+
const stack = error instanceof Error ? error.stack ?? "" : "";
2419+
return {
2420+
name,
2421+
cause,
2422+
stackTail: stack
2423+
? redact(stack).slice(-PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH)
2424+
: "",
2425+
message: `The Pre-PR checks step failed: ${cause}`,
2426+
};
2427+
}
2428+
2429+
export async function runPrePrChecksStep(
23732430
sandboxId: string,
23742431
agentKind: AgentKind,
23752432
model: string,
@@ -2403,17 +2460,40 @@ async function runPrePrChecksStep(
24032460
{ version: current?.version ?? null },
24042461
"pre_pr_checks_config_version",
24052462
);
2406-
const result = await runPrePrChecksWithFixes(
2407-
sandboxId,
2408-
current?.config ?? emptyPrePrCheckConfig,
2409-
agentKind,
2410-
model,
2411-
maxFixCycles,
2412-
timeoutMs,
2413-
budget,
2414-
runtime,
2415-
arthurTaskId,
2416-
);
2463+
let result: Awaited<ReturnType<typeof runPrePrChecksWithFixes>>;
2464+
try {
2465+
result = await runPrePrChecksWithFixes(
2466+
sandboxId,
2467+
current?.config ?? emptyPrePrCheckConfig,
2468+
agentKind,
2469+
model,
2470+
maxFixCycles,
2471+
timeoutMs,
2472+
budget,
2473+
runtime,
2474+
arthurTaskId,
2475+
);
2476+
} catch (err) {
2477+
// Nothing used to be caught here, so a throw reached the operator as
2478+
// Workflow's own wrapper alone ("exceeded max retries"): no error name, no
2479+
// message, no captured output, and nothing in the runtime logs either.
2480+
// Carry the reason, redacted and bounded exactly like a diagnostic tail.
2481+
// Run-control and duration-abort errors rethrow untouched, because the call
2482+
// site turns them into a budget stop by matching their identity.
2483+
if (prePrChecksFailureMustPropagate(err)) throw err;
2484+
const { redactDiagnosticText } = await import("../sandbox/agents/protocol.js");
2485+
const report = prePrChecksFailureReport(err, redactDiagnosticText);
2486+
logger.error(
2487+
{
2488+
version: current?.version ?? null,
2489+
name: report.name,
2490+
cause: report.cause,
2491+
stackTail: report.stackTail,
2492+
},
2493+
"pre_pr_checks_step_failed",
2494+
);
2495+
throw new Error(report.message);
2496+
}
24172497
return {
24182498
...result,
24192499
configurationVersion: current?.version ?? null,

0 commit comments

Comments
 (0)