diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 353d92e1b85..3a4e9d91cd2 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -197,6 +197,50 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("treats transient error payloads as non-fatal when a later success payload exists", async () => { + await withTempHome(async (home) => { + mockEmbeddedPayloads([ + { + text: "⚠️ ✍️ Write: failed", + isError: true, + }, + { + text: "Write completed successfully.", + isError: false, + }, + ]); + const { res } = await runCronTurn(home, { + jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, + mockTexts: null, + }); + + expect(res.status).toBe("ok"); + expect(res.summary).toBe("Write completed successfully."); + }); + }); + + it("keeps error status when run-level error accompanies post-error text", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [ + { text: "Model context overflow", isError: true }, + { text: "Partial assistant text before error" }, + ], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + error: { kind: "context_overflow", message: "exceeded context window" }, + }, + }); + const { res } = await runCronTurn(home, { + jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, + mockTexts: null, + }); + + expect(res.status).toBe("error"); + }); + }); + it("passes resolved agentDir to runEmbeddedPiAgent", async () => { await withTempHome(async (home) => { const { res } = await runCronTurn(home, { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index d4f1f1d6d59..445001b93f4 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -570,17 +570,29 @@ export async function runCronIsolatedAgentTurn(params: { Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); const hasErrorPayload = payloads.some((payload) => payload?.isError === true); + const runLevelError = runResult.meta?.error; + const lastErrorPayloadIndex = payloads.findLastIndex((payload) => payload?.isError === true); + const hasSuccessfulPayloadAfterLastError = + !runLevelError && + lastErrorPayloadIndex >= 0 && + payloads + .slice(lastErrorPayloadIndex + 1) + .some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim())); + // Tool wrappers can emit transient/false-positive error payloads before a valid final + // assistant payload. Only treat payload errors as recoverable when (a) the run itself + // did not report a model/context-level error and (b) a non-error payload follows. + const hasFatalErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError; const lastErrorPayloadText = [...payloads] .toReversed() .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) ?.text?.trim(); - const embeddedRunError = hasErrorPayload + const embeddedRunError = hasFatalErrorPayload ? (lastErrorPayloadText ?? "cron isolated run returned an error payload") : undefined; const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) => withRunSession({ - status: hasErrorPayload ? "error" : "ok", - ...(hasErrorPayload + status: hasFatalErrorPayload ? "error" : "ok", + ...(hasFatalErrorPayload ? { error: embeddedRunError ?? "cron isolated run returned an error payload" } : {}), summary, @@ -637,7 +649,7 @@ export async function runCronIsolatedAgentTurn(params: { deliveryAttempted: deliveryResult.result.deliveryAttempted ?? deliveryResult.deliveryAttempted, }; - if (!hasErrorPayload || deliveryResult.result.status !== "ok") { + if (!hasFatalErrorPayload || deliveryResult.result.status !== "ok") { return resultWithDeliveryMeta; } return resolveRunOutcome({