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:
+21
-20
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user