From 4499068beeea9b36c93c007a4eaf75164d57e8d2 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 4 Apr 2026 17:58:12 +0800 Subject: [PATCH] fix(cron): preserve session-scoped failure fallback delivery --- CHANGELOG.md | 1 + src/cron/delivery.failure-notify.test.ts | 21 ++++++++ src/cron/delivery.ts | 3 +- src/gateway/server-cron.ts | 2 + src/gateway/server.cron.test.ts | 69 ++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2d152c8a0..14843551ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai - Exec approvals/node host: forward prepared `system.run` approval plans on the async node invoke path so mutable script operands keep their approval-time binding and drift revalidation instead of dropping back to unbound execution. - Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out. - Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation. +- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker. ## 2026.4.2 diff --git a/src/cron/delivery.failure-notify.test.ts b/src/cron/delivery.failure-notify.test.ts index 98cb437c961..aee7b3be020 100644 --- a/src/cron/delivery.failure-notify.test.ts +++ b/src/cron/delivery.failure-notify.test.ts @@ -95,6 +95,27 @@ describe("sendFailureNotificationAnnounce", () => { ); }); + it("passes sessionKey through to delivery-target resolution", async () => { + await sendFailureNotificationAnnounce( + {} as never, + {} as never, + "main", + "job-1", + { + channel: "telegram", + sessionKey: "agent:main:telegram:direct:123:thread:99", + }, + "Cron failed", + ); + + expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith({} as never, "main", { + channel: "telegram", + to: undefined, + accountId: undefined, + sessionKey: "agent:main:telegram:direct:123:thread:99", + }); + }); + it("does not send when target resolution fails", async () => { mocks.resolveDeliveryTarget.mockResolvedValue({ ok: false, diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index 74cb24e6aec..41c176ff74e 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -32,13 +32,14 @@ export async function sendFailureNotificationAnnounce( cfg: OpenClawConfig, agentId: string, jobId: string, - target: { channel?: string; to?: string; accountId?: string }, + target: { channel?: string; to?: string; accountId?: string; sessionKey?: string }, message: string, ): Promise { const resolvedTarget = await resolveDeliveryTarget(cfg, agentId, { channel: target.channel as CronMessageChannel | undefined, to: target.to, accountId: target.accountId, + sessionKey: target.sessionKey, }); if (!resolvedTarget.ok) { diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index d5198e3fa71..8ad45cb8005 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -476,6 +476,7 @@ export function buildGatewayCronService(params: { channel: failureDest.channel, to: failureDest.to, accountId: failureDest.accountId, + sessionKey: job.sessionKey, }, `⚠️ ${failureMessage}`, ); @@ -494,6 +495,7 @@ export function buildGatewayCronService(params: { channel: primaryPlan.channel, to: primaryPlan.to, accountId: primaryPlan.accountId, + sessionKey: job.sessionKey, }, `⚠️ ${failureMessage}`, ); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 8742d595588..8dcaff43e2f 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -24,6 +24,8 @@ const fetchWithSsrFGuardMock = vi.hoisted(() => })), ); +const sendFailureNotificationAnnounceMock = vi.hoisted(() => vi.fn(async () => undefined)); + vi.mock("../infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: (...args: unknown[]) => ( @@ -35,6 +37,17 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ )(...args), })); +vi.mock("../cron/delivery.js", async () => { + const actual = await vi.importActual("../cron/delivery.js"); + return { + ...actual, + sendFailureNotificationAnnounce: (...args: unknown[]) => + ( + sendFailureNotificationAnnounceMock as unknown as (...innerArgs: unknown[]) => Promise + )(...args), + }; +}); + installGatewayTestHooks({ scope: "suite" }); const CRON_WAIT_TIMEOUT_MS = 3_000; const EMPTY_CRON_STORE_CONTENT = JSON.stringify({ version: 1, jobs: [] }); @@ -232,6 +245,7 @@ describe("gateway server cron", () => { beforeEach(() => { // Keep polling helpers deterministic even if other tests left fake timers enabled. vi.useRealTimers(); + sendFailureNotificationAnnounceMock.mockClear(); }); test("handles cron CRUD, normalization, and patch semantics", { timeout: 20_000 }, async () => { @@ -976,6 +990,61 @@ describe("gateway server cron", () => { } }, 60_000); + test("falls back to the primary delivery channel on job failure and preserves sessionKey", async () => { + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-failure-primary-fallback-", + cronEnabled: false, + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" }); + const jobId = await addWebhookCronJob({ + ws, + name: "primary delivery fallback", + sessionTarget: "isolated", + delivery: { + mode: "announce", + channel: "last", + }, + }); + + const updateRes = await rpcReq(ws, "cron.update", { + id: jobId, + patch: { + sessionKey: "agent:main:telegram:direct:123:thread:99", + }, + }); + expect(updateRes.ok).toBe(true); + + const finished = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "finished", + ); + await runCronJobForce(ws, jobId); + await finished; + + expect(sendFailureNotificationAnnounceMock).toHaveBeenCalledTimes(1); + expect(sendFailureNotificationAnnounceMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.any(String), + jobId, + { + channel: "last", + to: undefined, + accountId: undefined, + sessionKey: "agent:main:telegram:direct:123:thread:99", + }, + '⚠️ Cron job "primary delivery fallback" failed: unknown error', + ); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }, 45_000); + test("ignores non-string cron.webhookToken values without crashing webhook delivery", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-webhook-secretinput-",