From 544abc927f097fd2e4b8171f1455c0e99ccaed38 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:25:24 -0600 Subject: [PATCH] fix(cron): restore direct fallback after announce failure in best-effort mode (openclaw#36177) Verified: - pnpm build - pnpm check (fails on pre-existing origin/main lint debt in extensions/mattermost imports) - pnpm test:macmini Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + ...p-recipient-besteffortdeliver-true.test.ts | 6 +- src/cron/isolated-agent/delivery-dispatch.ts | 63 +++++++++---------- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ae198965e..05cef55abea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd. - Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd. - Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3. +- Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after `cron announce delivery failed` warnings. - Auto-reply/system events: restore runtime system events to the message timeline (`System:` lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera. - Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for `accounts`. (#34982) Thanks @HOYALIM. - Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin. diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index a4522279c63..f63c6b520b2 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -421,13 +421,13 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("marks attempted when announce delivery reports false and best-effort is enabled", async () => { + it("falls back to direct delivery when announce reports false and best-effort is enabled", async () => { const { res, deps } = await runAnnounceFlowResult(true); expect(res.status).toBe("ok"); - expect(res.delivered).toBe(false); + expect(res.delivered).toBe(true); expect(res.deliveryAttempted).toBe(true); expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); }); it("falls back to direct delivery when announce flow throws and best-effort is disabled", async () => { diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 0fc301cc2b7..6d07d5d3183 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -465,39 +465,38 @@ export async function dispatchCronDelivery( } } else { const announceResult = await deliverViaAnnounce(params.resolvedDelivery); - if (announceResult) { - // Fall back to direct delivery only when the announce send was - // actually attempted and failed. Early returns from - // deliverViaAnnounce (active subagents, interim suppression, - // SILENT_REPLY_TOKEN) are intentional suppressions that must NOT - // trigger direct delivery — doing so would bypass the suppression - // guard and leak partial/stale content to the channel. (#32432) - if (announceDeliveryWasAttempted && !delivered && !params.isAborted()) { - const directFallback = await deliverViaDirect(params.resolvedDelivery); - if (directFallback) { - return { - result: directFallback, - delivered, - deliveryAttempted, - summary, - outputText, - synthesizedText, - deliveryPayloads, - }; - } - // If direct delivery succeeded (returned null without error), - // `delivered` has been set to true by deliverViaDirect. - if (delivered) { - return { - delivered, - deliveryAttempted, - summary, - outputText, - synthesizedText, - deliveryPayloads, - }; - } + // Fall back to direct delivery only when the announce send was actually + // attempted and failed. Early returns from deliverViaAnnounce (active + // subagents, interim suppression, SILENT_REPLY_TOKEN) are intentional + // suppressions that must NOT trigger direct delivery — doing so would + // bypass the suppression guard and leak partial/stale content. + if (announceDeliveryWasAttempted && !delivered && !params.isAborted()) { + const directFallback = await deliverViaDirect(params.resolvedDelivery); + if (directFallback) { + return { + result: directFallback, + delivered, + deliveryAttempted, + summary, + outputText, + synthesizedText, + deliveryPayloads, + }; } + // If direct delivery succeeded (returned null without error), + // `delivered` has been set to true by deliverViaDirect. + if (delivered) { + return { + delivered, + deliveryAttempted, + summary, + outputText, + synthesizedText, + deliveryPayloads, + }; + } + } + if (announceResult) { return { result: announceResult, delivered,