From c18b6fc9da03c8393b381e0788b79dbf5f97997e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 21 Apr 2026 10:30:22 +0530 Subject: [PATCH] feat(cron): preview resolved delivery targets --- docs/automation/cron-jobs.md | 4 + docs/cli/cron.md | 19 +++- src/cli/cron-cli/register.cron-add.ts | 4 +- src/cli/cron-cli/register.cron-simple.ts | 43 +++++++- src/cli/cron-cli/shared.test.ts | 25 +++++ src/cli/cron-cli/shared.ts | 121 ++++++++++++++++++++++- src/cron/delivery-plan.ts | 1 + 7 files changed, 209 insertions(+), 8 deletions(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 422640e7b5f..e3bf42afdf0 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -25,6 +25,7 @@ openclaw cron add \ # Check your jobs openclaw cron list +openclaw cron show # See run history openclaw cron runs --id @@ -316,6 +317,9 @@ gog gmail watch start \ # List all jobs openclaw cron list +# Show one job, including resolved delivery route +openclaw cron show + # Edit a job openclaw cron edit --message "Updated prompt" --model "opus" diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 4cb695998b4..8cfaa7ddd31 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -16,6 +16,10 @@ Related: Tip: run `openclaw cron --help` for the full command surface. +Note: `openclaw cron list` and `openclaw cron show ` preview the +resolved delivery route. For `channel: "last"`, the preview shows whether the +route resolved from the main/current session or will fail closed. + Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep output internal. `--deliver` remains as a deprecated alias for `--announce`. @@ -124,22 +128,27 @@ openclaw cron add \ Delivery ownership note: -- Cron-owned isolated jobs always route final user-visible delivery through the - cron runner (`announce`, `webhook`, or internal-only `none`). -- If the task mentions messaging some external recipient, the agent should - describe the intended destination in its result instead of trying to send it - directly. +- Isolated cron chat delivery is shared. The agent can send directly with the + `message` tool when a chat route is available. +- `announce` fallback-delivers the final reply only when the agent did not send + directly to the resolved target. `webhook` posts the finished payload to a URL. + `none` disables runner fallback delivery. ## Common admin commands Manual run: ```bash +openclaw cron list +openclaw cron show openclaw cron run openclaw cron run --due openclaw cron runs --id --limit 50 ``` +`cron runs` entries include delivery diagnostics with the intended cron target, +the resolved target, message-tool sends, fallback use, and delivered state. + Agent/session retargeting: ```bash diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index dd7a0271c7c..22e4493fb04 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -16,6 +16,7 @@ import { parseCronToolsAllow, printCronJson, printCronList, + resolveCronDeliveryPreviews, warnIfCronSchedulerDisabled, } from "./shared.js"; @@ -53,7 +54,8 @@ export function registerCronListCommand(cron: Command) { return; } const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; - printCronList(jobs, defaultRuntime); + const deliveryPreviews = await resolveCronDeliveryPreviews(jobs); + printCronList(jobs, defaultRuntime, { deliveryPreviews }); } catch (err) { handleCronCliError(err); } diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 891d8691968..7e951aec8fc 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -1,7 +1,23 @@ import type { Command } from "commander"; +import type { CronJob } from "../../cron/types.js"; import { defaultRuntime } from "../../runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; -import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js"; +import { + handleCronCliError, + printCronJson, + printCronShow, + warnIfCronSchedulerDisabled, +} from "./shared.js"; + +function findCronJobForShow(jobs: CronJob[], idOrName: string): CronJob | undefined { + const needle = normalizeLowercaseStringOrEmpty(idOrName); + return jobs.find( + (job) => + normalizeLowercaseStringOrEmpty(job.id) === needle || + normalizeLowercaseStringOrEmpty(job.name) === needle, + ); +} function registerCronToggleCommand(params: { cron: Command; @@ -61,6 +77,31 @@ export function registerCronSimpleCommands(cron: Command) { enabled: false, }); + addGatewayClientOptions( + cron + .command("show") + .description("Show a cron job") + .argument("", "Job id or exact name") + .option("--json", "Output JSON", false) + .action(async (id, opts) => { + try { + const res = await callGatewayFromCli("cron.list", opts, { includeDisabled: true }); + const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; + const job = findCronJobForShow(jobs, String(id)); + if (!job) { + throw new Error(`cron job not found: ${String(id)}`); + } + if (opts.json) { + printCronJson(job); + return; + } + await printCronShow(job, defaultRuntime); + } catch (err) { + handleCronCliError(err); + } + }), + ); + addGatewayClientOptions( cron .command("runs") diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index d33fae5745f..7af3b94a067 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -122,6 +122,31 @@ describe("printCronList", () => { expect(dataLine).toContain("sonnet"); }); + it("shows delivery preview when provided", () => { + const { logs, runtime } = createRuntimeLogCapture(); + const job = createBaseJob({ + id: "delivery-job", + name: "Delivery", + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: "hello" }, + }); + + printCronList([job], runtime, { + deliveryPreviews: new Map([ + [ + "delivery-job", + { + label: "announce -> telegram:-100", + detail: "resolved from last, main session", + }, + ], + ]), + }); + + expect(logs[0]).toContain("Delivery"); + expect(logs[1]).toContain("announce -> telegram:-100"); + }); + it("shows dash in Model column for systemEvent jobs", () => { const { logs, runtime } = createRuntimeLogCapture(); const job = createBaseJob({ diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 16872061f54..f07cf5bb611 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -152,6 +152,7 @@ const CRON_NEXT_PAD = 10; const CRON_LAST_PAD = 10; const CRON_STATUS_PAD = 9; const CRON_TARGET_PAD = 9; +const CRON_DELIVERY_PAD = 42; const CRON_AGENT_PAD = 10; const CRON_MODEL_PAD = 20; @@ -224,7 +225,101 @@ const formatStatus = (job: CronJob) => { return job.state.lastStatus ?? "idle"; }; -export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRuntime) { +export type CronDeliveryPreview = { + label: string; + detail: string; +}; + +function formatTarget(channel?: string, to?: string | null): string { + if (!channel) { + return "last"; + } + if (to) { + return `${channel}:${to}`; + } + return channel; +} + +function formatDeliveryDetail(params: { + requestedChannel?: string; + resolved: boolean; + sessionKey?: string; + error?: string; +}): string { + if (params.requestedChannel === "last" || !params.requestedChannel) { + if (!params.resolved) { + return params.error + ? `last -> no route, will fail-closed: ${params.error}` + : "last -> no route, will fail-closed"; + } + return params.sessionKey + ? `resolved from last, session ${params.sessionKey}` + : "resolved from last, main session"; + } + return params.resolved ? "explicit" : (params.error ?? "unresolved"); +} + +export async function resolveCronDeliveryPreview(job: CronJob): Promise { + const { resolveCronDeliveryPlan } = await import("../../cron/delivery-plan.js"); + const plan = resolveCronDeliveryPlan(job); + if (!plan.requested && plan.mode === "none" && !job.delivery) { + return { label: "not requested", detail: "not requested" }; + } + if (plan.mode === "webhook") { + const target = plan.to ? `webhook:${plan.to}` : "webhook"; + return { label: target, detail: plan.to ? "webhook" : "webhook target missing" }; + } + + const requestedChannel = plan.channel ?? "last"; + const [{ loadConfig }, { resolveDefaultAgentId }, { resolveDeliveryTarget }] = await Promise.all([ + import("../../config/config.js"), + import("../../agents/agent-scope-config.js"), + import("../../cron/isolated-agent/delivery-target.js"), + ]); + const cfg = loadConfig(); + const agentId = job.agentId?.trim() || resolveDefaultAgentId(cfg); + const resolved = await resolveDeliveryTarget(cfg, agentId, { + channel: requestedChannel, + to: plan.to, + threadId: plan.threadId, + accountId: plan.accountId, + sessionKey: job.sessionKey, + }); + if (!resolved.ok) { + return { + label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, + detail: formatDeliveryDetail({ + requestedChannel, + resolved: false, + sessionKey: job.sessionKey, + error: resolved.error.message, + }), + }; + } + return { + label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`, + detail: formatDeliveryDetail({ + requestedChannel, + resolved: true, + sessionKey: job.sessionKey, + }), + }; +} + +export async function resolveCronDeliveryPreviews( + jobs: CronJob[], +): Promise> { + const entries = await Promise.all( + jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview(job)] as const), + ); + return new Map(entries); +} + +export function printCronList( + jobs: CronJob[], + runtime: RuntimeEnv = defaultRuntime, + opts?: { deliveryPreviews?: Map }, +) { if (jobs.length === 0) { runtime.log("No cron jobs."); return; @@ -239,6 +334,7 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt pad("Last", CRON_LAST_PAD), pad("Status", CRON_STATUS_PAD), pad("Target", CRON_TARGET_PAD), + pad("Delivery", CRON_DELIVERY_PAD), pad("Agent ID", CRON_AGENT_PAD), pad("Model", CRON_MODEL_PAD), ].join(" "); @@ -261,6 +357,11 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt const statusRaw = formatStatus(job); const statusLabel = pad(statusRaw, CRON_STATUS_PAD); const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD); + const deliveryPreview = opts?.deliveryPreviews?.get(job.id); + const deliveryLabel = pad( + truncate(deliveryPreview?.label ?? "-", CRON_DELIVERY_PAD), + CRON_DELIVERY_PAD, + ); const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD); const modelLabel = pad( truncate( @@ -302,6 +403,9 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt colorize(rich, theme.muted, lastLabel), coloredStatus, coloredTarget, + deliveryPreview + ? colorize(rich, theme.info, deliveryLabel) + : colorize(rich, theme.muted, deliveryLabel), coloredAgent, job.payload.kind === "agentTurn" && job.payload.model ? colorize(rich, theme.info, modelLabel) @@ -311,3 +415,18 @@ export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRunt runtime.log(line.trimEnd()); } } + +export async function printCronShow(job: CronJob, runtime: RuntimeEnv = defaultRuntime) { + const preview = await resolveCronDeliveryPreview(job); + runtime.log(`id: ${job.id}`); + runtime.log(`name: ${job.name}`); + runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`); + runtime.log(`schedule: ${formatSchedule(job.schedule)}`); + runtime.log(`session: ${job.sessionTarget ?? "-"}`); + runtime.log(`agent: ${job.agentId ?? "-"}`); + runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`); + runtime.log(`delivery: ${preview.label} (${preview.detail})`); + runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`); + runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`); + runtime.log(`status: ${formatStatus(job)}`); +} diff --git a/src/cron/delivery-plan.ts b/src/cron/delivery-plan.ts index 2d6f24ec264..e7e4100aa39 100644 --- a/src/cron/delivery-plan.ts +++ b/src/cron/delivery-plan.ts @@ -70,6 +70,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const isIsolatedAgentTurn = job.payload.kind === "agentTurn" && + typeof job.sessionTarget === "string" && (job.sessionTarget === "isolated" || job.sessionTarget === "current" || job.sessionTarget.startsWith("session:"));