Skip to content

Commit e94f5d7

Browse files
outof-placeclaude
andauthored
fix(worker): run pre-PR checks detached so they survive the invocation ceiling (#318)
* fix(worker): run pre-PR checks detached so they survive the invocation ceiling * fix(worker): keep the check reporting honest under truncation and failure Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfeohXE66xx7RJPxZ2pvH * 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 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c3a1bd8 commit e94f5d7

13 files changed

Lines changed: 4915 additions & 1516 deletions

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

Lines changed: 1171 additions & 783 deletions
Large diffs are not rendered by default.

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

Lines changed: 989 additions & 307 deletions
Large diffs are not rendered by default.

apps/worker/src/sandbox/agents/protocol.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import type {
1111
RunnableSandbox,
1212
SerializableAgentCliSpec,
1313
} from "./types.js";
14+
import { redactDiagnosticText } from "./redact.js";
1415
import { AgentRuntimeError } from "./runtime-error.js";
1516

1617
export {
1718
AgentRuntimeError,
1819
isAgentRuntimeError,
1920
} from "./runtime-error.js";
21+
// Re-exported so every existing caller keeps its import. It lives in a module
22+
// of its own because workflow scope needs it and cannot have node:crypto.
23+
export { redactDiagnosticText } from "./redact.js";
2024

2125
const DIAGNOSTIC_TAIL_BYTES = 2 * 1024;
2226
const MAX_SCHEMA_ISSUES = 20;
@@ -394,20 +398,6 @@ export function eventMetadata(value: unknown): AgentProtocolDiagnostic["event"]
394398
return Object.keys(metadata).length > 0 ? metadata : undefined;
395399
}
396400

397-
export function redactDiagnosticText(value: string): string {
398-
let redacted = value;
399-
const sensitiveValues = Object.entries(process.env)
400-
.filter(([key, secret]) =>
401-
secret && secret.length >= 8 && /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(key),
402-
)
403-
.map(([, secret]) => secret as string);
404-
for (const secret of sensitiveValues) redacted = redacted.split(secret).join("[REDACTED]");
405-
return redacted
406-
.replace(/\b(?:sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|glpat-[A-Za-z0-9_-]+)\b/g, "[REDACTED]")
407-
.replace(/\b(Bearer\s+)[^\s,;]+/gi, "$1[REDACTED]")
408-
.replace(/\b(api[_-]?key|token|secret|password)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]");
409-
}
410-
411401
export function hashText(value: string): string {
412402
return createHash("sha256").update(value).digest("hex");
413403
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Diagnostic redaction, deliberately alone in a module of its own.
3+
*
4+
* It lives here rather than in protocol.ts so it can be imported from workflow
5+
* scope: protocol.ts imports `node:crypto` for its schema hashing, and a Node
6+
* builtin anywhere in the workflow module graph fails the Vercel build alone,
7+
* never vitest or a local build, which is what makes it worth stating. This
8+
* module imports nothing at all.
9+
*
10+
* Redacting before a value crosses into a step is not cosmetic. Workflow
11+
* journals step arguments durably, so an unredacted secret handed to a step is
12+
* written to the run's event log whatever the step then does with it.
13+
*/
14+
export function redactDiagnosticText(value: string): string {
15+
let redacted = value;
16+
const sensitiveValues = Object.entries(process.env)
17+
.filter(([key, secret]) =>
18+
secret && secret.length >= 8 && /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(key),
19+
)
20+
.map(([, secret]) => secret as string);
21+
for (const secret of sensitiveValues) redacted = redacted.split(secret).join("[REDACTED]");
22+
return redacted
23+
.replace(/\b(?:sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|glpat-[A-Za-z0-9_-]+)\b/g, "[REDACTED]")
24+
.replace(/\b(Bearer\s+)[^\s,;]+/gi, "$1[REDACTED]")
25+
.replace(/\b(api[_-]?key|token|secret|password)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]");
26+
}
Lines changed: 116 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mocks = vi.hoisted(() => ({
4-
getCurrentPrePrCheckConfig: vi.fn(),
5-
runPrePrChecksWithFixes: vi.fn(),
64
info: vi.fn(),
75
warn: vi.fn(),
86
error: vi.fn(),
97
}));
108

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-
}));
209
vi.mock("../lib/logger.js", () => ({
2110
logger: { info: mocks.info, warn: mocks.warn, error: mocks.error },
2211
}));
12+
// The regex half of the real redactor is what these assert against; its
13+
// process.env half would make the output depend on the machine running the
14+
// suite, and there is no secret in these fixtures for it to find.
2315
vi.mock("../../env.js", () => ({
2416
env: { DASHBOARD_ORIGIN: "https://dashboard.example.com" },
2517
}));
2618

2719
import {
2820
PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
21+
prePrChecksFailureMessage,
2922
PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH,
23+
describePrePrChecksFailureStep,
24+
prePrChecksFailureInput,
3025
prePrChecksFailureMustPropagate,
3126
prePrChecksFailureReport,
32-
runPrePrChecksStep,
3327
} from "./agent.js";
3428
import { isDurationAbortError } from "./run-budget.js";
3529
import { isRunControlError } from "./run-control-error.js";
@@ -43,51 +37,27 @@ function namedError(name: string, message: string): Error {
4337
return error;
4438
}
4539

46-
function runStep() {
47-
return runPrePrChecksStep("sbx-test-123", "codex", "gpt-5");
40+
/**
41+
* What the call site does with a throw it may not propagate: flatten it in
42+
* workflow scope, then compose and log the sentence inside the step. The
43+
* checks stopped being a step of their own (they are launched detached and
44+
* polled), so this pair is the seam that carries what #316 landed.
45+
*/
46+
function describe_(error: unknown, version: number | null = 7) {
47+
return describePrePrChecksFailureStep(prePrChecksFailureInput(error), version);
4848
}
4949

5050
describe("pre-PR checks step failure cause", () => {
5151
beforeEach(() => {
5252
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();
7953
});
8054

8155
it("names the thrown cause instead of leaving Workflow's wrapper to speak alone", async () => {
8256
// Production runs wrun_01M0CBQNAX24STRMN5SGCKKGB2 and
8357
// wrun_01M0CAZKV3YMNFBCZJA8MT95GW died here reading only "exceeded max
8458
// retries", with no sanitized output captured and nothing in the runtime
8559
// 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(
60+
await expect(describe_(new Error("sandbox connection reset"))).resolves.toBe(
9161
`${MESSAGE_LEAD}sandbox connection reset`,
9262
);
9363
expect(mocks.error).toHaveBeenCalledTimes(1);
@@ -108,66 +78,57 @@ describe("pre-PR checks step failure cause", () => {
10878
);
10979
});
11080

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-
);
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"), {
89+
code: "ECONNREFUSED",
90+
});
11791

118-
await expect(runStep()).rejects.toThrow(
119-
`${MESSAGE_LEAD}ECONNREFUSED: connect ECONNREFUSED 10.0.0.1:443`,
92+
await expect(describe_(error)).resolves.toBe(
93+
`${MESSAGE_LEAD}SandboxError: connect ECONNREFUSED 10.0.0.1:443`,
12094
);
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`);
12199
});
122100

123101
it("bounds a runaway cause instead of embedding it whole", async () => {
124102
const thrownMessage = "sandbox refused the launch. ".repeat(200);
125-
mocks.runPrePrChecksWithFixes.mockRejectedValue(new Error(thrownMessage));
126103

127-
const thrown = await runStep().then(
128-
() => null,
129-
(err: unknown) => err as Error,
130-
);
104+
const message = await describe_(new Error(thrownMessage));
131105

132-
expect(thrown?.message).toContain(MESSAGE_LEAD);
133-
expect(thrown?.message).not.toContain(thrownMessage);
106+
expect(message).toContain(MESSAGE_LEAD);
107+
expect(message).not.toContain(thrownMessage);
134108
// Sentence plus the bound the cause is clamped to, and nothing more, so a
135109
// runaway error text cannot become the run status.
136-
expect(thrown?.message.length).toBeLessThanOrEqual(
110+
expect(message.length).toBeLessThanOrEqual(
137111
MESSAGE_LEAD.length + PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
138112
);
139113
const logged = mocks.error.mock.calls[0]?.[0] as { cause: string };
140114
expect(logged.cause.length).toBe(PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH);
141115
});
142116

143117
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();
118+
"keeps %s out of the wrap so the call site still recognizes it",
119+
(_label, error) => {
120+
expect(prePrChecksFailureMustPropagate(error)).toBe(true);
121+
expect(isRunControlError(error)).toBe(true);
155122
},
156123
);
157124

158125
it.each([["AbortError"], ["TimeoutError"]])(
159-
"rethrows a %s untouched so the duration budget stop survives",
160-
async (name) => {
126+
"keeps a %s out of the wrap so the duration budget stop survives",
127+
(name) => {
161128
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();
129+
130+
expect(prePrChecksFailureMustPropagate(error)).toBe(true);
131+
expect(isDurationAbortError(error)).toBe(true);
171132
},
172133
);
173134

@@ -184,18 +145,18 @@ describe("pre-PR checks step failure cause", () => {
184145
expect(prePrChecksFailureMustPropagate(new Error("sandbox died"))).toBe(false);
185146

186147
const rewrappedBudget = new Error(
187-
prePrChecksFailureReport(budgetStop, identity).message,
148+
prePrChecksFailureReport(prePrChecksFailureInput(budgetStop), identity).message,
188149
);
189150
expect(isRunControlError(rewrappedBudget)).toBe(false);
190151
const rewrappedAbort = new Error(
191-
prePrChecksFailureReport(abort, identity).message,
152+
prePrChecksFailureReport(prePrChecksFailureInput(abort), identity).message,
192153
);
193154
expect(isDurationAbortError(rewrappedAbort)).toBe(false);
194155
});
195156

196157
it("redacts the cause and the stack tail through the caller's redactor", () => {
197158
const report = prePrChecksFailureReport(
198-
new Error("auth failed for token=abcd1234"),
159+
prePrChecksFailureInput(new Error("auth failed for token=abcd1234")),
199160
(value) => value.replace("abcd1234", "[REDACTED]"),
200161
);
201162

@@ -204,10 +165,78 @@ describe("pre-PR checks step failure cause", () => {
204165
});
205166

206167
it("names a non-Error throw rather than dropping it", () => {
207-
const report = prePrChecksFailureReport("sandbox vanished", (v) => v);
168+
const report = prePrChecksFailureReport(
169+
prePrChecksFailureInput("sandbox vanished"),
170+
(v) => v,
171+
);
208172

209173
expect(report.message).toBe(`${MESSAGE_LEAD}sandbox vanished`);
210174
expect(report.name).toBe("string");
211175
expect(report.stackTail).toBe("");
212176
});
177+
178+
it("redacts and bounds before the value can be journaled", async () => {
179+
// Workflow journals step arguments durably, so whatever the flattener
180+
// returns is written into the run's event log verbatim. Redacting inside
181+
// the step would protect only the sentence an operator reads.
182+
const secret = "glpat-AAAAAAAAAAAAAAAAAAAA";
183+
const error = new Error(
184+
`clone failed for https://oauth2:${secret}@gitlab.com/acme/api.git ${"pad ".repeat(200)}`,
185+
);
186+
187+
const input = prePrChecksFailureInput(error);
188+
189+
expect(input.message).not.toContain(secret);
190+
expect(input.message).toContain("[REDACTED]");
191+
expect(input.message.length).toBe(PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH);
192+
expect(input.stack.length).toBeLessThanOrEqual(
193+
PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH,
194+
);
195+
});
196+
197+
it("survives a throw with no properties at all", async () => {
198+
// `throw null` and a bare Promise.reject() both reach here. A TypeError
199+
// raised inside the reporting path would lose the cause it exists to carry
200+
// and hand the operator an unrelated error.
201+
for (const thrown of [null, undefined]) {
202+
expect(() => prePrChecksFailureInput(thrown)).not.toThrow();
203+
await expect(describe_(thrown)).resolves.toContain(MESSAGE_LEAD);
204+
}
205+
});
206+
207+
it("degrades to a fixed sentence when the reporting step itself fails", async () => {
208+
// The step is the only place the cause is logged and its maxRetries is 0.
209+
// A failed dynamic import on a cold start, or an invocation killed
210+
// mid-step, must not substitute the reporting path's own error for the
211+
// report.
212+
mocks.error.mockImplementation(() => {
213+
throw new Error("logger transport is gone");
214+
});
215+
216+
const message = await prePrChecksFailureMessage(
217+
Object.assign(new Error("sandbox connection reset"), { name: "SandboxError" }),
218+
7,
219+
);
220+
221+
expect(message).toBe(
222+
"The Pre-PR checks step failed (SandboxError), and the cause could not be recorded.",
223+
);
224+
});
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+
);
213242
});

0 commit comments

Comments
 (0)