Skip to content

Commit 8a631aa

Browse files
outof-placeclaude
andcommitted
fix(worker): keep a check failure's reason out of its truncated payload
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfeohXE66xx7RJPxZ2pvH
1 parent 6f6f885 commit 8a631aa

8 files changed

Lines changed: 491 additions & 188 deletions

File tree

apps/worker/src/pre-pr-checks/runner.test.ts

Lines changed: 165 additions & 45 deletions
Large diffs are not rendered by default.

apps/worker/src/pre-pr-checks/runner.ts

Lines changed: 149 additions & 85 deletions
Large diffs are not rendered by default.

apps/worker/src/workflows/agent-pre-pr-checks-failure.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,24 @@ describe("pre-PR checks step failure cause", () => {
7878
);
7979
});
8080

81-
it("prefers a system error code over a class name that says nothing", async () => {
82-
const error = Object.assign(new Error("connect ECONNREFUSED 10.0.0.1:443"), {
81+
it("labels the cause with the class name, the only thing left of the error", async () => {
82+
// Everything caught here was thrown inside a step, and Workflow reduces a
83+
// thrown error to name, message and stack at the VM boundary and revives it
84+
// as a plain Error. A system error code would name the cause far better
85+
// than `Error` does, but `.code` cannot reach this side, so nothing may be
86+
// built on it: a label that can never fire reads as coverage that does not
87+
// exist. Recovering it would mean parsing the message.
88+
const error = Object.assign(namedError("SandboxError", "connect ECONNREFUSED 10.0.0.1:443"), {
8389
code: "ECONNREFUSED",
8490
});
8591

8692
await expect(describe_(error)).resolves.toBe(
87-
`${MESSAGE_LEAD}ECONNREFUSED: connect ECONNREFUSED 10.0.0.1:443`,
93+
`${MESSAGE_LEAD}SandboxError: connect ECONNREFUSED 10.0.0.1:443`,
8894
);
95+
// A plain Error adds nothing worth prefixing, and a non-Error throw's
96+
// `typeof` is noise on top of its own text.
97+
await expect(describe_(new Error("plain"))).resolves.toBe(`${MESSAGE_LEAD}plain`);
98+
await expect(describe_("just a string")).resolves.toBe(`${MESSAGE_LEAD}just a string`);
8999
});
90100

91101
it("bounds a runaway cause instead of embedding it whole", async () => {
@@ -212,4 +222,21 @@ describe("pre-PR checks step failure cause", () => {
212222
"The Pre-PR checks step failed (SandboxError), and the cause could not be recorded.",
213223
);
214224
});
225+
226+
it.each(runControlErrorCases())(
227+
"rethrows %s from the reporting step instead of degrading",
228+
async (_label, controlError) => {
229+
// The degraded sentence is for a reporting path that broke. A cancelled
230+
// run surfaces at every step, this one included, and swallowing it here
231+
// would report a Pre-PR checks failure for a run the operator cancelled,
232+
// and let it carry on being cancelled anyway.
233+
mocks.error.mockImplementation(() => {
234+
throw controlError;
235+
});
236+
237+
await expect(
238+
prePrChecksFailureMessage(new Error("sandbox connection reset"), 7),
239+
).rejects.toBe(controlError);
240+
},
241+
);
215242
});

apps/worker/src/workflows/agent.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2414,10 +2414,17 @@ export interface PrePrChecksFailureInput {
24142414
name: string;
24152415
message: string;
24162416
/**
2417-
* What to prefix the cause with, or empty for no prefix. A system error code
2418-
* when there is one, because `ECONNREFUSED` names the cause where `Error`
2419-
* names nothing; the class name otherwise; nothing at all for a non-Error
2417+
* What to prefix the cause with: the class name, or empty for a non-Error
24202418
* throw, whose `typeof` would only add noise to its own text.
2419+
*
2420+
* The class name is all there is to prefix with. This catch sits in workflow
2421+
* scope and every error it sees was thrown inside a step, and Workflow
2422+
* reduces a thrown error to its name, message and stack at the VM boundary
2423+
* and revives it as a plain Error on this side. A system error code
2424+
* (`ECONNREFUSED`) would name the cause far better than `Error` does, but
2425+
* `.code` is gone by the time anything here can read it. Recovering it would
2426+
* mean parsing the message, the way isReplayedRunControlStepError parses for
2427+
* run-control errors, and no incident has yet asked for it.
24212428
*/
24222429
label: string;
24232430
stack: string;
@@ -2434,6 +2441,15 @@ export interface PrePrChecksFailureInput {
24342441
* protecting only the sentence an operator reads. Redaction runs before
24352442
* truncation so a secret is blanked rather than cut in half.
24362443
*
2444+
* The redactor runs in workflow scope here and again inside the step, and the
2445+
* two cannot disagree within an invocation: the workflow VM shims `process` as
2446+
* a frozen spread of `process.env` taken when the context is built, so both
2447+
* sides read the same snapshot. The narrow consequence is that a secret added
2448+
* or rotated AFTER the context was built is absent from that snapshot, so its
2449+
* literal value would cross the boundary into the journal unblanked while the
2450+
* operator's sentence stays clean. The pattern rules below (`sk-ant-`, `gh?_`,
2451+
* `glpat-`, `Bearer`) do not depend on the environment and still catch it.
2452+
*
24372453
* Bounded here for the same reason: the step keeps exactly these bytes anyway,
24382454
* so anything beyond them would be journaled and then dropped.
24392455
*
@@ -2443,10 +2459,6 @@ export interface PrePrChecksFailureInput {
24432459
*/
24442460
export function prePrChecksFailureInput(error: unknown): PrePrChecksFailureInput {
24452461
const isError = error instanceof Error;
2446-
// Optional on purpose. `throw null` and a bare `Promise.reject()` both reach
2447-
// here, and a TypeError raised inside the reporting path would destroy the
2448-
// very cause this exists to carry.
2449-
const code = (error as { code?: unknown } | null | undefined)?.code;
24502462
const name = isError ? error.name : typeof error;
24512463
const stack = isError ? error.stack ?? "" : "";
24522464
return {
@@ -2455,7 +2467,7 @@ export function prePrChecksFailureInput(error: unknown): PrePrChecksFailureInput
24552467
0,
24562468
PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
24572469
),
2458-
label: typeof code === "string" && code ? code : isError ? name : "",
2470+
label: isError ? name : "",
24592471
stack: stack
24602472
? redactDiagnosticText(stack).slice(-PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH)
24612473
: "",
@@ -2494,11 +2506,11 @@ export function prePrChecksFailureReport(
24942506
* Redact, bound and log a Pre-PR checks failure, and return the one sentence
24952507
* the operator is allowed to see.
24962508
*
2497-
* A step, because both halves of this need the server: pino may only be used
2498-
* inside a step, and the redactor reads process.env to find the secrets it
2499-
* blanks. The checks themselves are no longer a step (they are launched
2500-
* detached and polled across ticks), so without this the catch in workflow
2501-
* scope could neither redact nor log.
2509+
* A step, because the logging needs the server: pino may only be used inside a
2510+
* step, never in workflow scope, and the checks themselves are no longer a step
2511+
* (they are launched detached and polled across ticks), so without this the
2512+
* catch in workflow scope could not log at all. The redaction is not what
2513+
* forces a step, it already ran in workflow scope before the boundary.
25022514
*/
25032515
export async function describePrePrChecksFailureStep(
25042516
error: PrePrChecksFailureInput,
@@ -2541,7 +2553,13 @@ export async function prePrChecksFailureMessage(
25412553
const input = prePrChecksFailureInput(error);
25422554
try {
25432555
return await describePrePrChecksFailureStep(input, configurationVersion);
2544-
} catch {
2556+
} catch (reportingError) {
2557+
// A cancelled run surfaces at every step, this one included, and it is not
2558+
// a reporting failure: swallowing it would turn a cancellation into a
2559+
// Pre-PR checks failure and let the run carry on being cancelled anyway.
2560+
// Same rethrow this file makes at every other step boundary, including the
2561+
// catch that calls this one.
2562+
if (isRunControlError(reportingError)) throw reportingError;
25452563
return `The Pre-PR checks step failed (${input.name.slice(0, 60)}), and the cause could not be recorded.`;
25462564
}
25472565
}

apps/worker/src/workflows/blocks/pre-pr-checks.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function collected(overrides: {
101101
exitCode: number;
102102
stdout: string;
103103
stderr: string;
104-
phase?: "setup" | "workspace";
104+
phase?: "setup" | "workspace" | "batch" | "omitted";
105105
}>;
106106
setupFailed?: boolean;
107107
progress?: { completed: number; total: number; stoppedAt: string | null };
@@ -319,6 +319,47 @@ describe("runPrePrChecksWithFixes", () => {
319319
expect(mocks.startPrePrRepairStep).not.toHaveBeenCalled();
320320
});
321321

322+
it("never reports a stall as a check whose fix cycles were suppressed", async () => {
323+
// A stall is not a check result: nothing was verified. Without a phase it
324+
// reads as an ordinary failing check, so with a setup failure earlier in
325+
// the same pass it collects the sentence explaining that its fix cycles
326+
// were suppressed, and it would be handed to the repair agent to fix.
327+
mocks.pollPhaseUntilDone
328+
.mockImplementationOnce(pollEnds("finished", 30_000))
329+
.mockImplementation(pollEnds("duration_cap", 1_500_000));
330+
mocks.checkPhaseDone.mockResolvedValue(false);
331+
mocks.collectRepoCheckBatchStep
332+
.mockResolvedValueOnce(
333+
collected({
334+
setupFailed: true,
335+
failures: [{
336+
provider: "github",
337+
repoPath: "acme/web",
338+
command: "make bootstrap",
339+
exitCode: 127,
340+
stdout: "",
341+
stderr: "toolchain: command not found",
342+
phase: "setup",
343+
}],
344+
}),
345+
)
346+
.mockResolvedValueOnce(
347+
collected({ progress: { completed: 0, total: 1, stoppedAt: "pnpm test" } }),
348+
);
349+
350+
const result = await runPrePrChecksWithFixes(options({ config }));
351+
352+
const entries = result.summary.split("\n\n");
353+
const setupEntry = entries.find((entry) => entry.startsWith("SETUP FAILED"));
354+
const stallEntry = entries.find((entry) => entry.includes("this is a timeout"));
355+
expect(setupEntry).toBeDefined();
356+
expect(stallEntry).toContain("CHECK BATCH ABANDONED for gitlab:acme/api");
357+
// The proof the phase is doing the work: an ordinary failing check in this
358+
// same summary would carry the suppression sentence.
359+
expect(stallEntry).not.toContain("No agent fix cycles were run");
360+
expect(mocks.startPrePrRepairStep).not.toHaveBeenCalled();
361+
});
362+
322363
it("keeps the stall diagnosis when the abandoned batch cannot be read either", async () => {
323364
// The sandbox-death path collects from a sandbox already observed as not
324365
// running twice, and the collect step's maxRetries is 0. Letting that

apps/worker/src/workflows/blocks/pre-pr-checks.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,6 @@ export async function runRepoCheckBatch(args: {
330330
fixCycle: number;
331331
repoIndex: number;
332332
requireChange: boolean;
333-
/** Whether an exit-0 check that says its dependencies are missing counts as
334-
* a failure. Only the configured checks carry that history; see
335-
* collectRepoCheckBatchStep. */
336-
scanBlockedDependencies: boolean;
337333
observeBudget: PrePrChecksOptions["observeBudget"];
338334
cancellation?: V2InvocationCancellation;
339335
}): Promise<RepoCheckBatchRun> {
@@ -363,7 +359,6 @@ export async function runRepoCheckBatch(args: {
363359
started.paths,
364360
started.localPath,
365361
batchFinished,
366-
args.scanBlockedDependencies,
367362
);
368363

369364
const outcome = newPhasePollOutcome();
@@ -465,7 +460,6 @@ async function runCheckBatches(
465460
fixCycle,
466461
repoIndex,
467462
requireChange: true,
468-
scanBlockedDependencies: true,
469463
observeBudget: options.observeBudget,
470464
cancellation: options.cancellation,
471465
});
@@ -599,6 +593,12 @@ function stalledBatches(
599593
exitCode: -1,
600594
stdout: "",
601595
stderr: batchStallReason(stall, elapsedMs, collected.progress),
596+
// The batch never reported, so this is not a check result at all. Without a
597+
// phase it reads as an ordinary failing check: it would be handed to the
598+
// repair agent, and under a setup failure elsewhere it would collect the
599+
// sentence saying its fix cycles were suppressed, when the reason nothing
600+
// was fixed is that nothing was verified.
601+
phase: "batch",
602602
};
603603
const allResults = [...results, ...collected.results];
604604
const allFailures = [...failures, ...collected.failures, stallFailure];

apps/worker/src/workflows/blocks/run-checks.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -267,14 +267,41 @@ describe("run_checks execute", () => {
267267
}
268268
});
269269

270-
it("asks for no missing-dependency scan, which this mode never had", async () => {
271-
// The block runs whatever an author typed and its description promises
272-
// nothing but the exit code. Six English phrases must not turn a green
273-
// suite into a failed check; this repository's own test names contain the
274-
// exact wording.
275-
await execute(makeNode("run_checks", { commands: ["pnpm test"] }), {}, makeCtx());
276-
277-
expect(mocks.collectRepoCheckBatchStep.mock.calls[0]![8]).toBe(false);
270+
it("keeps the sentence explaining a failure out of the truncated payload", async () => {
271+
// A check that exits 0 because it never ran is reported as a failure, and
272+
// the only thing saying why is the note. This block's failure shape has one
273+
// `output` string, so the note is appended after the bound: folded in
274+
// before it, it would sit at the join between the two streams, which is
275+
// exactly the middle a head-and-tail bound deletes.
276+
mocks.collectRepoCheckBatchStep.mockResolvedValue({
277+
results: [{ provider: "github", repoPath: "acme/api", command: "yarn test", exitCode: 0 }],
278+
failures: [
279+
{
280+
provider: "github",
281+
repoPath: "acme/api",
282+
command: "yarn test",
283+
exitCode: 0,
284+
stdout: `HEAD${"y".repeat(40_000)}TAIL`,
285+
stderr: "",
286+
note: "Pre-PR check exited 0 but its dependencies are not installed.",
287+
},
288+
],
289+
setupFailed: false,
290+
});
291+
292+
const result = await execute(
293+
makeNode("run_checks", { commands: ["yarn test"] }),
294+
{},
295+
makeCtx(),
296+
);
297+
298+
const { failures } = result.output! as unknown as {
299+
failures: Array<{ output: string }>;
300+
};
301+
const output = failures[0]!.output;
302+
expect(output).toContain("dependencies are not installed");
303+
expect(output).toContain("HEAD");
304+
expect(output).toContain("TAIL");
278305
});
279306

280307
it("fails the block when an explicit batch stalls, never reporting a partial pass", async () => {

apps/worker/src/workflows/blocks/run-checks.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
listWorkspaceRepositoriesStep,
55
type CheckOutcome,
66
type CollectedRepoCheckBatch,
7+
type PrePrCheckFailure,
78
} from "../../pre-pr-checks/runner.js";
89
import {
910
RunBudgetError,
@@ -68,18 +69,39 @@ function toBlockResults(
6869
function toBlockFailures(
6970
collected: CollectedRepoCheckBatch,
7071
): RunChecksStepResult["failures"] {
71-
return collected.failures.map((failure) => ({
72+
return collected.failures.map(toBlockFailure);
73+
}
74+
75+
function toBlockFailure(
76+
failure: PrePrCheckFailure,
77+
): RunChecksStepResult["failures"][number] {
78+
return {
7279
repo: `${failure.provider}:${failure.repoPath}`,
7380
command: failure.command,
7481
exitCode: failure.exitCode,
75-
output: boundFailureOutput(
76-
[failure.stderr, failure.stdout]
77-
.map((part) => part.trim())
78-
.filter(Boolean)
79-
.join("\n"),
80-
OUTPUT_TRUNCATE,
81-
),
82-
}));
82+
output: failureOutput(failure),
83+
};
84+
}
85+
86+
/**
87+
* The command's own output, bounded, then the note on its own line.
88+
*
89+
* The note is appended AFTER the bound on purpose. This block's failure shape
90+
* has one `output` string and no field for a note, so folding the note into a
91+
* stream before bounding puts it at the join between stderr and stdout, which
92+
* is the middle a head-and-tail bound deletes: an operator would read
93+
* `exitCode: 0` under a heading that says failures with nothing saying why.
94+
*/
95+
function failureOutput(failure: PrePrCheckFailure): string {
96+
const output = boundFailureOutput(
97+
[failure.stderr, failure.stdout]
98+
.map((part) => part.trim())
99+
.filter(Boolean)
100+
.join("\n"),
101+
OUTPUT_TRUNCATE,
102+
);
103+
if (!failure.note) return output;
104+
return output ? `${output}\n${failure.note}` : failure.note;
83105
}
84106

85107
/**
@@ -121,11 +143,6 @@ async function runExplicitCommands(
121143
// Every attached repository runs, changed or not. That is this mode's
122144
// contract, and it never inspected HEAD before.
123145
requireChange: false,
124-
// And it never scanned output for missing-dependency phrases either. The
125-
// block runs whatever an author typed and promises nothing but the exit
126-
// code, so a green suite whose output mentions one of six English
127-
// sentences must not come back failed.
128-
scanBlockedDependencies: false,
129146
observeBudget,
130147
cancellation,
131148
});
@@ -202,18 +219,7 @@ async function runConfiguredChecks(
202219
observeBudget,
203220
cancellation,
204221
});
205-
const failures = run.failures.map((failure) => ({
206-
repo: `${failure.provider}:${failure.repoPath}`,
207-
command: failure.command,
208-
exitCode: failure.exitCode,
209-
output: boundFailureOutput(
210-
[failure.stderr, failure.stdout]
211-
.map((part) => part.trim())
212-
.filter(Boolean)
213-
.join("\n"),
214-
OUTPUT_TRUNCATE,
215-
),
216-
}));
222+
const failures = run.failures.map(toBlockFailure);
217223
const results = (run.results ?? run.failures).map((result) => ({
218224
repo: `${result.provider}:${result.repoPath}`,
219225
command: result.command,

0 commit comments

Comments
 (0)