fix(cron): treat transient tool error payloads as recoverable (openclaw#29527) thanks @Sid-Qin

Verified:
- pnpm install --frozen-lockfile
- pnpm check
- pnpm test -- --run src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts

Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Sid
2026-03-01 20:52:15 +08:00
committed by GitHub
parent 635c78a177
commit d509a81a12
2 changed files with 60 additions and 4 deletions

View File

@@ -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, {

View File

@@ -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({