Skip to content

Commit 2043fd9

Browse files
authored
fix(worker): launch the pre-PR repair agent detached so it survives invocation boundaries (#311)
1 parent e7ee8ac commit 2043fd9

2 files changed

Lines changed: 225 additions & 3 deletions

File tree

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,114 @@ describe("runPrePrChecksWithFixes", () => {
396396
expect(checkRuns).toBe(1);
397397
});
398398

399+
it("launches the repair wrapper detached and reads its sentinel instead of holding the command open", async () => {
400+
// A blocking launch keeps one sandbox ndjson stream open for the whole
401+
// repair agent. Production runs whose pre-PR checks crossed a function
402+
// invocation boundary lost that stream mid-flight, and the SDK's parse
403+
// error was reported as a launch that never produced a process: empty
404+
// output, empty logs, no exit code, for an agent that was in fact running.
405+
// The launch has to return before the agent finishes, and completion has to
406+
// come from the wrapper's sentinel file.
407+
vi.useFakeTimers();
408+
try {
409+
let checkRuns = 0;
410+
let sentinelReads = 0;
411+
let sentinelReadsWhenLaunchReturned = -1;
412+
mockRunCommand.mockImplementation((cmd, args) => {
413+
const artifact = phaseArtifactCommand(cmd, args, "codex");
414+
if (artifact) return artifact;
415+
if (isWrapperLaunch(cmd)) {
416+
sentinelReadsWhenLaunchReturned = sentinelReads;
417+
// A detached command has not exited when runCommand resolves.
418+
return detachedCommand();
419+
}
420+
if (isSentinelRead(cmd, args)) {
421+
sentinelReads++;
422+
return commandResult(sentinelReads >= 2 ? 0 : 1);
423+
}
424+
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
425+
return commandResult(0, JSON.stringify(manifest));
426+
}
427+
if (cmd === "git" && args[0] === "-C" && args[2] === "rev-parse") {
428+
return commandResult(0, "web-head");
429+
}
430+
if (isConfiguredCheck(cmd)) {
431+
checkRuns++;
432+
return checkRuns === 1
433+
? commandResult(1, "", "Type error")
434+
: commandResult(0, "ok");
435+
}
436+
return commandResult(0, "");
437+
});
438+
439+
const pending = runPrePrChecksWithFixes(
440+
"sbx-test-123",
441+
{ repositories: [config.repositories[0]!] },
442+
"codex",
443+
"gpt-5",
444+
);
445+
const result = await drainPollTicks(pending);
446+
447+
expect(result.passed).toBe(true);
448+
expect(result.fixCycles).toBe(1);
449+
expect(result.agentFailure).toBeUndefined();
450+
expect(mockRunCommand).toHaveBeenCalledWith(
451+
expect.objectContaining({
452+
cmd: "bash",
453+
args: ["/tmp/pre-pr-fix-1-wrapper.sh"],
454+
cwd: "/vercel/sandbox",
455+
detached: true,
456+
}),
457+
);
458+
expect(mockRunCommand).toHaveBeenCalledWith("test", [
459+
"-f",
460+
"/tmp/pre-pr-fix-1-done",
461+
]);
462+
// The launch resolved before any sentinel existed, and the phase only
463+
// ended once a later poll found one: completion is driven by the poll,
464+
// not by the launch call.
465+
expect(sentinelReadsWhenLaunchReturned).toBe(0);
466+
expect(sentinelReads).toBe(2);
467+
} finally {
468+
vi.useRealTimers();
469+
}
470+
});
471+
472+
it("propagates a deadline abort raised while the repair phase is polled", async () => {
473+
// The deadline that used to abort the blocking launch now expires during
474+
// the poll, and it must stay a real TimeoutError rather than become a
475+
// "could not be launched" failure attributed to the provider.
476+
let sentinelReads = 0;
477+
mockRunCommand.mockImplementation((cmd, args) => {
478+
const artifact = phaseArtifactCommand(cmd, args, "codex");
479+
if (artifact) return artifact;
480+
if (isWrapperLaunch(cmd)) return detachedCommand();
481+
if (isSentinelRead(cmd, args)) {
482+
sentinelReads++;
483+
return commandResult(1);
484+
}
485+
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
486+
return commandResult(0, JSON.stringify(manifest));
487+
}
488+
if (isHeadInspection(cmd)) return commandResult(0, "web-head");
489+
if (isConfiguredCheck(cmd)) return commandResult(1, "", "still failing");
490+
return commandResult(0, "");
491+
});
492+
493+
await expect(
494+
runPrePrChecksWithFixes(
495+
"sbx-test-123",
496+
{ repositories: [config.repositories[0]!] },
497+
"codex",
498+
"gpt-5",
499+
3,
500+
50,
501+
),
502+
).rejects.toMatchObject({ name: "TimeoutError" });
503+
504+
expect(sentinelReads).toBeGreaterThan(0);
505+
});
506+
399507
it("names the cause when the repair process cannot be launched at all", async () => {
400508
// Production runs died here with a failure that named only the boundary:
401509
// no exit code, no captured bytes, and the thrown error destroyed at the
@@ -744,6 +852,40 @@ function isConfiguredCheck(cmd: unknown): boolean {
744852
);
745853
}
746854

855+
/** What a detached `runCommand` resolves to: the process is still running, so
856+
* there is no exit code yet. */
857+
function detachedCommand() {
858+
return {
859+
exitCode: null,
860+
stdout: vi.fn().mockResolvedValue(""),
861+
stderr: vi.fn().mockResolvedValue(""),
862+
};
863+
}
864+
865+
function isSentinelRead(cmd: unknown, args: unknown): boolean {
866+
return cmd === "test" && Array.isArray(args) && args[0] === "-f";
867+
}
868+
869+
/** Run the fake clock forward until the polled run settles, so a poll tick
870+
* costs the suite nothing. */
871+
async function drainPollTicks<T>(pending: Promise<T>): Promise<T> {
872+
let settled = false;
873+
const watched = pending.then(
874+
(value) => {
875+
settled = true;
876+
return value;
877+
},
878+
(error) => {
879+
settled = true;
880+
throw error;
881+
},
882+
);
883+
for (let tick = 0; tick < 20 && !settled; tick++) {
884+
await vi.advanceTimersByTimeAsync(5_000);
885+
}
886+
return watched;
887+
}
888+
747889
function isWrapperLaunch(cmd: unknown): boolean {
748890
const objectCommand = cmd as { cmd?: unknown; args?: unknown };
749891
return (

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

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { Sandbox as SandboxType } from "@vercel/sandbox";
1+
import type {
2+
Command as SandboxCommand,
3+
Sandbox as SandboxType,
4+
} from "@vercel/sandbox";
25
import { getSandboxCredentials } from "../sandbox/credentials.js";
36
import {
47
parseWorkspaceManifest,
@@ -118,6 +121,7 @@ export async function runPrePrChecksWithFixes(
118121
while (!result.passed && fixCycles < maxFixCycles) {
119122
fixCycles++;
120123
const fixer = await runFixAgent(
124+
sandboxId,
121125
sandbox,
122126
result,
123127
agentKind,
@@ -261,7 +265,57 @@ async function hasRepositoryChanged(
261265
return !repo.preAgentSha || repo.preAgentSha !== headSha;
262266
}
263267

268+
/** How often the detached Pre-PR repair wrapper's sentinel file is read. The
269+
* phase polls inside the caller's step rather than across workflow ticks, so
270+
* the tick is a plain sleep and can be far shorter than the 30s ceiling in
271+
* workflows/blocks/poll-phase.ts. */
272+
const PRE_PR_REPAIR_POLL_INTERVAL_MS = 5_000;
273+
274+
/**
275+
* Wait for the detached repair wrapper to touch its sentinel file.
276+
*
277+
* The only bound is the caller's deadline `signal`, which is what bounded the
278+
* blocking runCommand this replaced: an expired deadline rejects with the
279+
* signal's own reason, so a real AbortError/TimeoutError still propagates out
280+
* of the repair phase instead of being reported as a failed launch. Returns
281+
* "stopped" when the sandbox is gone, the same verdict checkPhaseDone gives
282+
* every other polled agent phase.
283+
*/
284+
async function waitForRepairPhase(
285+
sandboxId: string,
286+
sentinelFile: string,
287+
signal?: AbortSignal,
288+
): Promise<"done" | "stopped"> {
289+
const { checkPhaseDone } = await import("../sandbox/poll-agent.js");
290+
for (;;) {
291+
signal?.throwIfAborted();
292+
const status = await checkPhaseDone(sandboxId, sentinelFile);
293+
if (status === true) return "done";
294+
if (status === "stopped") return "stopped";
295+
await sleepUntilNextPoll(PRE_PR_REPAIR_POLL_INTERVAL_MS, signal);
296+
}
297+
}
298+
299+
function sleepUntilNextPoll(ms: number, signal?: AbortSignal): Promise<void> {
300+
return new Promise<void>((resolve, reject) => {
301+
if (signal?.aborted) {
302+
reject(signal.reason);
303+
return;
304+
}
305+
const onAbort = () => {
306+
clearTimeout(timer);
307+
reject(signal?.reason);
308+
};
309+
const timer = setTimeout(() => {
310+
signal?.removeEventListener("abort", onAbort);
311+
resolve();
312+
}, ms);
313+
signal?.addEventListener("abort", onAbort, { once: true });
314+
});
315+
}
316+
264317
async function runFixAgent(
318+
sandboxId: string,
265319
sandbox: SandboxSession,
266320
failedRun: PrePrCheckRunResult,
267321
agentKind: "claude" | "codex",
@@ -387,12 +441,21 @@ async function runFixAgent(
387441
if (failure.ok) throw new Error("unreachable");
388442
return { usage: null, failure };
389443
}
390-
let launch: SandboxCommandResult;
444+
let launch: SandboxCommand;
391445
try {
446+
// Detached, like every other agent phase (see writeAndStartPhase in
447+
// workflows/agent.ts): the sandbox SDK keeps one ndjson stream open for the
448+
// whole of a blocking runCommand, and a repair agent outlives the function
449+
// invocation that started it. When that invocation ends mid-stream the SDK
450+
// raises a parse/stream error rather than an abort, which reached the
451+
// generic catch below and reported a launch failure with no exit code and
452+
// no bytes for an agent that was in fact running. A detached launch returns
453+
// at once and completion is read from the wrapper's sentinel file instead.
392454
launch = await sandbox.runCommand({
393455
cmd: "bash",
394456
args: [paths.wrapper],
395457
cwd: "/vercel/sandbox",
458+
detached: true,
396459
...(signal ? { signal } : {}),
397460
});
398461
} catch (error) {
@@ -438,7 +501,9 @@ async function runFixAgent(
438501
if (failure.ok) throw new Error("unreachable");
439502
return { usage: null, failure };
440503
}
441-
if (launch.exitCode !== 0) {
504+
// A detached launch has no exit code yet while the wrapper runs; only a
505+
// wrapper that already exited non-zero is a launch that failed.
506+
if (launch.exitCode !== null && launch.exitCode !== 0) {
442507
const { commandProtocolFailure } = await import("../sandbox/agents/protocol.js");
443508
return {
444509
usage: null,
@@ -452,6 +517,21 @@ async function runFixAgent(
452517
}),
453518
};
454519
}
520+
const phaseStatus = await waitForRepairPhase(sandboxId, paths.sentinel, signal);
521+
if (phaseStatus === "stopped") {
522+
const { protocolFailure } = await import("../sandbox/agents/protocol.js");
523+
const failure = protocolFailure({
524+
spec: adapter.cliSpec,
525+
phase,
526+
artifacts: { stdout: "", stderr: "", structuredOutput: null, exitCode: null },
527+
failureKind: "provider_error",
528+
category: "provider",
529+
message: "The current agent phase could not be completed.",
530+
detail: "The sandbox stopped before the Pre-PR repair process finished.",
531+
});
532+
if (failure.ok) throw new Error("unreachable");
533+
return { usage: null, failure };
534+
}
455535
const artifacts = await collectPhaseFromSandbox(sandbox, paths);
456536
const usage = adapter.extractUsage(artifacts.stdout, artifacts.structuredOutput);
457537
const protocol = adapter.validateFreeformProtocol(artifacts, phase);

0 commit comments

Comments
 (0)