Skip to content

Commit 404eee1

Browse files
committed
fix(cron): route failure alerts to configured threads
1 parent b7450f8 commit 404eee1

30 files changed

Lines changed: 549 additions & 24 deletions

docs/automation/cron-jobs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ Failure notifications follow a separate destination path:
199199
- If neither is set and the job already delivers via `announce`, failure notifications now fall back to that primary announce target.
200200
- `delivery.failureDestination` is only supported on `sessionTarget="isolated"` jobs unless the primary delivery mode is `webhook`.
201201
- `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.
202+
- `failureAlert.threadId` routes threshold alerts to a channel thread or topic. When the alert uses the job's primary announce target and does not set its own `to`, it inherits `delivery.threadId`; if it names a separate destination, set `failureAlert.threadId` explicitly.
202203

203204
## CLI examples
204205

docs/cli/cron.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Failure notifications resolve in this order:
8484
2. Global `cron.failureDestination`.
8585
3. The job's primary announce target (when no explicit failure destination is set).
8686

87+
Per-job `failureAlert.threadId` routes threshold alerts to a channel thread or topic, such as a Telegram forum topic. When a failure alert uses the job's primary announce target and does not set its own `to`, OpenClaw reuses `delivery.threadId` so failures land beside the corresponding success announcement. If `failureAlert.to` names a separate destination, set `failureAlert.threadId` explicitly when that destination needs a topic.
88+
8789
<Note>
8890
Main-session jobs may only use `delivery.failureDestination` when primary delivery mode is `webhook`. Isolated jobs accept it in all modes.
8991
</Note>
@@ -239,6 +241,16 @@ Announce to a Telegram forum topic:
239241
openclaw cron edit <job-id> --announce --channel telegram --to "-1001234567890" --thread-id 42
240242
```
241243

244+
Route failure alerts to a Telegram forum topic:
245+
246+
```bash
247+
openclaw cron edit <job-id> \
248+
--failure-alert \
249+
--failure-alert-channel telegram \
250+
--failure-alert-to "-1001234567890" \
251+
--failure-alert-thread-id 42
252+
```
253+
242254
Create an isolated job with lightweight bootstrap context:
243255

244256
```bash

docs/gateway/configuration-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,7 @@ One-shot jobs stay enabled until retry attempts are exhausted, then disable whil
13331333
cooldownMs: 3600000,
13341334
includeSkipped: false,
13351335
mode: "announce",
1336+
threadId: 79,
13361337
accountId: "main",
13371338
},
13381339
},
@@ -1344,6 +1345,7 @@ One-shot jobs stay enabled until retry attempts are exhausted, then disable whil
13441345
- `cooldownMs`: minimum milliseconds between repeated alerts for the same job (non-negative integer).
13451346
- `includeSkipped`: count consecutive skipped runs toward the alert threshold (default: `false`). Skipped runs are tracked separately and do not affect execution-error backoff.
13461347
- `mode`: delivery mode - `"announce"` sends via a channel message; `"webhook"` posts to the configured webhook.
1348+
- `threadId`: optional channel thread or topic id for threshold alerts. If omitted and the alert uses a job's primary announce target, OpenClaw inherits the job's `delivery.threadId`.
13471349
- `accountId`: optional account or channel id to scope alert delivery.
13481350

13491351
### `cron.failureDestination`

docs/refactor/database-first.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,7 +1477,7 @@ update_check_state(state_key, last_checked_at, last_notified_version, last_notif
14771477
config_health_entries(config_path, last_known_good_json, last_promoted_good_json, last_observed_suspicious_signature, updated_at_ms)
14781478
sandbox_registry_entries(registry_kind, container_name, session_key, backend_id, runtime_label, image, created_at_ms, last_used_at_ms, config_label_kind, config_hash, cdp_port, no_vnc_port, entry_json, updated_at)
14791479
cron_run_logs(store_key, job_id, seq, ts, status, error, summary, diagnostics_summary, delivery_status, delivery_error, delivered, session_id, session_key, run_id, run_at_ms, duration_ms, next_run_at_ms, model, provider, total_tokens, entry_json, created_at)
1480-
cron_jobs(store_key, job_id, name, description, enabled, delete_after_run, created_at_ms, agent_id, session_key, schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at, stagger_ms, session_target, wake_mode, payload_kind, payload_message, payload_model, payload_fallbacks_json, payload_thinking, payload_timeout_seconds, payload_allow_unsafe_external_content, payload_external_content_source_json, payload_light_context, payload_tools_allow_json, delivery_mode, delivery_channel, delivery_to, delivery_thread_id, delivery_account_id, delivery_best_effort, failure_delivery_mode, failure_delivery_channel, failure_delivery_to, failure_delivery_account_id, failure_alert_disabled, failure_alert_after, failure_alert_channel, failure_alert_to, failure_alert_cooldown_ms, failure_alert_include_skipped, failure_alert_mode, failure_alert_account_id, next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status, last_error, last_duration_ms, consecutive_errors, consecutive_skipped, schedule_error_count, last_delivery_status, last_delivery_error, last_delivered, last_failure_alert_at_ms, job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at)
1480+
cron_jobs(store_key, job_id, name, description, enabled, delete_after_run, created_at_ms, agent_id, session_key, schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at, stagger_ms, session_target, wake_mode, payload_kind, payload_message, payload_model, payload_fallbacks_json, payload_thinking, payload_timeout_seconds, payload_allow_unsafe_external_content, payload_external_content_source_json, payload_light_context, payload_tools_allow_json, delivery_mode, delivery_channel, delivery_to, delivery_thread_id, delivery_account_id, delivery_best_effort, failure_delivery_mode, failure_delivery_channel, failure_delivery_to, failure_delivery_account_id, failure_alert_disabled, failure_alert_after, failure_alert_channel, failure_alert_to, failure_alert_thread_id, failure_alert_cooldown_ms, failure_alert_include_skipped, failure_alert_mode, failure_alert_account_id, next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status, last_error, last_duration_ms, consecutive_errors, consecutive_skipped, schedule_error_count, last_delivery_status, last_delivery_error, last_delivered, last_failure_alert_at_ms, job_json, state_json, runtime_updated_at_ms, schedule_identity, sort_order, updated_at)
14811481
delivery_queue_entries(queue_name, id, status, entry_kind, session_key, channel, target, account_id, retry_count, last_attempt_at, last_error, recovery_state, platform_send_started_at, entry_json, enqueued_at, updated_at, failed_at)
14821482
commitments(id, agent_id, session_key, channel, account_id, recipient_id, thread_id, sender_id, kind, sensitivity, source, status, reason, suggested_text, dedupe_key, confidence, due_earliest_ms, due_latest_ms, due_timezone, source_message_id, source_run_id, created_at_ms, updated_at_ms, attempts, last_attempt_at_ms, sent_at_ms, dismissed_at_ms, snoozed_until_ms, expired_at_ms, record_json)
14831483
migration_runs(id, started_at, finished_at, status, report_json)

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,30 @@ describe("CronToolSchema", () => {
181181
);
182182
});
183183

184-
it("job.failureAlert exposes after, channel, to, cooldownMs, includeSkipped, mode, accountId", () => {
184+
it("job.failureAlert exposes after, channel, to, threadId, cooldownMs, includeSkipped, mode, accountId", () => {
185185
expect(keysAt(schemaRecord, "job.failureAlert")).toEqual(
186-
["accountId", "after", "channel", "cooldownMs", "includeSkipped", "mode", "to"].toSorted(),
186+
[
187+
"accountId",
188+
"after",
189+
"channel",
190+
"cooldownMs",
191+
"includeSkipped",
192+
"mode",
193+
"threadId",
194+
"to",
195+
].toSorted(),
196+
);
197+
expect(keysAt(schemaRecord, "patch.failureAlert")).toEqual(
198+
[
199+
"accountId",
200+
"after",
201+
"channel",
202+
"cooldownMs",
203+
"includeSkipped",
204+
"mode",
205+
"threadId",
206+
"to",
207+
].toSorted(),
187208
);
188209
});
189210

src/agents/tools/cron-tool.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ function createCronFailureAlertSchema(): TSchema {
219219
after: optionalPositiveIntegerSchema({ description: "Failures before alert" }),
220220
channel: Type.Optional(Type.String({ description: "Alert channel" })),
221221
to: Type.Optional(Type.String({ description: "Alert target" })),
222+
threadId: Type.Optional(
223+
Type.Union([Type.String(), Type.Number()], { description: "Alert thread/topic id" }),
224+
),
222225
cooldownMs: optionalNonNegativeIntegerSchema({ description: "Alert cooldown ms" }),
223226
includeSkipped: Type.Optional(
224227
Type.Boolean({ description: "Skipped runs count toward alert" }),

src/cli/cron-cli.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1533,21 +1533,30 @@ describe("cron cli", () => {
15331533
"telegram",
15341534
"--failure-alert-to",
15351535
"19098680",
1536+
"--failure-alert-thread-id",
1537+
"79",
15361538
],
15371539
{ from: "user" },
15381540
);
15391541

15401542
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
15411543
const patch = updateCall?.[2] as {
15421544
patch?: {
1543-
failureAlert?: { after?: number; cooldownMs?: number; channel?: string; to?: string };
1545+
failureAlert?: {
1546+
after?: number;
1547+
cooldownMs?: number;
1548+
channel?: string;
1549+
to?: string;
1550+
threadId?: string | number;
1551+
};
15441552
};
15451553
};
15461554

15471555
expect(patch?.patch?.failureAlert?.after).toBe(3);
15481556
expect(patch?.patch?.failureAlert?.cooldownMs).toBe(3_600_000);
15491557
expect(patch?.patch?.failureAlert?.channel).toBe("telegram");
15501558
expect(patch?.patch?.failureAlert?.to).toBe("19098680");
1559+
expect(patch?.patch?.failureAlert?.threadId).toBe(79);
15511560
});
15521561

15531562
it("rejects partial failure alert threshold on cron edit", async () => {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export function registerCronEditCommand(cron: Command) {
124124
`Failure alert channel (${getCronChannelOptions()})`,
125125
)
126126
.option("--failure-alert-to <dest>", "Failure alert destination")
127+
.option("--failure-alert-thread-id <id>", "Telegram forum topic id for failure alerts")
127128
.option("--failure-alert-cooldown <duration>", "Minimum time between alerts (e.g. 1h, 30m)")
128129
.option("--failure-alert-include-skipped", "Count consecutive skipped runs toward alerts")
129130
.option("--failure-alert-exclude-skipped", "Alert only on execution errors")
@@ -352,6 +353,7 @@ export function registerCronEditCommand(cron: Command) {
352353
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";
353354
const hasFailureAlertChannel = typeof opts.failureAlertChannel === "string";
354355
const hasFailureAlertTo = typeof opts.failureAlertTo === "string";
356+
const hasFailureAlertThreadId = typeof opts.failureAlertThreadId === "string";
355357
const hasFailureAlertCooldown = typeof opts.failureAlertCooldown === "string";
356358
const hasFailureAlertIncludeSkipped =
357359
typeof opts.failureAlertIncludeSkipped === "boolean";
@@ -368,6 +370,7 @@ export function registerCronEditCommand(cron: Command) {
368370
hasFailureAlertAfter ||
369371
hasFailureAlertChannel ||
370372
hasFailureAlertTo ||
373+
hasFailureAlertThreadId ||
371374
hasFailureAlertCooldown ||
372375
hasFailureAlertIncludeSkipped ||
373376
hasFailureAlertExcludeSkipped ||
@@ -396,6 +399,12 @@ export function registerCronEditCommand(cron: Command) {
396399
const to = normalizeOptionalString(opts.failureAlertTo) ?? "";
397400
failureAlert.to = to ? to : undefined;
398401
}
402+
if (hasFailureAlertThreadId) {
403+
failureAlert.threadId = parseCronThreadIdOption(
404+
opts.failureAlertThreadId,
405+
"--failure-alert-thread-id",
406+
);
407+
}
399408
if (hasFailureAlertCooldown) {
400409
const cooldownMs = parseDurationMs(String(opts.failureAlertCooldown));
401410
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
@@ -3,17 +3,20 @@ import {
33
normalizeOptionalString,
44
} from "@openclaw/normalization-core/string-coerce";
55

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

src/config/types.cron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type CronFailureAlertConfig = {
1818
cooldownMs?: number;
1919
includeSkipped?: boolean;
2020
mode?: "announce" | "webhook";
21+
threadId?: string | number;
2122
accountId?: string;
2223
};
2324

0 commit comments

Comments
 (0)