fix(cron): notify user via primary delivery channel on job failure (#60622)

Merged via squash.

Prepared head SHA: bee4dfca06
Co-authored-by: artwalker <44759507+artwalker@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
XING
2026-04-04 20:24:16 +08:00
committed by GitHub
parent c89d4857e4
commit 587f19967c
5 changed files with 125 additions and 8 deletions

View File

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

View File

@@ -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<void> {
const resolvedTarget = await resolveDeliveryTarget(cfg, agentId, {
channel: target.channel as CronMessageChannel | undefined,
to: target.to,
accountId: target.accountId,
sessionKey: target.sessionKey,
});
if (!resolvedTarget.ok) {

View File

@@ -9,7 +9,11 @@ import {
resolveAgentMainSessionKey,
} from "../config/sessions.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { resolveFailureDestination, sendFailureNotificationAnnounce } from "../cron/delivery.js";
import {
resolveCronDeliveryPlan,
resolveFailureDestination,
sendFailureNotificationAnnounce,
} from "../cron/delivery.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { resolveDeliveryTarget } from "../cron/isolated-agent/delivery-target.js";
import {
@@ -420,12 +424,13 @@ export function buildGatewayCronService(params: {
}
if (evt.status === "error" && job) {
const failureDest = resolveFailureDestination(job, params.cfg.cron?.failureDestination);
if (failureDest) {
const isBestEffort = job.delivery?.bestEffort === true;
const isBestEffort = job.delivery?.bestEffort === true;
if (!isBestEffort) {
const failureMessage = `Cron job "${job.name}" failed: ${evt.error ?? "unknown error"}`;
const failureDest = resolveFailureDestination(job, params.cfg.cron?.failureDestination);
if (!isBestEffort) {
const failureMessage = `Cron job "${job.name}" failed: ${evt.error ?? "unknown error"}`;
if (failureDest) {
// Explicit failureDestination configured — use it
const failurePayload = {
jobId: job.id,
jobName: job.name,
@@ -471,8 +476,28 @@ export function buildGatewayCronService(params: {
channel: failureDest.channel,
to: failureDest.to,
accountId: failureDest.accountId,
sessionKey: job.sessionKey,
},
`[Cron Failure] ${failureMessage}`,
`⚠️ ${failureMessage}`,
);
}
} else {
// No explicit failureDestination — fall back to primary delivery channel (#60608)
const primaryPlan = resolveCronDeliveryPlan(job);
if (primaryPlan.mode === "announce" && primaryPlan.requested) {
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
void sendFailureNotificationAnnounce(
params.deps,
runtimeConfig,
agentId,
job.id,
{
channel: primaryPlan.channel,
to: primaryPlan.to,
accountId: primaryPlan.accountId,
sessionKey: job.sessionKey,
},
`⚠️ ${failureMessage}`,
);
}
}

View File

@@ -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<typeof import("../cron/delivery.js")>("../cron/delivery.js");
return {
...actual,
sendFailureNotificationAnnounce: (...args: unknown[]) =>
(
sendFailureNotificationAnnounceMock as unknown as (...innerArgs: unknown[]) => Promise<void>
)(...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-",