From a3f2f07e90fddc4431f6dd4b2da0ccb12760ed60 Mon Sep 17 00:00:00 2001 From: Dylan Boudro <121908331+starmorph@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:05:12 -0700 Subject: [PATCH 1/2] Recover required investigation output before retrying work --- docs/3_TAKEAWAYS.md | 6 +- src/sandbox/render/sandbox.ts | 65 ++++++++++++++- test/sandbox-render.test.ts | 148 +++++++++++++++++++++++++++++++++- 3 files changed, 215 insertions(+), 4 deletions(-) diff --git a/docs/3_TAKEAWAYS.md b/docs/3_TAKEAWAYS.md index 3f42213..33280c0 100644 --- a/docs/3_TAKEAWAYS.md +++ b/docs/3_TAKEAWAYS.md @@ -186,7 +186,11 @@ Implementation: [`src/runner/protocol.ts`](../src/runner/protocol.ts) and A live exec stream can drop while the process continues. The in-Sandbox script therefore writes structured events and exit status to root-owned files. The host can poll for completion and download those files even after losing -the stream. +the stream. A completed run's output is required: the host makes up to three +retrieval attempts within one output-download budget before reporting an +infrastructure error with the transport cause. These retries read the same +Sandbox and never repeat the model execution. Aborted, timed-out, and lost +runs retain their explicit status with any partial output that can be recovered. Apply deadlines at several layers: diff --git a/src/sandbox/render/sandbox.ts b/src/sandbox/render/sandbox.ts index d82b8ca..89c8313 100644 --- a/src/sandbox/render/sandbox.ts +++ b/src/sandbox/render/sandbox.ts @@ -3,6 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { ClientError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; import { z } from "zod"; import { RepoManifest } from "../../manifest.ts"; +import { RUNNER_EXIT } from "../../runner/protocol.ts"; import { errorMessage } from "../../util.ts"; import { SnapshotUnavailableError, @@ -154,7 +155,11 @@ export async function openRenderSandbox(client: SandboxClient, snapshotName: str } } +const EXIT_TIMEOUT = 124; // coreutils `timeout` +const EXIT_SIGKILL = 137; + export async function runInvestigation(client: SandboxClient, id: string, opts: RunInvestigationOptions): Promise { + const startedAt = Date.now(); const runDir = `/tmp/run-${randomUUID()}`; const deadline = AbortSignal.timeout(opts.timeoutMs + client.t.investigationGraceMs); const signal = opts.signal ? AbortSignal.any([opts.signal, deadline]) : deadline; @@ -182,13 +187,24 @@ export async function runInvestigation(client: SandboxClient, id: string, opts: // A dropped exec stream doesn't stop the run, and the API can't reattach to it, so wait for the exit // status run-investigation.sh writes to a file instead. if (exitCode === null && !signal.aborted) exitCode = await awaitExitFile(client, id, runDir, signal); - const output = await readRunFile(client, id, `${runDir}.jsonl`, AbortSignal.timeout(client.t.verifyTimeoutMs)); + // Aborted, timed-out, or lost runs may leave only partial output. Completed non-timeout runs + // must recover their output: a transport error must not masquerade as an empty agent response. + const timedOut = exitCode === EXIT_TIMEOUT || exitCode === RUNNER_EXIT.timeout + || (exitCode === EXIT_SIGKILL && Date.now() - startedAt >= opts.timeoutMs); + const outputRequired = exitCode !== null && !signal.aborted && !timedOut; + let output: string; + try { + output = await readRunOutput(client, id, `${runDir}.jsonl`, AbortSignal.timeout(client.t.verifyTimeoutMs)); + } catch (err) { + if (outputRequired || !(err instanceof RunOutputUnavailableError)) throw err; + output = ""; + } return { exitCode, aborted: exitCode === null && signal.aborted, streamLost: exitCode === null && !signal.aborted, stderrTail, - ...parseRunnerEvents(output ?? ""), + ...parseRunnerEvents(output), }; } @@ -208,3 +224,48 @@ async function awaitExitFile(client: SandboxClient, id: string, runDir: string, async function readRunFile(client: SandboxClient, id: string, path: string, signal: AbortSignal): Promise { return client.download(id, path, signal).catch(() => null); } + +const OUTPUT_DOWNLOAD_ATTEMPTS = 3; +class RunOutputUnavailableError extends Error {} + +/** Retries retrieval from this same run within one budget; never repeats the agent execution. */ +async function readRunOutput(client: SandboxClient, id: string, path: string, signal: AbortSignal): Promise { + let lastError: unknown; + let attempts = 0; + while (attempts < OUTPUT_DOWNLOAD_ATTEMPTS) { + let file: Awaited>; + try { + attempts++; + file = await downloadOutputOnce(client, id, path, signal); + } catch (err) { + lastError = err; + if (signal.aborted || attempts === OUTPUT_DOWNLOAD_ATTEMPTS) break; + try { + await sleep(client.t.pollMs, undefined, { signal }); + } catch { + break; + } + continue; + } + // Decode outside the transport catch: malformed SDK data is a programming error, not empty output. + return file.data.toString("utf8"); + } + throw new RunOutputUnavailableError(`could not download runner output ${path} from ${id} after ${attempts} attempt(s)`, { cause: lastError }); +} + +/** The SDK's connect-token phase can ignore its signal; bound our wait and handle late rejections. */ +async function downloadOutputOnce(client: SandboxClient, id: string, path: string, signal: AbortSignal) { + signal.throwIfAborted(); + let onAbort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(signal.reason); }); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([ + // Each outer attempt is one paced request, without multiplying the SDK-wrapper retry count. + client.call(() => client.api.download(id, path, undefined, signal), { attempts: 1, signal }), + aborted, + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} diff --git a/test/sandbox-render.test.ts b/test/sandbox-render.test.ts index 5d34d71..36f42bd 100644 --- a/test/sandbox-render.test.ts +++ b/test/sandbox-render.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import type { RepoManifest } from "../src/manifest.ts"; -import { AbortError, RenderError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; +import { AbortError, RenderError, ServerError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; import type { SandboxExecEvent } from "@renderinc/sdk/experimental"; import { beforeEach, describe, expect, it } from "vitest"; import { configureSandboxes } from "../src/sandbox/index.ts"; @@ -11,6 +11,7 @@ import { openRenderSandbox, runInvestigation } from "../src/sandbox/render/sandb import { buildSnapshot, pruneSnapshots } from "../src/sandbox/render/snapshot.ts"; import { runnerInput } from "./helpers.ts"; import { createLog } from "../src/log.ts"; +import { captureRun, classifyRun } from "../src/tasks.ts"; const GH_TOKEN = "ghp_renderProviderSecret000000000000"; const MODEL_KEY = "sk-ant-api03-renderProviderSecret"; @@ -341,6 +342,150 @@ const runsInvestigation = (events: () => SandboxExecEvent[] | AsyncGenerator (command.includes("run-investigation.sh") ? events() : [exit(0)]); describe("runInvestigation", () => { + it("recovers required output after a transport failure without rerunning the investigation", async () => { + fake.exec_ = runsInvestigation(() => [exit(0)]); + let downloads = 0; + fake.file_ = (path) => { + if (!path.endsWith(".jsonl")) return undefined; + if (++downloads === 1) throw new TypeError("synthetic output transport failure"); + return clarify; + }; + + const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), modelApiKey: MODEL_KEY, timeoutMs: 60_000 }); + + expect(res.events).toEqual([JSON.parse(clarify)]); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 1, agentTimeoutMs: 60_000 }).kind).toBe("result"); + expect(downloads).toBe(2); + expect(fake.commands().filter((command) => command.includes("run-investigation.sh"))).toHaveLength(1); + expect(fake.calls.filter((call) => call.op === "upload" && call.path.endsWith("/key"))).toHaveLength(1); + }); + + it("throws an infrastructure error with the last transport cause after exactly three output requests", async () => { + fake.exec_ = runsInvestigation(() => [exit(0)]); + const failures = [1, 2, 3].map((attempt) => new ServerError(`synthetic output failure ${attempt}`, 503)); + let downloads = 0; + fake.file_ = (path) => { + if (!path.endsWith(".jsonl")) return undefined; + throw failures[Math.min(downloads++, failures.length - 1)]; + }; + + const api = fake.api(); + const download = api.download; + const signals: Array = []; + api.download = (...args) => { signals.push(args[3]); return download(...args); }; + const error = await runInvestigation(client(undefined, api), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ message: expect.stringContaining("after 3 attempt(s)"), cause: failures[2] }); + expect(downloads).toBe(3); + expect(signals).toHaveLength(3); + expect(signals[0]).toBeInstanceOf(AbortSignal); + expect(new Set(signals).size).toBe(1); + expect(fake.commands().filter((command) => command.includes("run-investigation.sh"))).toHaveLength(1); + }); + + it("keeps a genuinely downloaded empty output as an agent failure, without transport retries", async () => { + fake.exec_ = runsInvestigation(() => [exit(0)]); + let downloads = 0; + fake.file_ = (path) => path.endsWith(".jsonl") ? (downloads++, "") : undefined; + + const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }); + + expect(res).toMatchObject({ exitCode: 0, aborted: false, events: [], invalidLines: 0 }); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 1, agentTimeoutMs: 60_000 })).toMatchObject({ kind: "failed", code: "agent_error" }); + expect(downloads).toBe(1); + }); + + it("bounds a signal-ignoring output download and handles its late rejection", async () => { + fake.exec_ = runsInvestigation(() => [exit(0)]); + const api = fake.api(); + const signals: Array = []; + let rejectDownload: (err: Error) => void = () => {}; + api.download = async (_id, _path, _owner, signal) => { + signals.push(signal); + return new Promise((_resolve, reject) => { rejectDownload = reject; }); + }; + const original = client(undefined, api); + const bounded = new SandboxClient(api, original.cfg, original.log, { ...original.t, verifyTimeoutMs: 25 }); + const started = Date.now(); + + const error = await runInvestigation(bounded, "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ cause: expect.objectContaining({ name: "TimeoutError" }) }); + expect(Date.now() - started).toBeLessThan(1_000); + expect(signals).toHaveLength(1); + expect(signals[0]?.aborted).toBe(true); + rejectDownload(new TypeError("late synthetic transport failure")); + await new Promise((resolve) => setImmediate(resolve)); + }); + + it.each(["abort", "timeout"] as const)("preserves an explicit %s when partial output cannot be retrieved", async (reason) => { + const controller = new AbortController(); + fake.exec_ = runsInvestigation(async function* () { + if (reason === "abort") { + controller.abort(); + throw new AbortError(); + } + yield exit(124); + }); + + const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000, signal: controller.signal }); + + expect(res).toMatchObject({ exitCode: reason === "abort" ? null : 124, aborted: reason === "abort", events: [] }); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 60_000, agentTimeoutMs: 60_000 })).toMatchObject({ kind: "failed", code: "timeout" }); + }); + + it("requires output after an early SIGKILL instead of treating it as a timeout", async () => { + fake.exec_ = runsInvestigation(() => [exit(137)]); + const failure = new TypeError("synthetic output unavailable after early SIGKILL"); + fake.file_ = () => { throw failure; }; + + await expect(runInvestigation(client(), "sbx-run", { + input: runnerInput(LAYOUT), timeoutMs: 60_000, + })).rejects.toMatchObject({ cause: failure }); + }); + + it("preserves a SIGKILL timeout only after the run exhausts its time budget", async () => { + fake.exec_ = runsInvestigation(async function* () { + await new Promise((resolve) => setTimeout(resolve, 5)); + yield exit(137); + }); + + const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 1 }); + + expect(res).toMatchObject({ exitCode: 137, aborted: false, events: [] }); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 5, agentTimeoutMs: 1 })).toMatchObject({ kind: "failed", code: "timeout" }); + }); + + it("recovers a complete result even after an explicit abort", async () => { + const controller = new AbortController(); + fake.exec_ = runsInvestigation(async function* () { + controller.abort(); + throw new AbortError(); + }); + fake.file_ = (path) => path.endsWith(".jsonl") ? clarify : undefined; + + const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000, signal: controller.signal }); + + expect(res.aborted).toBe(true); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 1, agentTimeoutMs: 60_000 }).kind).toBe("result"); + }); + + it("does not turn invalid SDK output into a successful best-effort read", async () => { + const controller = new AbortController(); + fake.exec_ = runsInvestigation(async function* () { + controller.abort(); + throw new AbortError(); + }); + const api = fake.api(); + api.download = async () => ({ data: undefined, size: 0 }) as unknown as Awaited>; + + await expect(runInvestigation(client(undefined, api), "sbx-run", { + input: runnerInput(LAYOUT), timeoutMs: 60_000, signal: controller.signal, + })).rejects.toBeInstanceOf(TypeError); + }); + it("returns the run's events and exit status, streaming stderr and counting invalid lines", async () => { const started = event({ type: "started", pi_version: "0.83.0", model: "m", prompt_version: "v3" }); fake.exec_ = runsInvestigation(() => [out("runner ", "stderr"), out("diagnostics", "stderr"), exit(0)]); @@ -355,6 +500,7 @@ describe("runInvestigation", () => { }); it("uploads the model key as a file beside the input and never puts it in a command", async () => { + fake.file_ = (path) => path.endsWith(".jsonl") ? "" : undefined; await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), modelApiKey: MODEL_KEY, timeoutMs: 90_000 }); expect(fake.commands().some((c) => c.includes(MODEL_KEY))).toBe(false); From d914f7705537989975de20632d160f3cbebe6baf Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Wed, 28 Oct 2026 13:00:37 -0700 Subject: [PATCH 2/2] Require output once the run has exited, without reclassifying timeouts run-investigation.sh always writes the output file before it exits, so after an exit status a failed download is a transport error. Retry it in the same Sandbox and throw an infrastructure error if it stays unavailable. This drops the duplicated timeout classification and the SDK token-phase race, which is tracked upstream for #26. Co-authored-by: Cursor --- docs/3_TAKEAWAYS.md | 8 ++- src/sandbox/render/sandbox.ts | 85 +++++++++----------------------- test/sandbox-render.test.ts | 91 ++++------------------------------- 3 files changed, 35 insertions(+), 149 deletions(-) diff --git a/docs/3_TAKEAWAYS.md b/docs/3_TAKEAWAYS.md index c8136b3..0d412db 100644 --- a/docs/3_TAKEAWAYS.md +++ b/docs/3_TAKEAWAYS.md @@ -192,11 +192,9 @@ Implementation: [`src/runner/protocol.ts`](../src/runner/protocol.ts) and A live exec stream can drop while the process continues. The in-Sandbox script therefore writes structured events and exit status to root-owned files. The host can poll for completion and download those files even after losing -the stream. A completed run's output is required: the host makes up to three -retrieval attempts within one output-download budget before reporting an -infrastructure error with the transport cause. These retries read the same -Sandbox and never repeat the model execution. Aborted, timed-out, and lost -runs retain their explicit status with any partial output that can be recovered. +the stream. Once the script has exited, its output is required: the host +retries the download in the same Sandbox rather than treating a failed +download as an empty run, which would discard a paid investigation. Apply deadlines at several layers: diff --git a/src/sandbox/render/sandbox.ts b/src/sandbox/render/sandbox.ts index 89c8313..5a3f63a 100644 --- a/src/sandbox/render/sandbox.ts +++ b/src/sandbox/render/sandbox.ts @@ -3,7 +3,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { ClientError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; import { z } from "zod"; import { RepoManifest } from "../../manifest.ts"; -import { RUNNER_EXIT } from "../../runner/protocol.ts"; import { errorMessage } from "../../util.ts"; import { SnapshotUnavailableError, @@ -155,11 +154,7 @@ export async function openRenderSandbox(client: SandboxClient, snapshotName: str } } -const EXIT_TIMEOUT = 124; // coreutils `timeout` -const EXIT_SIGKILL = 137; - export async function runInvestigation(client: SandboxClient, id: string, opts: RunInvestigationOptions): Promise { - const startedAt = Date.now(); const runDir = `/tmp/run-${randomUUID()}`; const deadline = AbortSignal.timeout(opts.timeoutMs + client.t.investigationGraceMs); const signal = opts.signal ? AbortSignal.any([opts.signal, deadline]) : deadline; @@ -187,27 +182,36 @@ export async function runInvestigation(client: SandboxClient, id: string, opts: // A dropped exec stream doesn't stop the run, and the API can't reattach to it, so wait for the exit // status run-investigation.sh writes to a file instead. if (exitCode === null && !signal.aborted) exitCode = await awaitExitFile(client, id, runDir, signal); - // Aborted, timed-out, or lost runs may leave only partial output. Completed non-timeout runs - // must recover their output: a transport error must not masquerade as an empty agent response. - const timedOut = exitCode === EXIT_TIMEOUT || exitCode === RUNNER_EXIT.timeout - || (exitCode === EXIT_SIGKILL && Date.now() - startedAt >= opts.timeoutMs); - const outputRequired = exitCode !== null && !signal.aborted && !timedOut; - let output: string; - try { - output = await readRunOutput(client, id, `${runDir}.jsonl`, AbortSignal.timeout(client.t.verifyTimeoutMs)); - } catch (err) { - if (outputRequired || !(err instanceof RunOutputUnavailableError)) throw err; - output = ""; - } + // Once the script has exited, its output file exists: a failed download is an infrastructure error, not an empty run. + const outputSignal = AbortSignal.timeout(client.t.verifyTimeoutMs); + const output = exitCode === null + ? await readRunFile(client, id, `${runDir}.jsonl`, outputSignal) + : await readRunOutput(client, id, `${runDir}.jsonl`, outputSignal); return { exitCode, aborted: exitCode === null && signal.aborted, streamLost: exitCode === null && !signal.aborted, stderrTail, - ...parseRunnerEvents(output), + ...parseRunnerEvents(output ?? ""), }; } +const OUTPUT_DOWNLOAD_ATTEMPTS = 3; + +/** Retries in the same Sandbox, so a transport error doesn't repeat the model run. */ +async function readRunOutput(client: SandboxClient, id: string, path: string, signal: AbortSignal): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await client.download(id, path, signal); + } catch (err) { + if (attempt === OUTPUT_DOWNLOAD_ATTEMPTS || signal.aborted) { + throw new Error(`could not download ${path} from ${id} after ${attempt} attempt(s)`, { cause: err }); + } + await sleep(client.t.pollMs, undefined, { signal }).catch(() => {}); + } + } +} + /** Polls for the run's exit status until it appears, the sandbox stops running, or `signal` fires. */ async function awaitExitFile(client: SandboxClient, id: string, runDir: string, signal: AbortSignal): Promise { while (!signal.aborted) { @@ -224,48 +228,3 @@ async function awaitExitFile(client: SandboxClient, id: string, runDir: string, async function readRunFile(client: SandboxClient, id: string, path: string, signal: AbortSignal): Promise { return client.download(id, path, signal).catch(() => null); } - -const OUTPUT_DOWNLOAD_ATTEMPTS = 3; -class RunOutputUnavailableError extends Error {} - -/** Retries retrieval from this same run within one budget; never repeats the agent execution. */ -async function readRunOutput(client: SandboxClient, id: string, path: string, signal: AbortSignal): Promise { - let lastError: unknown; - let attempts = 0; - while (attempts < OUTPUT_DOWNLOAD_ATTEMPTS) { - let file: Awaited>; - try { - attempts++; - file = await downloadOutputOnce(client, id, path, signal); - } catch (err) { - lastError = err; - if (signal.aborted || attempts === OUTPUT_DOWNLOAD_ATTEMPTS) break; - try { - await sleep(client.t.pollMs, undefined, { signal }); - } catch { - break; - } - continue; - } - // Decode outside the transport catch: malformed SDK data is a programming error, not empty output. - return file.data.toString("utf8"); - } - throw new RunOutputUnavailableError(`could not download runner output ${path} from ${id} after ${attempts} attempt(s)`, { cause: lastError }); -} - -/** The SDK's connect-token phase can ignore its signal; bound our wait and handle late rejections. */ -async function downloadOutputOnce(client: SandboxClient, id: string, path: string, signal: AbortSignal) { - signal.throwIfAborted(); - let onAbort: () => void = () => {}; - const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(signal.reason); }); - signal.addEventListener("abort", onAbort, { once: true }); - try { - return await Promise.race([ - // Each outer attempt is one paced request, without multiplying the SDK-wrapper retry count. - client.call(() => client.api.download(id, path, undefined, signal), { attempts: 1, signal }), - aborted, - ]); - } finally { - signal.removeEventListener("abort", onAbort); - } -} diff --git a/test/sandbox-render.test.ts b/test/sandbox-render.test.ts index 15a9263..ff4d59d 100644 --- a/test/sandbox-render.test.ts +++ b/test/sandbox-render.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import type { RepoManifest } from "../src/manifest.ts"; -import { AbortError, RenderError, ServerError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; +import { AbortError, RenderError, SandboxSnapshotNotFoundError, SandboxSnapshotNotReadyError } from "@renderinc/sdk"; import type { SandboxExecEvent } from "@renderinc/sdk/experimental"; import { beforeEach, describe, expect, it } from "vitest"; import { configureSandboxes } from "../src/sandbox/index.ts"; @@ -447,27 +447,19 @@ describe("runInvestigation", () => { expect(fake.calls.filter((call) => call.op === "upload" && call.path.endsWith("/key"))).toHaveLength(1); }); - it("throws an infrastructure error with the last transport cause after exactly three output requests", async () => { + it("throws an infrastructure error with the last transport cause after three output requests", async () => { fake.exec_ = runsInvestigation(() => [exit(0)]); - const failures = [1, 2, 3].map((attempt) => new ServerError(`synthetic output failure ${attempt}`, 503)); + const failures = [1, 2, 3].map((attempt) => new TypeError(`synthetic output failure ${attempt}`)); let downloads = 0; fake.file_ = (path) => { if (!path.endsWith(".jsonl")) return undefined; - throw failures[Math.min(downloads++, failures.length - 1)]; + throw failures[downloads++]; }; - const api = fake.api(); - const download = api.download; - const signals: Array = []; - api.download = (...args) => { signals.push(args[3]); return download(...args); }; - const error = await runInvestigation(client(undefined, api), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }).catch((err: unknown) => err); + const error = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }).catch((err: unknown) => err); - expect(error).toBeInstanceOf(Error); expect(error).toMatchObject({ message: expect.stringContaining("after 3 attempt(s)"), cause: failures[2] }); expect(downloads).toBe(3); - expect(signals).toHaveLength(3); - expect(signals[0]).toBeInstanceOf(AbortSignal); - expect(new Set(signals).size).toBe(1); expect(fake.commands().filter((command) => command.includes("run-investigation.sh"))).toHaveLength(1); }); @@ -483,66 +475,17 @@ describe("runInvestigation", () => { expect(downloads).toBe(1); }); - it("bounds a signal-ignoring output download and handles its late rejection", async () => { - fake.exec_ = runsInvestigation(() => [exit(0)]); - const api = fake.api(); - const signals: Array = []; - let rejectDownload: (err: Error) => void = () => {}; - api.download = async (_id, _path, _owner, signal) => { - signals.push(signal); - return new Promise((_resolve, reject) => { rejectDownload = reject; }); - }; - const original = client(undefined, api); - const bounded = new SandboxClient(api, original.cfg, original.log, { ...original.t, verifyTimeoutMs: 25 }); - const started = Date.now(); - - const error = await runInvestigation(bounded, "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000 }).catch((err: unknown) => err); - - expect(error).toBeInstanceOf(Error); - expect(error).toMatchObject({ cause: expect.objectContaining({ name: "TimeoutError" }) }); - expect(Date.now() - started).toBeLessThan(1_000); - expect(signals).toHaveLength(1); - expect(signals[0]?.aborted).toBe(true); - rejectDownload(new TypeError("late synthetic transport failure")); - await new Promise((resolve) => setImmediate(resolve)); - }); - - it.each(["abort", "timeout"] as const)("preserves an explicit %s when partial output cannot be retrieved", async (reason) => { + it("keeps an aborted run's output best-effort", async () => { const controller = new AbortController(); fake.exec_ = runsInvestigation(async function* () { - if (reason === "abort") { - controller.abort(); - throw new AbortError(); - } - yield exit(124); + controller.abort(); + throw new AbortError(); }); const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 60_000, signal: controller.signal }); - expect(res).toMatchObject({ exitCode: reason === "abort" ? null : 124, aborted: reason === "abort", events: [] }); - expect(classifyRun(res, captureRun(res.events), { elapsedMs: 60_000, agentTimeoutMs: 60_000 })).toMatchObject({ kind: "failed", code: "timeout" }); - }); - - it("requires output after an early SIGKILL instead of treating it as a timeout", async () => { - fake.exec_ = runsInvestigation(() => [exit(137)]); - const failure = new TypeError("synthetic output unavailable after early SIGKILL"); - fake.file_ = () => { throw failure; }; - - await expect(runInvestigation(client(), "sbx-run", { - input: runnerInput(LAYOUT), timeoutMs: 60_000, - })).rejects.toMatchObject({ cause: failure }); - }); - - it("preserves a SIGKILL timeout only after the run exhausts its time budget", async () => { - fake.exec_ = runsInvestigation(async function* () { - await new Promise((resolve) => setTimeout(resolve, 5)); - yield exit(137); - }); - - const res = await runInvestigation(client(), "sbx-run", { input: runnerInput(LAYOUT), timeoutMs: 1 }); - - expect(res).toMatchObject({ exitCode: 137, aborted: false, events: [] }); - expect(classifyRun(res, captureRun(res.events), { elapsedMs: 5, agentTimeoutMs: 1 })).toMatchObject({ kind: "failed", code: "timeout" }); + expect(res).toMatchObject({ exitCode: null, aborted: true, events: [] }); + expect(classifyRun(res, captureRun(res.events), { elapsedMs: 1, agentTimeoutMs: 60_000 })).toMatchObject({ kind: "failed", code: "timeout" }); }); it("recovers a complete result even after an explicit abort", async () => { @@ -559,20 +502,6 @@ describe("runInvestigation", () => { expect(classifyRun(res, captureRun(res.events), { elapsedMs: 1, agentTimeoutMs: 60_000 }).kind).toBe("result"); }); - it("does not turn invalid SDK output into a successful best-effort read", async () => { - const controller = new AbortController(); - fake.exec_ = runsInvestigation(async function* () { - controller.abort(); - throw new AbortError(); - }); - const api = fake.api(); - api.download = async () => ({ data: undefined, size: 0 }) as unknown as Awaited>; - - await expect(runInvestigation(client(undefined, api), "sbx-run", { - input: runnerInput(LAYOUT), timeoutMs: 60_000, signal: controller.signal, - })).rejects.toBeInstanceOf(TypeError); - }); - it("returns the run's events and exit status, streaming stderr and counting invalid lines", async () => { const started = event({ type: "started", pi_version: "0.83.0", model: "m", prompt_version: "v3" }); fake.exec_ = runsInvestigation(() => [out("runner ", "stderr"), out("diagnostics", "stderr"), exit(0)]);