Skip to content

Commit c18b6fc

Browse files
committed
feat(cron): preview resolved delivery targets
1 parent 4c8299c commit c18b6fc

7 files changed

Lines changed: 209 additions & 8 deletions

File tree

docs/automation/cron-jobs.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ openclaw cron add \
2525

2626
# Check your jobs
2727
openclaw cron list
28+
openclaw cron show <job-id>
2829

2930
# See run history
3031
openclaw cron runs --id <job-id>
@@ -316,6 +317,9 @@ gog gmail watch start \
316317
# List all jobs
317318
openclaw cron list
318319

320+
# Show one job, including resolved delivery route
321+
openclaw cron show <jobId>
322+
319323
# Edit a job
320324
openclaw cron edit <jobId> --message "Updated prompt" --model "opus"
321325

docs/cli/cron.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Related:
1616

1717
Tip: run `openclaw cron --help` for the full command surface.
1818

19+
Note: `openclaw cron list` and `openclaw cron show <job-id>` preview the
20+
resolved delivery route. For `channel: "last"`, the preview shows whether the
21+
route resolved from the main/current session or will fail closed.
22+
1923
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
2024
output internal. `--deliver` remains as a deprecated alias for `--announce`.
2125

@@ -124,22 +128,27 @@ openclaw cron add \
124128

125129
Delivery ownership note:
126130

127-
- Cron-owned isolated jobs always route final user-visible delivery through the
128-
cron runner (`announce`, `webhook`, or internal-only `none`).
129-
- If the task mentions messaging some external recipient, the agent should
130-
describe the intended destination in its result instead of trying to send it
131-
directly.
131+
- Isolated cron chat delivery is shared. The agent can send directly with the
132+
`message` tool when a chat route is available.
133+
- `announce` fallback-delivers the final reply only when the agent did not send
134+
directly to the resolved target. `webhook` posts the finished payload to a URL.
135+
`none` disables runner fallback delivery.
132136

133137
## Common admin commands
134138

135139
Manual run:
136140

137141
```bash
142+
openclaw cron list
143+
openclaw cron show <job-id>
138144
openclaw cron run <job-id>
139145
openclaw cron run <job-id> --due
140146
openclaw cron runs --id <job-id> --limit 50
141147
```
142148

149+
`cron runs` entries include delivery diagnostics with the intended cron target,
150+
the resolved target, message-tool sends, fallback use, and delivered state.
151+
143152
Agent/session retargeting:
144153

145154
```bash

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
parseCronToolsAllow,
1717
printCronJson,
1818
printCronList,
19+
resolveCronDeliveryPreviews,
1920
warnIfCronSchedulerDisabled,
2021
} from "./shared.js";
2122

@@ -53,7 +54,8 @@ export function registerCronListCommand(cron: Command) {
5354
return;
5455
}
5556
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
56-
printCronList(jobs, defaultRuntime);
57+
const deliveryPreviews = await resolveCronDeliveryPreviews(jobs);
58+
printCronList(jobs, defaultRuntime, { deliveryPreviews });
5759
} catch (err) {
5860
handleCronCliError(err);
5961
}

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
import type { Command } from "commander";
2+
import type { CronJob } from "../../cron/types.js";
23
import { defaultRuntime } from "../../runtime.js";
4+
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
35
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
4-
import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js";
6+
import {
7+
handleCronCliError,
8+
printCronJson,
9+
printCronShow,
10+
warnIfCronSchedulerDisabled,
11+
} from "./shared.js";
12+
13+
function findCronJobForShow(jobs: CronJob[], idOrName: string): CronJob | undefined {
14+
const needle = normalizeLowercaseStringOrEmpty(idOrName);
15+
return jobs.find(
16+
(job) =>
17+
normalizeLowercaseStringOrEmpty(job.id) === needle ||
18+
normalizeLowercaseStringOrEmpty(job.name) === needle,
19+
);
20+
}
521

622
function registerCronToggleCommand(params: {
723
cron: Command;
@@ -61,6 +77,31 @@ export function registerCronSimpleCommands(cron: Command) {
6177
enabled: false,
6278
});
6379

80+
addGatewayClientOptions(
81+
cron
82+
.command("show")
83+
.description("Show a cron job")
84+
.argument("<id>", "Job id or exact name")
85+
.option("--json", "Output JSON", false)
86+
.action(async (id, opts) => {
87+
try {
88+
const res = await callGatewayFromCli("cron.list", opts, { includeDisabled: true });
89+
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
90+
const job = findCronJobForShow(jobs, String(id));
91+
if (!job) {
92+
throw new Error(`cron job not found: ${String(id)}`);
93+
}
94+
if (opts.json) {
95+
printCronJson(job);
96+
return;
97+
}
98+
await printCronShow(job, defaultRuntime);
99+
} catch (err) {
100+
handleCronCliError(err);
101+
}
102+
}),
103+
);
104+
64105
addGatewayClientOptions(
65106
cron
66107
.command("runs")

src/cli/cron-cli/shared.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,31 @@ describe("printCronList", () => {
122122
expect(dataLine).toContain("sonnet");
123123
});
124124

125+
it("shows delivery preview when provided", () => {
126+
const { logs, runtime } = createRuntimeLogCapture();
127+
const job = createBaseJob({
128+
id: "delivery-job",
129+
name: "Delivery",
130+
sessionTarget: "isolated",
131+
payload: { kind: "agentTurn", message: "hello" },
132+
});
133+
134+
printCronList([job], runtime, {
135+
deliveryPreviews: new Map([
136+
[
137+
"delivery-job",
138+
{
139+
label: "announce -> telegram:-100",
140+
detail: "resolved from last, main session",
141+
},
142+
],
143+
]),
144+
});
145+
146+
expect(logs[0]).toContain("Delivery");
147+
expect(logs[1]).toContain("announce -> telegram:-100");
148+
});
149+
125150
it("shows dash in Model column for systemEvent jobs", () => {
126151
const { logs, runtime } = createRuntimeLogCapture();
127152
const job = createBaseJob({

src/cli/cron-cli/shared.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ const CRON_NEXT_PAD = 10;
152152
const CRON_LAST_PAD = 10;
153153
const CRON_STATUS_PAD = 9;
154154
const CRON_TARGET_PAD = 9;
155+
const CRON_DELIVERY_PAD = 42;
155156
const CRON_AGENT_PAD = 10;
156157
const CRON_MODEL_PAD = 20;
157158

@@ -224,7 +225,101 @@ const formatStatus = (job: CronJob) => {
224225
return job.state.lastStatus ?? "idle";
225226
};
226227

227-
export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRuntime) {
228+
export type CronDeliveryPreview = {
229+
label: string;
230+
detail: string;
231+
};
232+
233+
function formatTarget(channel?: string, to?: string | null): string {
234+
if (!channel) {
235+
return "last";
236+
}
237+
if (to) {
238+
return `${channel}:${to}`;
239+
}
240+
return channel;
241+
}
242+
243+
function formatDeliveryDetail(params: {
244+
requestedChannel?: string;
245+
resolved: boolean;
246+
sessionKey?: string;
247+
error?: string;
248+
}): string {
249+
if (params.requestedChannel === "last" || !params.requestedChannel) {
250+
if (!params.resolved) {
251+
return params.error
252+
? `last -> no route, will fail-closed: ${params.error}`
253+
: "last -> no route, will fail-closed";
254+
}
255+
return params.sessionKey
256+
? `resolved from last, session ${params.sessionKey}`
257+
: "resolved from last, main session";
258+
}
259+
return params.resolved ? "explicit" : (params.error ?? "unresolved");
260+
}
261+
262+
export async function resolveCronDeliveryPreview(job: CronJob): Promise<CronDeliveryPreview> {
263+
const { resolveCronDeliveryPlan } = await import("../../cron/delivery-plan.js");
264+
const plan = resolveCronDeliveryPlan(job);
265+
if (!plan.requested && plan.mode === "none" && !job.delivery) {
266+
return { label: "not requested", detail: "not requested" };
267+
}
268+
if (plan.mode === "webhook") {
269+
const target = plan.to ? `webhook:${plan.to}` : "webhook";
270+
return { label: target, detail: plan.to ? "webhook" : "webhook target missing" };
271+
}
272+
273+
const requestedChannel = plan.channel ?? "last";
274+
const [{ loadConfig }, { resolveDefaultAgentId }, { resolveDeliveryTarget }] = await Promise.all([
275+
import("../../config/config.js"),
276+
import("../../agents/agent-scope-config.js"),
277+
import("../../cron/isolated-agent/delivery-target.js"),
278+
]);
279+
const cfg = loadConfig();
280+
const agentId = job.agentId?.trim() || resolveDefaultAgentId(cfg);
281+
const resolved = await resolveDeliveryTarget(cfg, agentId, {
282+
channel: requestedChannel,
283+
to: plan.to,
284+
threadId: plan.threadId,
285+
accountId: plan.accountId,
286+
sessionKey: job.sessionKey,
287+
});
288+
if (!resolved.ok) {
289+
return {
290+
label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`,
291+
detail: formatDeliveryDetail({
292+
requestedChannel,
293+
resolved: false,
294+
sessionKey: job.sessionKey,
295+
error: resolved.error.message,
296+
}),
297+
};
298+
}
299+
return {
300+
label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`,
301+
detail: formatDeliveryDetail({
302+
requestedChannel,
303+
resolved: true,
304+
sessionKey: job.sessionKey,
305+
}),
306+
};
307+
}
308+
309+
export async function resolveCronDeliveryPreviews(
310+
jobs: CronJob[],
311+
): Promise<Map<string, CronDeliveryPreview>> {
312+
const entries = await Promise.all(
313+
jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview(job)] as const),
314+
);
315+
return new Map(entries);
316+
}
317+
318+
export function printCronList(
319+
jobs: CronJob[],
320+
runtime: RuntimeEnv = defaultRuntime,
321+
opts?: { deliveryPreviews?: Map<string, CronDeliveryPreview> },
322+
) {
228323
if (jobs.length === 0) {
229324
runtime.log("No cron jobs.");
230325
return;
@@ -239,6 +334,7 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
239334
pad("Last", CRON_LAST_PAD),
240335
pad("Status", CRON_STATUS_PAD),
241336
pad("Target", CRON_TARGET_PAD),
337+
pad("Delivery", CRON_DELIVERY_PAD),
242338
pad("Agent ID", CRON_AGENT_PAD),
243339
pad("Model", CRON_MODEL_PAD),
244340
].join(" ");
@@ -261,6 +357,11 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
261357
const statusRaw = formatStatus(job);
262358
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
263359
const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
360+
const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
361+
const deliveryLabel = pad(
362+
truncate(deliveryPreview?.label ?? "-", CRON_DELIVERY_PAD),
363+
CRON_DELIVERY_PAD,
364+
);
264365
const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD);
265366
const modelLabel = pad(
266367
truncate(
@@ -302,6 +403,9 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
302403
colorize(rich, theme.muted, lastLabel),
303404
coloredStatus,
304405
coloredTarget,
406+
deliveryPreview
407+
? colorize(rich, theme.info, deliveryLabel)
408+
: colorize(rich, theme.muted, deliveryLabel),
305409
coloredAgent,
306410
job.payload.kind === "agentTurn" && job.payload.model
307411
? colorize(rich, theme.info, modelLabel)
@@ -311,3 +415,18 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt
311415
runtime.log(line.trimEnd());
312416
}
313417
}
418+
419+
export async function printCronShow(job: CronJob, runtime: RuntimeEnv = defaultRuntime) {
420+
const preview = await resolveCronDeliveryPreview(job);
421+
runtime.log(`id: ${job.id}`);
422+
runtime.log(`name: ${job.name}`);
423+
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
424+
runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
425+
runtime.log(`session: ${job.sessionTarget ?? "-"}`);
426+
runtime.log(`agent: ${job.agentId ?? "-"}`);
427+
runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);
428+
runtime.log(`delivery: ${preview.label} (${preview.detail})`);
429+
runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`);
430+
runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`);
431+
runtime.log(`status: ${formatStatus(job)}`);
432+
}

src/cron/delivery-plan.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
7070

7171
const isIsolatedAgentTurn =
7272
job.payload.kind === "agentTurn" &&
73+
typeof job.sessionTarget === "string" &&
7374
(job.sessionTarget === "isolated" ||
7475
job.sessionTarget === "current" ||
7576
job.sessionTarget.startsWith("session:"));

0 commit comments

Comments
 (0)