mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 18:40:24 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-",
|
||||
|
||||
Reference in New Issue
Block a user