diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index c7eb61a2..22d6d1b9 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -451,13 +451,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise error.trim().length > 0) ?? ""; + const effectiveExitCode = (rawExitCode ?? 0) === 0 && parsedError ? 1 : rawExitCode; + const fallbackErrorMessage = parsedError || stderrLine || `Pi exited with code ${rawExitCode ?? -1}`; return { - exitCode: rawExitCode, + exitCode: effectiveExitCode, signal: attempt.proc.signal, timedOut: false, - errorMessage: (rawExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + errorMessage: (effectiveExitCode ?? 0) === 0 ? null : fallbackErrorMessage, usage: { inputTokens: attempt.parsed.usage.inputTokens, outputTokens: attempt.parsed.usage.outputTokens, diff --git a/packages/adapters/pi-local/src/server/parse.test.ts b/packages/adapters/pi-local/src/server/parse.test.ts index 6a3eef4d..34ef8b93 100644 --- a/packages/adapters/pi-local/src/server/parse.test.ts +++ b/packages/adapters/pi-local/src/server/parse.test.ts @@ -209,6 +209,57 @@ describe("parsePiJsonl", () => { expect(parsed.usage.cachedInputTokens).toBe(25); expect(parsed.usage.costUsd).toBe(0.003); }); + + it("surfaces failed auto-retry exhaustion as an error", () => { + const stdout = [ + JSON.stringify({ + type: "auto_retry_end", + success: false, + attempt: 3, + finalError: "Cloud Code Assist API error (429): RESOURCE_EXHAUSTED", + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.errors).toEqual(["Cloud Code Assist API error (429): RESOURCE_EXHAUSTED"]); + }); + + it("does not treat successful auto-retry as an error", () => { + const stdout = [ + JSON.stringify({ + type: "auto_retry_end", + success: true, + attempt: 2, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.errors).toEqual([]); + }); + + it("surfaces standalone error events", () => { + const stdout = [ + JSON.stringify({ + type: "error", + message: "Connection to model provider lost", + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.errors).toEqual(["Connection to model provider lost"]); + }); + + it("ignores error events with empty messages", () => { + const stdout = [ + JSON.stringify({ + type: "error", + message: "", + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.errors).toEqual([]); + }); }); describe("isPiUnknownSessionError", () => { diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts index 3ba50d8b..5ab0d4cd 100644 --- a/packages/adapters/pi-local/src/server/parse.ts +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -76,6 +76,15 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { continue; } + if (eventType === "auto_retry_end") { + const succeeded = event.success === true; + if (!succeeded) { + const finalError = asString(event.finalError, "").trim(); + result.errors.push(finalError || "Pi exhausted automatic retries without producing a response."); + } + continue; + } + // Turn lifecycle if (eventType === "turn_start") { continue; @@ -145,6 +154,14 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { continue; } + if (eventType === "error") { + const message = asString(event.message, "").trim(); + if (message) { + result.errors.push(message); + } + continue; + } + // Tool execution if (eventType === "tool_execution_start") { const toolCallId = asString(event.toolCallId, ""); diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts new file mode 100644 index 00000000..e0f49a27 --- /dev/null +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execute } from "@paperclipai/adapter-pi-local/server"; + +async function writeFakePiCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +if (process.argv.includes("--list-models")) { + console.log("provider model"); + console.log("google gemini-3-flash-preview"); + process.exit(0); +} +console.log(JSON.stringify({ type: "agent_start" })); +console.log(JSON.stringify({ type: "turn_start" })); +console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] })); +console.log(JSON.stringify({ type: "agent_end", messages: [] })); +console.log(JSON.stringify({ + type: "auto_retry_end", + success: false, + attempt: 3, + finalError: "Cloud Code Assist API error (429): RESOURCE_EXHAUSTED" +})); +process.exit(0); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +describe("pi_local execute", () => { + it("fails the run when Pi exhausts automatic retries despite exiting 0", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-execute-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "pi"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakePiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-pi-quota-exhausted", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Agent", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "google/gemini-3-flash-preview", + promptTemplate: "Keep working.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toContain("RESOURCE_EXHAUSTED"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); +});