Skip to content

Commit 8a29d43

Browse files
committed
fix cron failure alert thread routing
1 parent 6a2ec62 commit 8a29d43

29 files changed

Lines changed: 781 additions & 33 deletions

docs/automation/cron-jobs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ Failure notifications follow a separate destination path:
225225
- `job.delivery.failureDestination` overrides that per job.
226226
- If neither is set and the job already delivers via `announce`, failure notifications now fall back to that primary announce target.
227227
- `delivery.failureDestination` is only supported on `sessionTarget="isolated"` jobs unless the primary delivery mode is `webhook`.
228+
- Per-job `failureAlert.threadId` routes announce alerts to a chat thread or topic. When it is omitted, announce alerts inherit `delivery.threadId` only if the alert uses the same channel and target as the job's primary announce delivery. Webhook alerts ignore thread ids. From the CLI, use `openclaw cron edit <jobId> --failure-alert-thread-id 79`.
228229
- `failureAlert.includeSkipped: true` opts a job or global cron alert policy into repeated skipped-run alerts. Skipped runs keep a separate consecutive skip counter, so they do not affect execution-error backoff.
229230

230231
## CLI examples

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,7 @@ One-shot jobs stay enabled until retry attempts are exhausted, then disable whil
13551355
- `includeSkipped`: count consecutive skipped runs toward the alert threshold (default: `false`). Skipped runs are tracked separately and do not affect execution-error backoff.
13561356
- `mode`: delivery mode - `"announce"` sends via a channel message; `"webhook"` posts to the configured webhook.
13571357
- `accountId`: optional account or channel id to scope alert delivery.
1358+
- Per-job `failureAlert.threadId` is set on the cron job, not in global `cron.failureAlert`. Announce alerts use it for chat threads/topics; webhook alerts do not.
13581359

13591360
### `cron.failureDestination`
13601361

packages/gateway-protocol/src/cron-validators.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ describe("cron protocol validators", () => {
162162
).toBe(true);
163163
});
164164

165+
it("accepts failure alert threadId on add and update params", () => {
166+
expect(
167+
validateCronAddParams({
168+
...minimalAddParams,
169+
failureAlert: {
170+
after: 1,
171+
channel: "telegram",
172+
to: "-100123",
173+
threadId: 79,
174+
},
175+
}),
176+
).toBe(true);
177+
expect(
178+
validateCronUpdateParams({
179+
id: "job-1",
180+
patch: {
181+
failureAlert: {
182+
threadId: "topic-79",
183+
},
184+
},
185+
}),
186+
).toBe(true);
187+
expect(
188+
validateCronUpdateParams({
189+
id: "job-1",
190+
patch: {
191+
failureAlert: {
192+
threadId: null,
193+
},
194+
},
195+
}),
196+
).toBe(true);
197+
});
198+
165199
it("accepts nullable delivery clears on update params", () => {
166200
expect(
167201
validateCronUpdateParams({

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,21 @@ export const CronFailureAlertSchema = Type.Object(
264264
after: Type.Optional(Type.Integer({ minimum: 1 })),
265265
channel: Type.Optional(CronAnnounceChannelSchema),
266266
to: Type.Optional(NonBlankString),
267+
threadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
268+
cooldownMs: Type.Optional(Type.Integer({ minimum: 0 })),
269+
includeSkipped: Type.Optional(Type.Boolean()),
270+
mode: Type.Optional(Type.Union([Type.Literal("announce"), Type.Literal("webhook")])),
271+
accountId: Type.Optional(NonEmptyString),
272+
},
273+
{ additionalProperties: false },
274+
);
275+
276+
const CronFailureAlertPatchSchema = Type.Object(
277+
{
278+
after: Type.Optional(Type.Integer({ minimum: 1 })),
279+
channel: Type.Optional(CronAnnounceChannelSchema),
280+
to: Type.Optional(NonBlankString),
281+
threadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])),
267282
cooldownMs: Type.Optional(Type.Integer({ minimum: 0 })),
268283
includeSkipped: Type.Optional(Type.Boolean()),
269284
mode: Type.Optional(Type.Union([Type.Literal("announce"), Type.Literal("webhook")])),
@@ -498,7 +513,7 @@ export const CronJobPatchSchema = Type.Object(
498513
wakeMode: Type.Optional(CronWakeModeSchema),
499514
payload: Type.Optional(CronPayloadPatchSchema),
500515
delivery: Type.Optional(CronDeliveryPatchSchema),
501-
failureAlert: Type.Optional(Type.Union([Type.Literal(false), CronFailureAlertSchema])),
516+
failureAlert: Type.Optional(Type.Union([Type.Literal(false), CronFailureAlertPatchSchema])),
502517
state: Type.Optional(CronJobStatePatchSchema),
503518
},
504519
{ additionalProperties: false },

src/agents/tools/cron-tool.schema.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,18 @@ describe("CronToolSchema", () => {
185185
);
186186
});
187187

188-
it("job.failureAlert exposes after, channel, to, cooldownMs, includeSkipped, mode, accountId", () => {
188+
it("job.failureAlert exposes after, channel, to, threadId, cooldownMs, includeSkipped, mode, accountId", () => {
189189
expect(keysAt(schemaRecord, "job.failureAlert")).toEqual(
190-
["accountId", "after", "channel", "cooldownMs", "includeSkipped", "mode", "to"].toSorted(),
190+
[
191+
"accountId",
192+
"after",
193+
"channel",
194+
"cooldownMs",
195+
"includeSkipped",
196+
"mode",
197+
"threadId",
198+
"to",
199+
].toSorted(),
191200
);
192201
});
193202

src/agents/tools/cron-tool.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -599,16 +599,31 @@ describe("cron tool", () => {
599599
);
600600
});
601601

602-
it("advertises delivery threadId in the tool schema", () => {
602+
it("advertises delivery and failure-alert threadId in the tool schema", () => {
603603
const tool = createTestCronTool();
604604
const parameters = tool.parameters as SchemaLike;
605605
const jobThreadId = parameters.properties?.job?.properties?.delivery?.properties?.threadId;
606606
const patchThreadId = parameters.properties?.patch?.properties?.delivery?.properties?.threadId;
607+
const jobFailureAlertThreadId =
608+
parameters.properties?.job?.properties?.failureAlert?.properties?.threadId;
609+
const patchFailureAlertThreadId =
610+
parameters.properties?.patch?.properties?.failureAlert?.properties?.threadId;
607611

608612
expect(jobThreadId?.description).toContain("Thread/topic id");
609613
expect(jobThreadId?.anyOf?.map((entry) => entry.type)).toEqual(["string", "number"]);
610614
expect(patchThreadId?.description).toContain("Thread/topic id");
611615
expect(patchThreadId?.anyOf?.map((entry) => entry.type)).toEqual(["string", "number", "null"]);
616+
expect(jobFailureAlertThreadId?.description).toContain("Thread/topic id");
617+
expect(jobFailureAlertThreadId?.anyOf?.map((entry) => entry.type)).toEqual([
618+
"string",
619+
"number",
620+
]);
621+
expect(patchFailureAlertThreadId?.description).toContain("Thread/topic id");
622+
expect(patchFailureAlertThreadId?.anyOf?.map((entry) => entry.type)).toEqual([
623+
"string",
624+
"number",
625+
"null",
626+
]);
612627
});
613628

614629
it("advertises nullable cron update clears in the tool schema", () => {
@@ -834,7 +849,7 @@ describe("cron tool", () => {
834849
lightContext: true,
835850
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
836851
toolsAllow: [" exec ", " read "],
837-
failureAlert: { after: 3, cooldownMs: 60_000 },
852+
failureAlert: { after: 3, cooldownMs: 60_000, threadId: 79 },
838853
});
839854

840855
const params = expectSingleGatewayCallMethod("cron.add") as
@@ -846,7 +861,7 @@ describe("cron tool", () => {
846861
fallbacks?: string[];
847862
toolsAllow?: string[];
848863
};
849-
failureAlert?: { after?: number; cooldownMs?: number };
864+
failureAlert?: { after?: number; cooldownMs?: number; threadId?: number };
850865
}
851866
| undefined;
852867
expect(params?.payload).toEqual({
@@ -856,7 +871,7 @@ describe("cron tool", () => {
856871
fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"],
857872
toolsAllow: ["exec", "read"],
858873
});
859-
expect(params?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
874+
expect(params?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000, threadId: 79 });
860875
});
861876

862877
it("recovers concatenated cron add keys from local tool-call parsers", async () => {
@@ -1584,21 +1599,21 @@ describe("cron tool", () => {
15841599
action: "update",
15851600
id: "job-2",
15861601
sessionTarget: "main",
1587-
failureAlert: { after: 3, cooldownMs: 60_000 },
1602+
failureAlert: { after: 3, cooldownMs: 60_000, threadId: 79 },
15881603
});
15891604

15901605
const params = expectSingleGatewayCallMethod("cron.update") as
15911606
| {
15921607
id?: string;
15931608
patch?: {
15941609
sessionTarget?: string;
1595-
failureAlert?: { after?: number; cooldownMs?: number };
1610+
failureAlert?: { after?: number; cooldownMs?: number; threadId?: number };
15961611
};
15971612
}
15981613
| undefined;
15991614
expect(params?.id).toBe("job-2");
16001615
expect(params?.patch?.sessionTarget).toBe("main");
1601-
expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
1616+
expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000, threadId: 79 });
16021617
});
16031618
it("passes through failureAlert=false for update", async () => {
16041619
callGatewayMock.mockResolvedValueOnce({ ok: true });

src/agents/tools/cron-tool.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,15 @@ function createCronDeliveryPatchSchema(): TSchema {
218218
// The schema declares `type: "object"` to stay compatible with providers that
219219
// enforce an OpenAPI 3.0 subset (e.g. Gemini via GitHub Copilot). The
220220
// description tells the LLM that `false` is also accepted.
221-
function createCronFailureAlertSchema(): TSchema {
221+
function createCronFailureAlertSchema(params: { nullableThreadIdClear: boolean }): TSchema {
222222
return Type.Optional(
223223
Type.Unsafe<Record<string, unknown> | false>({
224224
type: "object",
225225
properties: {
226226
after: optionalPositiveIntegerSchema({ description: "Failures before alert" }),
227227
channel: Type.Optional(Type.String({ description: "Alert channel" })),
228228
to: Type.Optional(Type.String({ description: "Alert target" })),
229+
threadId: deliveryThreadIdSchema({ nullableClears: params.nullableThreadIdClear }),
229230
cooldownMs: optionalNonNegativeIntegerSchema({ description: "Alert cooldown ms" }),
230231
includeSkipped: Type.Optional(
231232
Type.Boolean({ description: "Skipped runs count toward alert" }),
@@ -258,7 +259,7 @@ function createCronJobObjectSchema(): TSchema {
258259
enabled: Type.Optional(Type.Boolean()),
259260
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after first run" })),
260261
sessionKey: nullableStringSchema("Explicit session key, or null to clear it"),
261-
failureAlert: createCronFailureAlertSchema(),
262+
failureAlert: createCronFailureAlertSchema({ nullableThreadIdClear: false }),
262263
},
263264
{ additionalProperties: true },
264265
),
@@ -285,7 +286,7 @@ function createCronPatchObjectSchema(): TSchema {
285286
deleteAfterRun: Type.Optional(Type.Boolean()),
286287
agentId: nullableStringSchema("Agent id, or null to clear it"),
287288
sessionKey: nullableStringSchema("Explicit session key, or null to clear it"),
288-
failureAlert: createCronFailureAlertSchema(),
289+
failureAlert: createCronFailureAlertSchema({ nullableThreadIdClear: true }),
289290
},
290291
{ additionalProperties: true },
291292
),

src/cli/cron-cli.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ type CronUpdatePatch = {
8383
accountId?: string;
8484
bestEffort?: boolean;
8585
};
86+
failureAlert?: {
87+
after?: number;
88+
cooldownMs?: number;
89+
channel?: string;
90+
to?: string;
91+
threadId?: number;
92+
mode?: "announce" | "webhook";
93+
accountId?: string;
94+
includeSkipped?: boolean;
95+
};
8696
};
8797
};
8898

@@ -1670,21 +1680,30 @@ describe("cron cli", () => {
16701680
"telegram",
16711681
"--failure-alert-to",
16721682
"19098680",
1683+
"--failure-alert-thread-id",
1684+
"79",
16731685
],
16741686
{ from: "user" },
16751687
);
16761688

16771689
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
16781690
const patch = updateCall?.[2] as {
16791691
patch?: {
1680-
failureAlert?: { after?: number; cooldownMs?: number; channel?: string; to?: string };
1692+
failureAlert?: {
1693+
after?: number;
1694+
cooldownMs?: number;
1695+
channel?: string;
1696+
to?: string;
1697+
threadId?: number;
1698+
};
16811699
};
16821700
};
16831701

16841702
expect(patch?.patch?.failureAlert?.after).toBe(3);
16851703
expect(patch?.patch?.failureAlert?.cooldownMs).toBe(3_600_000);
16861704
expect(patch?.patch?.failureAlert?.channel).toBe("telegram");
16871705
expect(patch?.patch?.failureAlert?.to).toBe("19098680");
1706+
expect(patch?.patch?.failureAlert?.threadId).toBe(79);
16881707
});
16891708

16901709
it("rejects partial failure alert threshold on cron edit", async () => {
@@ -1697,6 +1716,16 @@ describe("cron cli", () => {
16971716
);
16981717
});
16991718

1719+
it("rejects invalid --failure-alert-thread-id on cron edit", async () => {
1720+
await expectCronCommandExit(["cron", "edit", "job-1", "--failure-alert-thread-id", "topic-79"]);
1721+
expectRuntimeErrorContaining("--failure-alert-thread-id must be a positive integer");
1722+
expect(callGatewayFromCli).not.toHaveBeenCalledWith(
1723+
"cron.update",
1724+
expect.anything(),
1725+
expect.anything(),
1726+
);
1727+
});
1728+
17001729
it("supports --no-failure-alert on cron edit", async () => {
17011730
callGatewayFromCli.mockClear();
17021731

src/cli/cron-cli/register.cron-edit.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export function registerCronEditCommand(cron: Command) {
139139
`Failure alert channel (${getCronChannelOptions()})`,
140140
)
141141
.option("--failure-alert-to <dest>", "Failure alert destination")
142+
.option("--failure-alert-thread-id <id>", "Telegram forum topic thread id for failure alerts")
142143
.option("--failure-alert-cooldown <duration>", "Minimum time between alerts (e.g. 1h, 30m)")
143144
.option("--failure-alert-include-skipped", "Count consecutive skipped runs toward alerts")
144145
.option("--failure-alert-exclude-skipped", "Alert only on execution errors")
@@ -440,6 +441,11 @@ export function registerCronEditCommand(cron: Command) {
440441
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";
441442
const hasFailureAlertChannel = typeof opts.failureAlertChannel === "string";
442443
const hasFailureAlertTo = typeof opts.failureAlertTo === "string";
444+
const failureAlertThreadId = parseCronThreadIdOption(
445+
opts.failureAlertThreadId,
446+
"--failure-alert-thread-id",
447+
);
448+
const hasFailureAlertThreadId = typeof failureAlertThreadId === "number";
443449
const hasFailureAlertCooldown = typeof opts.failureAlertCooldown === "string";
444450
const hasFailureAlertIncludeSkipped =
445451
typeof opts.failureAlertIncludeSkipped === "boolean";
@@ -456,6 +462,7 @@ export function registerCronEditCommand(cron: Command) {
456462
hasFailureAlertAfter ||
457463
hasFailureAlertChannel ||
458464
hasFailureAlertTo ||
465+
hasFailureAlertThreadId ||
459466
hasFailureAlertCooldown ||
460467
hasFailureAlertIncludeSkipped ||
461468
hasFailureAlertExcludeSkipped ||
@@ -484,6 +491,9 @@ export function registerCronEditCommand(cron: Command) {
484491
const to = normalizeOptionalString(opts.failureAlertTo) ?? "";
485492
failureAlert.to = to ? to : undefined;
486493
}
494+
if (hasFailureAlertThreadId) {
495+
failureAlert.threadId = failureAlertThreadId;
496+
}
487497
if (hasFailureAlertCooldown) {
488498
const cooldownMs = parseDurationMs(String(opts.failureAlertCooldown));
489499
if (!cooldownMs && cooldownMs !== 0) {

src/cli/cron-cli/thread-id-shared.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ import {
44
normalizeOptionalString,
55
} from "@openclaw/normalization-core/string-coerce";
66

7-
export function parseCronThreadIdOption(value: unknown): number | undefined {
7+
export function parseCronThreadIdOption(
8+
value: unknown,
9+
flagName = "--thread-id",
10+
): number | undefined {
811
const raw = normalizeOptionalString(value);
912
if (!raw) {
1013
return undefined;
1114
}
1215
if (!/^\d+$/.test(raw)) {
13-
throw new Error("--thread-id must be a positive integer Telegram topic thread id");
16+
throw new Error(`${flagName} must be a positive integer Telegram topic thread id`);
1417
}
1518
const parsed = Number.parseInt(raw, 10);
1619
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
17-
throw new Error("--thread-id must be a safe positive integer Telegram topic thread id");
20+
throw new Error(`${flagName} must be a safe positive integer Telegram topic thread id`);
1821
}
1922
return parsed;
2023
}

0 commit comments

Comments
 (0)