From 11bdba00526648285cb4c899b2ea03df2533089d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:17:09 +0000 Subject: [PATCH] fix(pi-agent): treat exhausted SDK retry plus settle as failure agent_settled after terminated or auto_retry_end success=false is not a deliverable. Return retryable for provider transients and keep clean missing targets permanent. Co-authored-by: Cursor --- deploy/pi-agent/runner.mjs | 41 ++++----- deploy/pi-agent/settle-policy.mjs | 116 +++++++++++++++++++++++++ deploy/pi-agent/settle-policy.test.mjs | 51 +++++++++++ 3 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 deploy/pi-agent/settle-policy.mjs create mode 100644 deploy/pi-agent/settle-policy.test.mjs diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index 845d28b7..e163dd7a 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -10,6 +10,7 @@ import { spawn } from "node:child_process"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, relative, isAbsolute } from "node:path"; import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/tavily-search.mjs"; +import { applyAgentEvent, classifyRunOutcome, newSettleState } from "./settle-policy.mjs"; const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; @@ -159,8 +160,7 @@ async function handleRun(body) { let agentId = ""; let runId = ""; let lastErr = ""; - let succeeded = false; - let retryFailed = false; + const settle = newSettleState(); console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); @@ -202,22 +202,10 @@ async function handleRun(body) { const t = ev.type || ""; if (t === "session" && ev.id) agentId = ev.id; - if (t === "auto_retry_end") { - if (ev.success === false) { - retryFailed = true; - lastErr = ev.finalError || ev.errorMessage || lastErr || "auto_retry_failed"; - } else { - retryFailed = false; - } - } + applyAgentEvent(settle, ev); if (t === "agent_settled") { - // PI emits agent_settled even after exhausted retries; that is not a deliverable. - succeeded = !retryFailed; child.stdin.end(); } - if (t === "agent_error") { - lastErr = ev.error?.message || ev.message || "agent_error"; - } } }); @@ -248,8 +236,8 @@ async function handleRun(body) { `Timestamp: ${new Date().toISOString()}`, `Workspace: ${workspace}`, `Model: ${model} (${provider})`, - `Outcome: ${succeeded ? "SUCCESS" : "FAILED"}`, - `Error: ${lastErr || "None"}`, + `Outcome: ${settle.succeeded ? "SUCCESS" : "FAILED"}`, + `Error: ${settle.lastErr || lastErr || "None"}`, `\n--- RAW OUTPUT & EVENTS ---\n${fullStdout}`, `\n--- STDERR ---\n${stderrBuf}`, ].join("\n"); @@ -258,12 +246,19 @@ async function handleRun(body) { console.warn("[runner] Could not write execution.log:", logErr.message); } - if (!succeeded) { + lastErr = settle.lastErr || lastErr; + if (!settle.succeeded) { + const classified = classifyRunOutcome({ + ...settle, + lastErr, + missing: [], + }); return { ok: false, agent_id: agentId, run_id: runId, - fallback_reason: lastErr || "agent_not_settled", + retryable: classified.retryable, + fallback_reason: classified.fallback_reason, stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches: [], }; @@ -296,11 +291,17 @@ async function handleRun(body) { } if (missing.length > 0) { + const classified = classifyRunOutcome({ + ...settle, + lastErr, + missing, + }); return { ok: false, agent_id: agentId, run_id: runId, - fallback_reason: `missing target deliverable ${missing.join(",")}`, + retryable: classified.retryable, + fallback_reason: classified.fallback_reason, stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches, }; diff --git a/deploy/pi-agent/settle-policy.mjs b/deploy/pi-agent/settle-policy.mjs new file mode 100644 index 00000000..9a88aca3 --- /dev/null +++ b/deploy/pi-agent/settle-policy.mjs @@ -0,0 +1,116 @@ +/** + * Success / retry classification for a PI RPC session. + * Write() arguments in traces are never treated as deliverables. + */ + +export function newSettleState() { + return { + retryFailed: false, + providerTransient: false, + lastErr: "", + succeeded: false, + }; +} + +function messageField(ev, key) { + const msg = ev && ev.message; + if (!msg || typeof msg !== "object") { + return ""; + } + const v = msg[key]; + return typeof v === "string" ? v : ""; +} + +export function isTransientText(s) { + const t = String(s || "").toLowerCase(); + return t.includes("connection error") || + t.includes("terminated") || + t.includes("auto_retry") || + t.includes("econnreset") || + t.includes("socket hang up"); +} + +export function isPermanentReason(s) { + const t = String(s || "").toLowerCase(); + return t.includes("prompt_empty") || + t.includes("unauthorized") || + t.includes("治理"); +} + +export function applyAgentEvent(state, ev) { + if (!state || !ev || typeof ev !== "object") { + return state; + } + const t = ev.type || ""; + const stop = messageField(ev, "stopReason"); + const msgErr = messageField(ev, "errorMessage") || + (typeof ev.errorMessage === "string" ? ev.errorMessage : "") || + (typeof ev.finalError === "string" ? ev.finalError : "") || + (ev.error && typeof ev.error.message === "string" ? ev.error.message : ""); + + if (msgErr && isTransientText(msgErr)) { + state.providerTransient = true; + state.lastErr = msgErr; + } + if (stop === "error") { + state.providerTransient = true; + if (msgErr) { + state.lastErr = msgErr; + } + } + if (t === "auto_retry_start") { + state.providerTransient = true; + if (ev.errorMessage) { + state.lastErr = String(ev.errorMessage); + } + } + if (t === "auto_retry_end") { + if (ev.success === false) { + state.retryFailed = true; + state.providerTransient = true; + state.lastErr = ev.finalError || ev.errorMessage || state.lastErr || "auto_retry_failed"; + } else { + state.retryFailed = false; + } + } + if (t === "agent_error") { + const err = (ev.error && ev.error.message) || ev.message || "agent_error"; + const text = typeof err === "string" ? err : "agent_error"; + state.lastErr = text; + if (isTransientText(text)) { + state.providerTransient = true; + } + } + if (t === "agent_settled") { + // PI emits agent_settled even after exhausted SDK retries. + state.succeeded = !state.retryFailed && !state.providerTransient; + } + return state; +} + +export function classifyRunOutcome({ + succeeded = false, + retryFailed = false, + providerTransient = false, + lastErr = "", + missing = [], +} = {}) { + const miss = Array.isArray(missing) ? missing.filter(Boolean) : []; + if (isPermanentReason(lastErr)) { + return { ok: false, retryable: false, fallback_reason: lastErr || "permanent_failure" }; + } + if (!succeeded) { + const reason = lastErr || "agent_not_settled"; + const retryable = (retryFailed || providerTransient || isTransientText(reason)) && + !isPermanentReason(reason); + return { ok: false, retryable, fallback_reason: reason }; + } + if (miss.length > 0) { + return { + ok: false, + retryable: Boolean(retryFailed || providerTransient), + fallback_reason: `missing target deliverable ${miss.join(",")}`, + }; + } + return { ok: true, retryable: false, fallback_reason: "" }; +} diff --git a/deploy/pi-agent/settle-policy.test.mjs b/deploy/pi-agent/settle-policy.test.mjs new file mode 100644 index 00000000..e9aa2a7f --- /dev/null +++ b/deploy/pi-agent/settle-policy.test.mjs @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { applyAgentEvent, classifyRunOutcome, newSettleState } from "./settle-policy.mjs"; + +const ui11Events = [ + { type: "message_end", message: { stopReason: "error", errorMessage: "terminated" } }, + { type: "auto_retry_start", attempt: 1, maxAttempts: 3, errorMessage: "terminated" }, + { type: "message_end", message: { stopReason: "error", errorMessage: "Connection error." } }, + { type: "auto_retry_start", attempt: 2, errorMessage: "Connection error." }, + { type: "auto_retry_start", attempt: 3, errorMessage: "Connection error." }, + { type: "auto_retry_end", success: false, attempt: 3, finalError: "Connection error." }, + { type: "agent_settled" }, +]; + +const state = newSettleState(); +for (const ev of ui11Events) { + applyAgentEvent(state, ev); +} +assert.equal(state.succeeded, false, "agent_settled after auto_retry_end success=false is not success"); +assert.equal(state.providerTransient, true); +assert.equal(state.retryFailed, true); + +const settledFail = classifyRunOutcome({ + ...state, + missing: ["output.md"], + lastErr: "Connection error.", +}); +assert.equal(settledFail.ok, false); +assert.equal(settledFail.retryable, true); +assert.match(settledFail.fallback_reason, /Connection error|terminated|auto_retry/i); + +const cleanMissing = classifyRunOutcome({ + succeeded: true, + retryFailed: false, + providerTransient: false, + lastErr: "", + missing: ["output.md"], +}); +assert.equal(cleanMissing.ok, false); +assert.equal(cleanMissing.retryable, false, "clean settle + missing target is permanent"); +assert.match(cleanMissing.fallback_reason, /missing target deliverable/); + +const promptEmpty = classifyRunOutcome({ + succeeded: false, + retryFailed: false, + providerTransient: false, + lastErr: "prompt_empty", + missing: [], +}); +assert.equal(promptEmpty.retryable, false); + +console.log("settle-policy.test.mjs PASS");