From d33eebd050378fef0dcaa19b04e6927ebccc9d44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 10:53:27 +0100 Subject: [PATCH] fix(cron): ignore delivered presentation warnings --- CHANGELOG.md | 1 + src/cron/isolated-agent.helpers.test.ts | 50 +++++++++++++++++++++++++ src/cron/isolated-agent/helpers.ts | 39 +++++++++++++++---- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1dadf698de..24934816fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - Google Meet: preserve Gemini Live function names when replying to realtime tool calls so Google SDK validation accepts the `FunctionResponse` payload. Fixes #72425. (#72426) Thanks @BsnizND. - Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras. - Cron: apply `cron.maxConcurrentRuns` to a dedicated `cron-nested` isolated agent-turn lane as well as cron dispatch, so parallel cron jobs no longer serialize on inner LLM execution while non-cron nested flows keep their existing lane behavior. Fixes #72707. Thanks @kagura-agent. +- Cron: report isolated runs as successful when fallback delivery already delivered the reply, and ignore no-delivery Message/Canvas presentation warnings without hiding real execution failures. Fixes #72732 and #50170; follow-up to #54188. Thanks @zNatix, @pixeldyn, and @ChickenEggRoll. - Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors. - Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech. - Cron: resolve failure alerts and failure-destination announcements against `session:` targets before falling back to the creator session, so jobs created from group chats can notify the targeted direct session without cross-account routing errors. Refs #62777; carries forward #68535. Thanks @slideshow-dingo and @likewen-tech. diff --git a/src/cron/isolated-agent.helpers.test.ts b/src/cron/isolated-agent.helpers.test.ts index 13a577d7ce4..79431f5e2e0 100644 --- a/src/cron/isolated-agent.helpers.test.ts +++ b/src/cron/isolated-agent.helpers.test.ts @@ -65,6 +65,56 @@ describe("resolveCronPayloadOutcome", () => { expect(result.summary).toBe("Write completed successfully."); }); + it("treats trailing message delivery warnings as non-fatal when final assistant text exists", () => { + const result = resolveCronPayloadOutcome({ + payloads: [{ text: "Draft output" }, { text: "⚠️ ✉️ Message failed", isError: true }], + finalAssistantVisibleText: "Final cron report", + preferFinalAssistantVisibleText: true, + }); + + expect(result.hasFatalErrorPayload).toBe(false); + expect(result.embeddedRunError).toBeUndefined(); + expect(result.summary).toBe("Final cron report"); + expect(result.outputText).toBe("Final cron report"); + expect(result.deliveryPayloads).toEqual([{ text: "Final cron report" }]); + }); + + it("treats trailing canvas warnings as non-fatal when earlier assistant output exists", () => { + const result = resolveCronPayloadOutcome({ + payloads: [{ text: "Saved report to disk." }, { text: "⚠️ 🖼️ Canvas failed", isError: true }], + }); + + expect(result.hasFatalErrorPayload).toBe(false); + expect(result.summary).toBe("Saved report to disk."); + expect(result.outputText).toBe("Saved report to disk."); + expect(result.deliveryPayloads).toEqual([{ text: "Saved report to disk." }]); + }); + + it("keeps standalone presentation warnings fatal when there is no cron output", () => { + const result = resolveCronPayloadOutcome({ + payloads: [{ text: "⚠️ ✉️ Message failed", isError: true }], + }); + + expect(result.hasFatalErrorPayload).toBe(true); + expect(result.embeddedRunError).toBe("⚠️ ✉️ Message failed"); + expect(result.deliveryPayloads).toEqual([{ text: "⚠️ ✉️ Message failed", isError: true }]); + }); + + it("keeps real trailing errors fatal even when earlier assistant output exists", () => { + const result = resolveCronPayloadOutcome({ + payloads: [{ text: "Partial result" }, { text: "model provider unreachable", isError: true }], + finalAssistantVisibleText: "Partial result", + preferFinalAssistantVisibleText: true, + }); + + expect(result.hasFatalErrorPayload).toBe(true); + expect(result.embeddedRunError).toBe("model provider unreachable"); + expect(result.outputText).toBe("model provider unreachable"); + expect(result.deliveryPayloads).toEqual([ + { text: "model provider unreachable", isError: true }, + ]); + }); + it("keeps error payloads fatal when the run also reported a run-level error", () => { const result = resolveCronPayloadOutcome({ payloads: [ diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index 7c675e9687b..f27c1fbfb0a 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -241,6 +241,16 @@ export function resolveHeartbeatAckMaxChars(agentCfg?: { heartbeat?: { ackMaxCha return Math.max(0, raw); } +function isNonFatalCronPresentationWarning(text: string | undefined): boolean { + const normalized = normalizeOptionalString(text)?.toLowerCase(); + return ( + normalized === "⚠️ ✉️ message failed" || + normalized?.startsWith("⚠️ ✉️ message failed:") === true || + normalized === "⚠️ 🖼️ canvas failed" || + normalized?.startsWith("⚠️ 🖼️ canvas failed:") === true + ); +} + export function resolveCronPayloadOutcome(params: { payloads: DeliveryPayload[]; runLevelError?: unknown; @@ -259,16 +269,33 @@ export function resolveCronPayloadOutcome(params: { const lastErrorPayloadIndex = params.payloads.findLastIndex( (payload) => payload?.isError === true, ); + const lastErrorPayloadText = [...params.payloads] + .toReversed() + .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) + ?.text?.trim(); + const normalizedFinalAssistantVisibleText = normalizeOptionalString( + params.finalAssistantVisibleText, + ); const hasSuccessfulPayloadAfterLastError = !params.runLevelError && lastErrorPayloadIndex >= 0 && params.payloads .slice(lastErrorPayloadIndex + 1) .some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim())); - const hasFatalStructuredErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError; - const normalizedFinalAssistantVisibleText = normalizeOptionalString( - params.finalAssistantVisibleText, - ); + const hasSuccessfulPayloadBeforeLastError = + !params.runLevelError && + lastErrorPayloadIndex > 0 && + params.payloads + .slice(0, lastErrorPayloadIndex) + .some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim())); + const hasNonFatalTrailingPresentationWarning = + lastErrorPayloadIndex >= 0 && + isNonFatalCronPresentationWarning(lastErrorPayloadText) && + (normalizedFinalAssistantVisibleText !== undefined || hasSuccessfulPayloadBeforeLastError); + const hasFatalStructuredErrorPayload = + hasErrorPayload && + !hasSuccessfulPayloadAfterLastError && + !hasNonFatalTrailingPresentationWarning; const hasStructuredDeliveryPayloads = selectedDeliveryPayloads.some((payload) => payloadHasStructuredDeliveryContent(payload), ); @@ -293,10 +320,6 @@ export function resolveCronPayloadOutcome(params: { : synthesizedText ? [{ text: synthesizedText }] : []; - const lastErrorPayloadText = [...params.payloads] - .toReversed() - .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) - ?.text?.trim(); const denialSignal = resolveCronDenialSignal([ { field: "summary", text: summary }, { field: "outputText", text: outputText },