Skip to content

Commit 5b0022e

Browse files
committed
fix(worker): run pre-PR checks detached so they survive the invocation ceiling
1 parent f747b52 commit 5b0022e

11 files changed

Lines changed: 4129 additions & 1496 deletions

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

Lines changed: 868 additions & 784 deletions
Large diffs are not rendered by default.

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

Lines changed: 832 additions & 308 deletions
Large diffs are not rendered by default.

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

Lines changed: 41 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,31 @@
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 real redactor reads process.env to find the secrets it blanks, which
13+
// makes its output depend on the machine running the suite. Redaction itself
14+
// is pinned below against an injected redactor.
15+
vi.mock("../sandbox/agents/protocol.js", () => ({
16+
redactDiagnosticText: (value: string) => value,
17+
}));
2318
vi.mock("../../env.js", () => ({
2419
env: { DASHBOARD_ORIGIN: "https://dashboard.example.com" },
2520
}));
2621

2722
import {
2823
PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
2924
PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH,
25+
describePrePrChecksFailureStep,
26+
prePrChecksFailureInput,
3027
prePrChecksFailureMustPropagate,
3128
prePrChecksFailureReport,
32-
runPrePrChecksStep,
3329
} from "./agent.js";
3430
import { isDurationAbortError } from "./run-budget.js";
3531
import { isRunControlError } from "./run-control-error.js";
@@ -43,51 +39,27 @@ function namedError(name: string, message: string): Error {
4339
return error;
4440
}
4541

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

5052
describe("pre-PR checks step failure cause", () => {
5153
beforeEach(() => {
5254
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();
7955
});
8056

8157
it("names the thrown cause instead of leaving Workflow's wrapper to speak alone", async () => {
8258
// Production runs wrun_01M0CBQNAX24STRMN5SGCKKGB2 and
8359
// wrun_01M0CAZKV3YMNFBCZJA8MT95GW died here reading only "exceeded max
8460
// retries", with no sanitized output captured and nothing in the runtime
8561
// 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(
62+
await expect(describe_(new Error("sandbox connection reset"))).resolves.toBe(
9163
`${MESSAGE_LEAD}sandbox connection reset`,
9264
);
9365
expect(mocks.error).toHaveBeenCalledTimes(1);
@@ -109,65 +81,46 @@ describe("pre-PR checks step failure cause", () => {
10981
});
11082

11183
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-
);
84+
const error = Object.assign(new Error("connect ECONNREFUSED 10.0.0.1:443"), {
85+
code: "ECONNREFUSED",
86+
});
11787

118-
await expect(runStep()).rejects.toThrow(
88+
await expect(describe_(error)).resolves.toBe(
11989
`${MESSAGE_LEAD}ECONNREFUSED: connect ECONNREFUSED 10.0.0.1:443`,
12090
);
12191
});
12292

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

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

132-
expect(thrown?.message).toContain(MESSAGE_LEAD);
133-
expect(thrown?.message).not.toContain(thrownMessage);
98+
expect(message).toContain(MESSAGE_LEAD);
99+
expect(message).not.toContain(thrownMessage);
134100
// Sentence plus the bound the cause is clamped to, and nothing more, so a
135101
// runaway error text cannot become the run status.
136-
expect(thrown?.message.length).toBeLessThanOrEqual(
102+
expect(message.length).toBeLessThanOrEqual(
137103
MESSAGE_LEAD.length + PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH,
138104
);
139105
const logged = mocks.error.mock.calls[0]?.[0] as { cause: string };
140106
expect(logged.cause.length).toBe(PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH);
141107
});
142108

143109
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();
110+
"keeps %s out of the wrap so the call site still recognizes it",
111+
(_label, error) => {
112+
expect(prePrChecksFailureMustPropagate(error)).toBe(true);
113+
expect(isRunControlError(error)).toBe(true);
155114
},
156115
);
157116

158117
it.each([["AbortError"], ["TimeoutError"]])(
159-
"rethrows a %s untouched so the duration budget stop survives",
160-
async (name) => {
118+
"keeps a %s out of the wrap so the duration budget stop survives",
119+
(name) => {
161120
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();
121+
122+
expect(prePrChecksFailureMustPropagate(error)).toBe(true);
123+
expect(isDurationAbortError(error)).toBe(true);
171124
},
172125
);
173126

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

186139
const rewrappedBudget = new Error(
187-
prePrChecksFailureReport(budgetStop, identity).message,
140+
prePrChecksFailureReport(prePrChecksFailureInput(budgetStop), identity).message,
188141
);
189142
expect(isRunControlError(rewrappedBudget)).toBe(false);
190143
const rewrappedAbort = new Error(
191-
prePrChecksFailureReport(abort, identity).message,
144+
prePrChecksFailureReport(prePrChecksFailureInput(abort), identity).message,
192145
);
193146
expect(isDurationAbortError(rewrappedAbort)).toBe(false);
194147
});
195148

196149
it("redacts the cause and the stack tail through the caller's redactor", () => {
197150
const report = prePrChecksFailureReport(
198-
new Error("auth failed for token=abcd1234"),
151+
prePrChecksFailureInput(new Error("auth failed for token=abcd1234")),
199152
(value) => value.replace("abcd1234", "[REDACTED]"),
200153
);
201154

@@ -204,7 +157,10 @@ describe("pre-PR checks step failure cause", () => {
204157
});
205158

206159
it("names a non-Error throw rather than dropping it", () => {
207-
const report = prePrChecksFailureReport("sandbox vanished", (v) => v);
160+
const report = prePrChecksFailureReport(
161+
prePrChecksFailureInput("sandbox vanished"),
162+
(v) => v,
163+
);
208164

209165
expect(report.message).toBe(`${MESSAGE_LEAD}sandbox vanished`);
210166
expect(report.name).toBe("string");

0 commit comments

Comments
 (0)