fix(cron): ignore delivered presentation warnings

This commit is contained in:
Peter Steinberger
2026-04-27 10:53:27 +01:00
parent 614a2846a2
commit d33eebd050
3 changed files with 82 additions and 8 deletions

View File

@@ -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:<id>` 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.

View File

@@ -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: [

View File

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