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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 19:17:09 +00:00
co-authored by Cursor
parent ce89939cd8
commit 11bdba0052
3 changed files with 188 additions and 20 deletions
+21 -20
View File
@@ -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,
};
+116
View File
@@ -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: "" };
}
+51
View File
@@ -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");