feat(cron): script payloads behind the trigger gate (#111112)

Run script payloads through the shared headless code-mode executor with payload-grade budgets and success-only trigger.state persistence.

Reuse cron delivery, wake, pacing, and dangerous trigger-gate contracts for notify, wake, and nextCheck results.
This commit is contained in:
Peter Steinberger
2026-07-18 19:24:12 -07:00
committed by GitHub
parent 80746b06b9
commit 68771ebdfe
46 changed files with 2311 additions and 617 deletions
+33 -7
View File
@@ -43,7 +43,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database, so restarts do not lose schedules.
- Every cron execution creates a [background task](/automation/tasks) record.
- One-shot jobs (`--at`) auto-delete after success by default; pass `--keep-after-run` to keep them.
- Per-run wall-clock budget: `--timeout-seconds` when set. Otherwise, isolated/detached agent-turn jobs are bounded by cron's own 60-minute watchdog before the underlying agent-turn timeout (`agents.defaults.timeoutSeconds`, default 48 hours) would ever apply; command jobs default to 10 minutes.
- Per-run wall-clock budget: `--timeout-seconds` when set. Otherwise, isolated/detached agent-turn jobs are bounded by cron's own 60-minute watchdog before the underlying agent-turn timeout (`agents.defaults.timeoutSeconds`, default 48 hours) would ever apply; command jobs default to 10 minutes, and script payloads default to 5 minutes.
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled instead of replayed immediately, keeping model/tool bootstrap work out of the channel-connect window.
- If you drive `openclaw agent` from system cron or another external scheduler, wrap it with a hard-kill escalation even though the CLI already handles `SIGTERM`/`SIGINT`. Gateway-backed runs ask the Gateway to abort accepted runs; local and embedded fallback runs get the same abort signal. For GNU `timeout`, prefer `timeout -k 60 600 openclaw agent ...` over plain `timeout 600 ...` — the `-k` value is the backstop if the process cannot drain in time. For systemd units, use a `SIGTERM` stop signal with a grace window (`TimeoutStopSec`) before the final kill. Reusing a `--run-id` while the original Gateway run is still active reports the duplicate as in-flight instead of starting a second run.
@@ -119,7 +119,7 @@ The script must return `{ fire, message?, state? }`. The previous JSON state is
Author watchers around **actionable state**, not only success: a watcher that goes quiet when its check fails or times out looks healthy while broken. Compare the observation with `trigger.state` and return fresh state to deduplicate; do not rely on model or process memory. When firing, make `message` self-contained because it becomes the fired run's complete event context.
<Warning>
Enabling `cron.triggers.enabled` lets agent-authored scripts run headlessly with the owning agent's **full tool policy, including `exec`**. Treat this as unattended code execution with that agent's permissions; leave it disabled unless every agent allowed to create cron jobs is trusted accordingly.
Enabling `cron.triggers.enabled` permits both condition-trigger scripts and `script` payloads to run headlessly with the owning agent's **full tool policy, including `exec`**. Treat this as unattended code execution with that agent's permissions; leave it disabled unless every agent allowed to create cron jobs is trusted accordingly.
</Warning>
Create a watcher from a local script file (`-` reads the script from stdin):
@@ -137,11 +137,12 @@ openclaw cron add \
Every job carries exactly one payload kind, chosen by flag:
| Payload | Flag | Runs |
| ------------- | ---------------------------------------------- | ------------------------------------------------------- |
| System event | `--system-event <text>` | Enqueued into the main session, no model call by itself |
| Agent message | `--message <text>` | A model-backed agent turn |
| Command | `--command <shell>` or `--command-argv <json>` | A shell/process on the Gateway host, no model call |
| Payload | Flag | Runs |
| ------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| System event | `--system-event <text>` | Enqueued into the main session, no model call by itself |
| Agent message | `--message <text>` | A model-backed agent turn |
| Command | `--command <shell>` or `--command-argv <json>` | A shell/process on the Gateway host, no model call |
| Script | `--script <file\|->` | A headless code-mode script using the owning agent's tools |
### Agent-turn options
@@ -210,6 +211,31 @@ openclaw cron create "*/15 * * * *" \
Delivered text is derived from process output: non-empty stdout wins; if stdout is empty and stderr is non-empty, stderr is delivered; if both are present, cron sends a small `stdout:` / `stderr:` block. Exit code `0` records the run `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
### Script payloads
Script payloads run headlessly in the same code-mode executor as trigger scripts, without starting a conversational agent turn. Enable `cron.triggers.enabled` before creating or running them; this dangerous-automation gate covers both trigger scripts and script payloads. Script jobs support only `main` and `isolated` session targets.
```bash
openclaw cron create "0 * * * *" \
--name "Hourly queue check" \
--script ./automation/check-queue.js \
--script-timeout-seconds 300 \
--script-tool-budget 50 \
--session isolated \
--announce
```
Use `--script <file|->` to read JavaScript from a file or stdin. The timeout defaults to 300 seconds and is capped at 900; the tool budget defaults to 50 calls and is capped at 200. These payload budgets are separate from the smaller trigger-gate evaluation budgets.
The script may return an object with these optional fields:
- `notify`: Text delivered through the job's `announce`, `webhook`, or `none` delivery mode. If omitted, nothing is delivered. For a `main` job, the text becomes a system event.
- `wake`: `"now"` requests an immediate heartbeat after enqueueing `notify` (or a compact completion event); `"next-heartbeat"` enqueues the event for the next heartbeat.
- `state`: JSON state, capped at 16 KB and persisted only after a successful run. The next run receives a frozen copy as `trigger.state`, matching trigger scripts. Because that namespace has one persisted owner, a script payload cannot be combined with a condition trigger on the same job.
- `nextCheck`: A duration such as `"15m"`. It is valid only for jobs with pacing enabled and uses the same pacing clamp as agent-turn proposals.
Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` without pacing are normal cron run errors: they enter run history, backoff, and failure-alert handling without persisting returned state.
## Execution styles
| Style | `--session` value | Runs in | Best for |
+1
View File
@@ -71,6 +71,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Payloads
- H3: Agent-turn options
- H3: Command payloads
- H3: Script payloads
- H2: Execution styles
- H2: Delivery and output
- H3: Failure notifications
@@ -50,6 +50,17 @@ function cronCommandPayloadSchema(params: { argv: TSchema; toolsAllow: TSchema }
});
}
function cronScriptPayloadSchema(params: { script: TSchema; toolsAllow: TSchema }) {
return closedObject({
kind: Type.Literal("script"),
script: params.script,
timeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })),
toolBudget: Type.Optional(Type.Integer({ minimum: 1 })),
toolsAllow: Type.Optional(params.toolsAllow),
toolsAllowIsDefault: Type.Optional(Type.Boolean()),
});
}
/** Session target accepted by cron jobs. */
const CronSessionTargetSchema = Type.Union([
Type.Literal("main"),
@@ -255,6 +266,10 @@ export const CronPayloadSchema = Type.Union([
argv: Type.Array(NonEmptyString, { minItems: 1 }),
toolsAllow: Type.Array(Type.String()),
}),
cronScriptPayloadSchema({
script: Type.String({ minLength: 1, maxLength: 65_536 }),
toolsAllow: Type.Array(Type.String()),
}),
]);
/** Partial cron payload for job updates. */
@@ -276,6 +291,10 @@ export const CronPayloadPatchSchema = Type.Union([
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
cronScriptPayloadSchema({
script: Type.Optional(Type.String({ minLength: 1, maxLength: 65_536 })),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
]);
/** Failure alert policy for repeated cron run failures. */
+4 -2
View File
@@ -8,7 +8,7 @@ import { isRecord } from "../../utils.js";
import { isStringOption } from "../../utils/string-readers.js";
const CRON_SCHEDULE_KINDS = ["at", "every", "cron", "on-exit"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn", "script"] as const;
const CRON_FLAT_PAYLOAD_KEYS = [
"message",
"text",
@@ -66,7 +66,7 @@ function isCronScheduleKind(value: unknown): value is (typeof CRON_SCHEDULE_KIND
}
function isCronPayloadKind(value: unknown): value is (typeof CRON_PAYLOAD_KINDS)[number] {
return value === "systemEvent" || value === "agentTurn";
return value === "systemEvent" || value === "agentTurn" || value === "script";
}
function isNonEmptyString(value: unknown): value is string {
@@ -247,6 +247,8 @@ function canonicalizeCronToolPayload(value: Record<string, unknown>): void {
(payload.fallbacks !== undefined && isStringArrayOrNull(payload.fallbacks));
if (hasAgentTurnSignal) {
payload.kind = "agentTurn";
} else if (isNonEmptyString(payload.script)) {
payload.kind = "script";
} else if (isNonEmptyString(payload.text)) {
payload.kind = "systemEvent";
}
+7 -1
View File
@@ -64,6 +64,7 @@ function capCronJobToolsAllow(params: {
const writesToolsAllow = Object.hasOwn(params.payload, "toolsAllow");
if (
params.payload.kind !== "agentTurn" &&
params.payload.kind !== "script" &&
!hasCronTriggerScript(params.trigger) &&
!writesToolsAllow
) {
@@ -170,7 +171,12 @@ export function planCronJobUpdatePatch(params: {
const trigger = Object.hasOwn(patch, "trigger") ? patch.trigger : params.currentJob.trigger;
const writesToolsAllow = payload !== undefined && Object.hasOwn(payload, "toolsAllow");
if (payloadKind !== "agentTurn" && !hasCronTriggerScript(trigger) && !writesToolsAllow) {
if (
payloadKind !== "agentTurn" &&
payloadKind !== "script" &&
!hasCronTriggerScript(trigger) &&
!writesToolsAllow
) {
return { kind: "ready", patch };
}
+32 -1
View File
@@ -182,7 +182,7 @@ describe("createCronToolSchema", () => {
);
});
it("job.payload exposes kind, text, message, model, thinking and extras", () => {
it("job.payload exposes conversational and script payload fields", () => {
expect(keysAt(schemaRecord, "job.payload")).toEqual(
[
"allowUnsafeExternalContent",
@@ -191,8 +191,10 @@ describe("createCronToolSchema", () => {
"lightContext",
"message",
"model",
"script",
"text",
"thinking",
"toolBudget",
"toolsAllow",
"timeoutSeconds",
].toSorted(),
@@ -212,14 +214,43 @@ describe("createCronToolSchema", () => {
"lightContext",
"message",
"model",
"script",
"text",
"thinking",
"toolBudget",
"toolsAllow",
"timeoutSeconds",
].toSorted(),
);
});
it("accepts script payloads in create and patch schemas", () => {
expect(
Value.Check(schema, {
action: "add",
job: {
name: "script job",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
},
}),
).toBe(true);
expect(
Value.Check(schema, {
action: "update",
id: "job-1",
patch: { payload: { kind: "script", toolBudget: 75 } },
}),
).toBe(true);
});
it("job.failureAlert exposes after, channel, to, cooldownMs, includeSkipped, mode, accountId", () => {
expect(keysAt(schemaRecord, "job.failureAlert")).toEqual(
["accountId", "after", "channel", "cooldownMs", "includeSkipped", "mode", "to"].toSorted(),
+28
View File
@@ -1123,6 +1123,34 @@ describe("cron tool", () => {
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows script payloads without treating them as shell commands", async () => {
const tool = createTestCronTool();
await tool.execute("call-script-add", {
action: "add",
job: {
name: "script",
schedule: { at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
},
});
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
});
});
it("rejects on-exit schedules from the agent cron tool on add", async () => {
const tool = createTestCronTool();
+5 -2
View File
@@ -77,7 +77,7 @@ const CRON_ACTIONS = [
const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn", "script"] as const;
const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const;
const CRON_RUN_MODES = ["due", "force"] as const;
@@ -128,9 +128,11 @@ function cronPayloadObjectSchema(params: {
kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload kind" }),
text: Type.Optional(Type.String({ description: "systemEvent text" })),
message: Type.Optional(Type.String({ description: "agentTurn prompt" })),
script: Type.Optional(Type.String({ description: "Headless code-mode script" })),
model: params.model,
thinking: Type.Optional(Type.String({ description: "Thinking override" })),
timeoutSeconds: optionalFiniteNumberSchema({ minimum: 0 }),
toolBudget: optionalPositiveIntegerSchema({ description: "Maximum script tool calls" }),
lightContext: Type.Optional(Type.Boolean()),
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
fallbacks: params.fallbacks,
@@ -737,8 +739,9 @@ ADD JOB:
Required: schedule,payload. enabled default true. trigger only every/cron.
TARGET/PAYLOAD:
- main => systemEvent {kind:"systemEvent",text:"..."}; systemEvent defaults main.
- main => systemEvent {kind:"systemEvent",text:"..."} or script; systemEvent defaults main.
- isolated/current/session:<id> => agentTurn {kind:"agentTurn",message:"...",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.
- script {kind:"script",script:"...",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.
- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.
SCHEDULE:
+1 -1
View File
@@ -794,7 +794,7 @@ describe("cron cli", () => {
value,
]);
expectRuntimeErrorContaining(
"--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery",
"--channel, --to, --account, and --thread-id require a non-main agentTurn, command, or script job with delivery",
);
});
+76 -17
View File
@@ -27,7 +27,7 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
import { readCronTriggerScript } from "./trigger-options.js";
import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options.js";
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
@@ -121,6 +121,9 @@ export function registerCronAddCommand(cron: Command) {
.option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--system-event <text>", "System event payload (main session)")
.option("--message <text>", "Agent message payload")
.option("--script <file|->", "Headless script payload file, or - for stdin")
.option("--script-timeout-seconds <n>", "Script wall-clock timeout seconds")
.option("--script-tool-budget <n>", "Maximum script tool calls")
.option("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Working directory for command payloads")
@@ -203,6 +206,7 @@ export function registerCronAddCommand(cron: Command) {
const positionalMessage = normalizeOptionalString(messageArg);
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
const scriptPath = normalizeOptionalString(opts.script);
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
throw new Error(
"Pass the cron job message either positionally or with --message, not both.",
@@ -218,15 +222,35 @@ export function registerCronAddCommand(cron: Command) {
Boolean(systemEvent),
Boolean(message),
Boolean(commandShell) || Boolean(commandArgv),
Boolean(scriptPath),
].filter(Boolean).length;
if (chosen !== 1) {
throw new Error(
"Choose exactly one payload: --system-event, --message, or --command",
"Choose exactly one payload: --system-event, --message, --command, or --script",
);
}
if (systemEvent) {
return { kind: "systemEvent" as const, text: systemEvent };
}
if (scriptPath) {
const scriptTimeoutSeconds = parseStrictPositiveIntOrUndefined(
opts.scriptTimeoutSeconds,
);
if (opts.scriptTimeoutSeconds !== undefined && scriptTimeoutSeconds === undefined) {
throw new Error("Invalid --script-timeout-seconds (must be a positive integer).");
}
const scriptToolBudget = parseStrictPositiveIntOrUndefined(opts.scriptToolBudget);
if (opts.scriptToolBudget !== undefined && scriptToolBudget === undefined) {
throw new Error("Invalid --script-tool-budget (must be a positive integer).");
}
return {
kind: "script" as const,
scriptPath,
timeoutSeconds: scriptTimeoutSeconds,
toolBudget: scriptToolBudget,
toolsAllow: parseCronToolsAllow(opts.tools),
};
}
const timeoutSeconds = parseStrictPositiveIntOrUndefined(opts.timeoutSeconds);
if (opts.timeoutSeconds !== undefined && timeoutSeconds === undefined) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
@@ -280,11 +304,25 @@ export function registerCronAddCommand(cron: Command) {
toolsAllow: parseCronToolsAllow(opts.tools),
};
})();
const resolvedPayload = await (async () => {
if (payload.kind !== "script") {
return payload;
}
const { scriptPath, ...scriptPayload } = payload;
return {
...scriptPayload,
script: await readCronPayloadScript(scriptPath),
};
})();
const sessionSource = cmd.getOptionValueSource("session");
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget =
payload.kind === "agentTurn" || payload.kind === "command" ? "isolated" : "main";
resolvedPayload.kind === "agentTurn" ||
resolvedPayload.kind === "command" ||
resolvedPayload.kind === "script"
? "isolated"
: "main";
const sessionTarget =
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
@@ -302,25 +340,37 @@ export function registerCronAddCommand(cron: Command) {
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
}
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent).");
if (
sessionTarget === "main" &&
resolvedPayload.kind !== "systemEvent" &&
resolvedPayload.kind !== "script"
) {
throw new Error("Main jobs require --system-event or --script.");
}
if (
resolvedPayload.kind === "script" &&
sessionTarget !== "main" &&
sessionTarget !== "isolated"
) {
throw new Error("Script jobs require --session main or --session isolated.");
}
if (
isIsolatedLikeSessionTarget &&
payload.kind !== "agentTurn" &&
payload.kind !== "command"
resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"
) {
throw new Error(
"Isolated/current/custom-session jobs require --message (agentTurn) or --command.",
);
throw new Error("Isolated jobs require --message, --command, or --script.");
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(!isIsolatedLikeSessionTarget ||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
(resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"))
) {
throw new Error(
"--announce/--no-deliver require a non-main agentTurn or command session target.",
"--announce/--no-deliver require a non-main agentTurn, command, or script session target.",
);
}
@@ -336,10 +386,12 @@ export function registerCronAddCommand(cron: Command) {
if (
hasChatDeliveryTarget &&
(!isIsolatedLikeSessionTarget ||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
(resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"))
) {
throw new Error(
"--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.",
"--channel, --to, --account, and --thread-id require a non-main agentTurn, command, or script job with delivery.",
);
}
if (hasWebhook && hasChatDeliveryTarget) {
@@ -349,7 +401,9 @@ export function registerCronAddCommand(cron: Command) {
const deliveryMode = hasWebhook
? "webhook"
: isIsolatedLikeSessionTarget &&
(payload.kind === "agentTurn" || payload.kind === "command")
(resolvedPayload.kind === "agentTurn" ||
resolvedPayload.kind === "command" ||
resolvedPayload.kind === "script")
? hasAnnounce
? "announce"
: hasNoDeliver
@@ -399,7 +453,12 @@ export function registerCronAddCommand(cron: Command) {
}
: undefined;
if ((payload.kind === "agentTurn" || payload.kind === "command") && !agentId) {
if (
(resolvedPayload.kind === "agentTurn" ||
resolvedPayload.kind === "command" ||
resolvedPayload.kind === "script") &&
!agentId
) {
defaultRuntime.error(
theme.warn(
"No --agent specified; the job will run with the configured default agent. " +
@@ -431,7 +490,7 @@ export function registerCronAddCommand(cron: Command) {
trigger,
sessionTarget,
wakeMode,
payload,
payload: resolvedPayload,
delivery: deliveryMode
? {
mode: deliveryMode,
@@ -0,0 +1,278 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { CronJob } from "../../cron/types.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import {
parseCronCommandArgv,
parseCronCommandEnv,
parseCronFallbacks,
parseCronToolsAllow,
} from "./shared.js";
import { parseCronThreadIdOption } from "./thread-id-shared.js";
import { readCronPayloadScript } from "./trigger-options.js";
const assignIf = (
target: Record<string, unknown>,
key: string,
value: unknown,
shouldAssign: boolean,
) => {
if (shouldAssign) {
target[key] = value;
}
};
export async function resolveCronEditPayloadDeliveryPatch(
opts: Record<string, unknown>,
loadExistingJob: () => Promise<CronJob>,
): Promise<Record<string, unknown>> {
const patch: Record<string, unknown> = {};
const hasSystemEventPatch = typeof opts.systemEvent === "string";
const scriptPath = normalizeOptionalString(opts.script);
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
if (commandShell && commandArgv) {
throw new Error("Pass command payload either with --command or --command-argv, not both.");
}
const model = normalizeOptionalString(opts.model);
if (model && opts.clearModel) {
throw new Error("Use --model or --clear-model, not both");
}
const thinking = normalizeOptionalString(opts.thinking);
if (thinking && opts.clearThinking) {
throw new Error("Use --thinking or --clear-thinking, not both");
}
const fallbacks = parseCronFallbacks(opts.fallbacks);
if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
throw new Error("Use --fallbacks or --clear-fallbacks, not both");
}
const toolsAllow = parseCronToolsAllow(opts.tools);
const timeoutSecondsValue = opts.timeoutSeconds;
const rawTimeoutSeconds =
timeoutSecondsValue === undefined
? undefined
: typeof timeoutSecondsValue === "string" || typeof timeoutSecondsValue === "number"
? String(timeoutSecondsValue).trim()
: "";
if (rawTimeoutSeconds !== undefined && !/^\d+$/u.test(rawTimeoutSeconds)) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const timeoutSeconds = rawTimeoutSeconds === undefined ? undefined : Number(rawTimeoutSeconds);
const hasTimeoutSeconds =
typeof timeoutSeconds === "number" &&
Number.isSafeInteger(timeoutSeconds) &&
timeoutSeconds > 0;
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const rawNoOutputTimeoutSeconds =
opts.noOutputTimeoutSeconds ??
(typeof opts.outputTimeoutSeconds === "string" || typeof opts.outputTimeoutSeconds === "number"
? opts.outputTimeoutSeconds
: undefined);
const noOutputTimeoutSeconds = parseStrictPositiveInteger(rawNoOutputTimeoutSeconds);
if (rawNoOutputTimeoutSeconds !== undefined && noOutputTimeoutSeconds === undefined) {
throw new Error("Invalid --no-output-timeout-seconds (must be a positive integer).");
}
const outputMaxBytes = parseStrictPositiveInteger(opts.outputMaxBytes);
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
}
const scriptTimeoutSeconds = parseStrictPositiveInteger(opts.scriptTimeoutSeconds);
if (opts.scriptTimeoutSeconds !== undefined && scriptTimeoutSeconds === undefined) {
throw new Error("Invalid --script-timeout-seconds (must be a positive integer).");
}
const scriptToolBudget = parseStrictPositiveInteger(opts.scriptToolBudget);
if (opts.scriptToolBudget !== undefined && scriptToolBudget === undefined) {
throw new Error("Invalid --script-tool-budget (must be a positive integer).");
}
const hasWebhookDelivery = typeof opts.webhook === "string";
const hasDeliveryModeFlag =
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
const threadId = parseCronThreadIdOption(opts.threadId);
const hasDeliveryThreadId = typeof threadId === "number";
const hasDeliveryTarget =
typeof opts.channel === "string" ||
typeof opts.to === "string" ||
hasDeliveryThreadId ||
Boolean(opts.clearChannel) ||
Boolean(opts.clearTo) ||
Boolean(opts.clearThreadId);
const hasDeliveryAccount = typeof opts.account === "string" || Boolean(opts.clearAccount);
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
throw new Error("--webhook cannot be combined with chat delivery options.");
}
if (typeof opts.channel === "string" && opts.clearChannel) {
throw new Error("Use --channel or --clear-channel, not both");
}
if (typeof opts.to === "string" && opts.clearTo) {
throw new Error("Use --to or --clear-to, not both");
}
if (hasDeliveryThreadId && opts.clearThreadId) {
throw new Error("Use --thread-id or --clear-thread-id, not both");
}
if (typeof opts.account === "string" && opts.clearAccount) {
throw new Error("Use --account or --clear-account, not both");
}
const hasCommandSpecificPayloadField =
Boolean(commandShell) ||
Boolean(commandArgv) ||
typeof opts.commandCwd === "string" ||
typeof opts.commandInput === "string" ||
opts.commandEnv !== undefined ||
noOutputTimeoutSeconds !== undefined ||
outputMaxBytes !== undefined;
let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined;
if (
hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
typeof opts.message !== "string" &&
!model &&
typeof opts.fallbacks !== "string" &&
!opts.clearFallbacks &&
!thinking &&
!opts.clearThinking &&
typeof opts.lightContext !== "boolean" &&
typeof opts.tools !== "string" &&
!Array.isArray(opts.tools) &&
!opts.clearTools
) {
const existing = await loadExistingJob();
timeoutOnlyPayloadKind = existing.payload.kind === "command" ? "command" : "agentTurn";
}
const hasAgentTurnPayloadField =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(opts.clearModel) ||
typeof opts.fallbacks === "string" ||
Boolean(opts.clearFallbacks) ||
Boolean(thinking) ||
Boolean(opts.clearThinking) ||
(hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
timeoutOnlyPayloadKind !== "command") ||
typeof opts.lightContext === "boolean" ||
typeof opts.tools === "string" ||
Array.isArray(opts.tools) ||
opts.clearTools;
const hasCommandPayloadField =
hasCommandSpecificPayloadField ||
(hasTimeoutSeconds && (hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command"));
const hasAgentTurnPatch = hasAgentTurnPayloadField;
const hasCommandPatch = hasCommandPayloadField;
const hasScriptPatch =
Boolean(scriptPath) || scriptTimeoutSeconds !== undefined || scriptToolBudget !== undefined;
if (
[hasSystemEventPatch, hasAgentTurnPatch, hasCommandPatch, hasScriptPatch].filter(Boolean)
.length > 1
) {
throw new Error("Choose at most one payload change");
}
if (hasSystemEventPatch) {
patch.payload = {
kind: "systemEvent",
text: String(opts.systemEvent),
};
} else if (hasAgentTurnPatch) {
const payload: Record<string, unknown> = { kind: "agentTurn" };
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
if (opts.clearModel) {
payload.model = null;
} else {
assignIf(payload, "model", model, Boolean(model));
}
assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
if (opts.clearThinking) {
payload.thinking = null;
} else {
assignIf(payload, "thinking", thinking, Boolean(thinking));
}
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(payload, "lightContext", opts.lightContext, typeof opts.lightContext === "boolean");
if (opts.clearTools) {
payload.toolsAllow = null;
} else if (toolsAllow) {
payload.toolsAllow = toolsAllow;
}
patch.payload = payload;
} else if (hasCommandPatch) {
const payload: Record<string, unknown> = { kind: "command" };
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
assignIf(
payload,
"cwd",
normalizeOptionalString(opts.commandCwd),
typeof opts.commandCwd === "string",
);
assignIf(payload, "env", parseCronCommandEnv(opts.commandEnv), opts.commandEnv !== undefined);
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
payload,
"noOutputTimeoutSeconds",
noOutputTimeoutSeconds,
noOutputTimeoutSeconds !== undefined,
);
assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined);
patch.payload = payload;
} else if (hasScriptPatch) {
const payload: Record<string, unknown> = { kind: "script" };
if (scriptPath) {
payload.script = await readCronPayloadScript(scriptPath);
}
assignIf(payload, "timeoutSeconds", scriptTimeoutSeconds, scriptTimeoutSeconds !== undefined);
assignIf(payload, "toolBudget", scriptToolBudget, scriptToolBudget !== undefined);
patch.payload = payload;
}
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
const delivery: Record<string, unknown> = {};
if (hasDeliveryModeFlag) {
delivery.mode = hasWebhookDelivery
? "webhook"
: opts.announce || opts.deliver === true
? "announce"
: "none";
} else if (opts.bestEffortDeliver === true) {
// Back-compat: enabling best-effort historically implied announce mode.
delivery.mode = "announce";
}
if (opts.clearChannel) {
delivery.channel = null;
} else if (typeof opts.channel === "string") {
const channel = opts.channel.trim();
delivery.channel = channel ? channel : undefined;
}
if (hasWebhookDelivery) {
const webhook = normalizeOptionalString(opts.webhook) ?? "";
delivery.to = webhook ? webhook : undefined;
} else if (opts.clearTo) {
delivery.to = null;
} else if (typeof opts.to === "string") {
const to = opts.to.trim();
delivery.to = to ? to : undefined;
}
if (opts.clearThreadId) {
delivery.threadId = null;
} else if (hasDeliveryThreadId) {
delivery.threadId = threadId;
}
if (opts.clearAccount) {
delivery.accountId = null;
} else if (typeof opts.account === "string") {
const account = opts.account.trim();
delivery.accountId = account ? account : undefined;
}
if (typeof opts.bestEffortDeliver === "boolean") {
delivery.bestEffort = opts.bestEffortDeliver;
}
patch.delivery = delivery;
}
return patch;
}
+17 -249
View File
@@ -11,36 +11,22 @@ import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js";
import {
applyExistingCronSchedulePatch,
resolveCronEditScheduleRequest,
} from "./schedule-options.js";
import {
getCronChannelOptions,
parseCronCommandArgv,
parseCronCommandEnv,
parseCronFallbacks,
parseCronToolsAllow,
parsePositiveCronDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
import { normalizeCronSessionTargetOption } from "./thread-id-shared.js";
import { readCronTriggerScript } from "./trigger-options.js";
const CRON_EDIT_LOOKUP_PAGE_SIZE = 200;
const CRON_EDIT_LOOKUP_MAX_PAGES = 50;
const assignIf = (
target: Record<string, unknown>,
key: string,
value: unknown,
shouldAssign: boolean,
) => {
if (shouldAssign) {
target[key] = value;
}
};
function isUnknownCronGetMethodError(error: unknown): error is Error {
return (
error instanceof Error &&
@@ -128,6 +114,9 @@ export function registerCronEditCommand(cron: Command) {
.option("--clear-trigger", "Remove the condition trigger", false)
.option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message")
.option("--script <file|->", "Set headless script payload from file, or - for stdin")
.option("--script-timeout-seconds <n>", "Set script wall-clock timeout seconds")
.option("--script-tool-budget <n>", "Set maximum script tool calls")
.option("--command <shell>", "Set command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Set command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Set command payload working directory")
@@ -208,6 +197,12 @@ export function registerCronEditCommand(cron: Command) {
"Main jobs cannot use --message or --command; use --system-event or --session isolated.",
);
}
if (
(sessionTarget === "current" || sessionTarget?.startsWith("session:")) &&
typeof opts.script === "string"
) {
throw new Error("Script jobs require --session main or --session isolated.");
}
if (
(sessionTarget === "isolated" ||
sessionTarget === "current" ||
@@ -364,239 +359,12 @@ export function registerCronEditCommand(cron: Command) {
patch.schedule = applyExistingCronSchedulePatch(existing.schedule, scheduleRequest);
}
const hasSystemEventPatch = typeof opts.systemEvent === "string";
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
if (commandShell && commandArgv) {
throw new Error(
"Pass command payload either with --command or --command-argv, not both.",
);
}
const model = normalizeOptionalString(opts.model);
if (model && opts.clearModel) {
throw new Error("Use --model or --clear-model, not both");
}
const thinking = normalizeOptionalString(opts.thinking);
if (thinking && opts.clearThinking) {
throw new Error("Use --thinking or --clear-thinking, not both");
}
const fallbacks = parseCronFallbacks(opts.fallbacks);
if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
throw new Error("Use --fallbacks or --clear-fallbacks, not both");
}
const toolsAllow = parseCronToolsAllow(opts.tools);
const rawTimeoutSeconds =
opts.timeoutSeconds === undefined ? undefined : String(opts.timeoutSeconds).trim();
if (rawTimeoutSeconds !== undefined && !/^\d+$/u.test(rawTimeoutSeconds)) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const timeoutSeconds =
rawTimeoutSeconds === undefined ? undefined : Number(rawTimeoutSeconds);
const hasTimeoutSeconds =
typeof timeoutSeconds === "number" &&
Number.isSafeInteger(timeoutSeconds) &&
timeoutSeconds > 0;
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const rawNoOutputTimeoutSeconds =
opts.noOutputTimeoutSeconds ??
(typeof opts.outputTimeoutSeconds === "string" ||
typeof opts.outputTimeoutSeconds === "number"
? opts.outputTimeoutSeconds
: undefined);
const noOutputTimeoutSeconds = parseStrictPositiveInteger(rawNoOutputTimeoutSeconds);
if (rawNoOutputTimeoutSeconds !== undefined && noOutputTimeoutSeconds === undefined) {
throw new Error("Invalid --no-output-timeout-seconds (must be a positive integer).");
}
const outputMaxBytes = parseStrictPositiveInteger(opts.outputMaxBytes);
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
}
const hasDeliveryModeFlag =
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
const threadId = parseCronThreadIdOption(opts.threadId);
const hasDeliveryThreadId = typeof threadId === "number";
const hasDeliveryTarget =
typeof opts.channel === "string" ||
typeof opts.to === "string" ||
hasDeliveryThreadId ||
Boolean(opts.clearChannel) ||
Boolean(opts.clearTo) ||
Boolean(opts.clearThreadId);
const hasDeliveryAccount = typeof opts.account === "string" || Boolean(opts.clearAccount);
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
throw new Error("--webhook cannot be combined with chat delivery options.");
}
if (typeof opts.channel === "string" && opts.clearChannel) {
throw new Error("Use --channel or --clear-channel, not both");
}
if (typeof opts.to === "string" && opts.clearTo) {
throw new Error("Use --to or --clear-to, not both");
}
if (hasDeliveryThreadId && opts.clearThreadId) {
throw new Error("Use --thread-id or --clear-thread-id, not both");
}
if (typeof opts.account === "string" && opts.clearAccount) {
throw new Error("Use --account or --clear-account, not both");
}
const hasCommandSpecificPayloadField =
Boolean(commandShell) ||
Boolean(commandArgv) ||
typeof opts.commandCwd === "string" ||
typeof opts.commandInput === "string" ||
opts.commandEnv !== undefined ||
noOutputTimeoutSeconds !== undefined ||
outputMaxBytes !== undefined;
let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined;
if (
hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
typeof opts.message !== "string" &&
!model &&
typeof opts.fallbacks !== "string" &&
!opts.clearFallbacks &&
!thinking &&
!opts.clearThinking &&
typeof opts.lightContext !== "boolean" &&
typeof opts.tools !== "string" &&
!Array.isArray(opts.tools) &&
!opts.clearTools
) {
const existing = await readCronJobForEdit(opts, String(id));
timeoutOnlyPayloadKind = existing.payload.kind === "command" ? "command" : "agentTurn";
}
const hasAgentTurnPayloadField =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(opts.clearModel) ||
typeof opts.fallbacks === "string" ||
Boolean(opts.clearFallbacks) ||
Boolean(thinking) ||
Boolean(opts.clearThinking) ||
(hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
timeoutOnlyPayloadKind !== "command") ||
typeof opts.lightContext === "boolean" ||
typeof opts.tools === "string" ||
Array.isArray(opts.tools) ||
opts.clearTools;
const hasCommandPayloadField =
hasCommandSpecificPayloadField ||
(hasTimeoutSeconds &&
(hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command"));
const hasAgentTurnPatch = hasAgentTurnPayloadField;
const hasCommandPatch = hasCommandPayloadField;
if (
[hasSystemEventPatch, hasAgentTurnPatch, hasCommandPatch].filter(Boolean).length > 1
) {
throw new Error("Choose at most one payload change");
}
if (hasSystemEventPatch) {
patch.payload = {
kind: "systemEvent",
text: String(opts.systemEvent),
};
} else if (hasAgentTurnPatch) {
const payload: Record<string, unknown> = { kind: "agentTurn" };
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
if (opts.clearModel) {
payload.model = null;
} else {
assignIf(payload, "model", model, Boolean(model));
}
assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
if (opts.clearThinking) {
payload.thinking = null;
} else {
assignIf(payload, "thinking", thinking, Boolean(thinking));
}
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
payload,
"lightContext",
opts.lightContext,
typeof opts.lightContext === "boolean",
);
if (opts.clearTools) {
payload.toolsAllow = null;
} else if (toolsAllow) {
payload.toolsAllow = toolsAllow;
}
patch.payload = payload;
} else if (hasCommandPatch) {
const payload: Record<string, unknown> = { kind: "command" };
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
assignIf(
payload,
"cwd",
normalizeOptionalString(opts.commandCwd),
typeof opts.commandCwd === "string",
);
assignIf(
payload,
"env",
parseCronCommandEnv(opts.commandEnv),
opts.commandEnv !== undefined,
);
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
payload,
"noOutputTimeoutSeconds",
noOutputTimeoutSeconds,
noOutputTimeoutSeconds !== undefined,
);
assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined);
patch.payload = payload;
}
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
const delivery: Record<string, unknown> = {};
if (hasDeliveryModeFlag) {
delivery.mode = hasWebhookDelivery
? "webhook"
: opts.announce || opts.deliver === true
? "announce"
: "none";
} else if (opts.bestEffortDeliver === true) {
// Back-compat: enabling best-effort historically implied announce mode.
delivery.mode = "announce";
}
if (opts.clearChannel) {
delivery.channel = null;
} else if (typeof opts.channel === "string") {
const channel = opts.channel.trim();
delivery.channel = channel ? channel : undefined;
}
if (hasWebhookDelivery) {
const webhook = normalizeOptionalString(opts.webhook) ?? "";
delivery.to = webhook ? webhook : undefined;
} else if (opts.clearTo) {
delivery.to = null;
} else if (typeof opts.to === "string") {
const to = opts.to.trim();
delivery.to = to ? to : undefined;
}
if (opts.clearThreadId) {
delivery.threadId = null;
} else if (hasDeliveryThreadId) {
delivery.threadId = threadId;
}
if (opts.clearAccount) {
delivery.accountId = null;
} else if (typeof opts.account === "string") {
const account = opts.account.trim();
delivery.accountId = account ? account : undefined;
}
if (typeof opts.bestEffortDeliver === "boolean") {
delivery.bestEffort = opts.bestEffortDeliver;
}
patch.delivery = delivery;
}
Object.assign(
patch,
await resolveCronEditPayloadDeliveryPatch(opts, () =>
readCronJobForEdit(opts, String(id)),
),
);
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";
const hasFailureAlertChannel = typeof opts.failureAlertChannel === "string";
+94 -1
View File
@@ -18,7 +18,7 @@ vi.mock("../gateway-rpc.js", async () => {
const { registerCronAddCommand } = await import("./register.cron-add.js");
const { registerCronEditCommand } = await import("./register.cron-edit.js");
const { readCronTriggerScript } = await import("./trigger-options.js");
const { readCronPayloadScript, readCronTriggerScript } = await import("./trigger-options.js");
describe("cron trigger CLI options", () => {
let fixtureRoot = "";
@@ -79,6 +79,87 @@ describe("cron trigger CLI options", () => {
);
});
it("reads --script client-side and sends payload budgets on add", async () => {
const scriptPath = path.join(fixtureRoot, "job.js");
await fs.writeFile(scriptPath, " return { notify: 'done' } \n", "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
await program.parseAsync(
[
"add",
"--name",
"script job",
"--every",
"30s",
"--script",
scriptPath,
"--script-timeout-seconds",
"450",
"--script-tool-budget",
"75",
"--session",
"isolated",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.add",
expect.objectContaining({
script: scriptPath,
scriptTimeoutSeconds: "450",
scriptToolBudget: "75",
}),
expect.objectContaining({
sessionTarget: "isolated",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 450,
toolBudget: 75,
},
}),
);
});
it("reads script payload updates client-side", async () => {
const scriptPath = path.join(fixtureRoot, "edit-job.js");
await fs.writeFile(scriptPath, "return { state: { ok: true } }\n", "utf8");
const program = new Command().exitOverride();
registerCronEditCommand(program);
await program.parseAsync(
[
"edit",
"job-1",
"--script",
scriptPath,
"--script-timeout-seconds",
"600",
"--script-tool-budget",
"100",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ script: scriptPath }),
{
id: "job-1",
patch: {
payload: {
kind: "script",
script: "return { state: { ok: true } }",
timeoutSeconds: 600,
toolBudget: 100,
},
},
},
);
});
it("sends pacing bounds on add", async () => {
const program = new Command().exitOverride();
registerCronAddCommand(program);
@@ -116,6 +197,18 @@ describe("cron trigger CLI options", () => {
await expect(readCronTriggerScript(scriptPath)).resolves.toHaveLength(65_536);
});
it("uses the same size and empty-input validation for payload scripts", async () => {
const atLimitPath = path.join(fixtureRoot, "payload-at-limit.js");
const emptyPath = path.join(fixtureRoot, "payload-empty.js");
await fs.writeFile(atLimitPath, "x".repeat(65_536), "utf8");
await fs.writeFile(emptyPath, " \n", "utf8");
await expect(readCronPayloadScript(atLimitPath)).resolves.toHaveLength(65_536);
await expect(readCronPayloadScript(emptyPath)).rejects.toThrow(
"Script payload must not be empty",
);
});
it("stops oversized trigger script files before the gateway call", async () => {
const scriptPath = path.join(fixtureRoot, "oversized.js");
await fs.writeFile(scriptPath, "x".repeat(65_537), "utf8");
+17 -3
View File
@@ -4,10 +4,10 @@ import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-w
const MAX_CRON_TRIGGER_SCRIPT_BYTES = 65_536;
async function readTriggerScriptStream(stream: AsyncIterable<unknown>): Promise<string> {
async function readScriptStream(stream: AsyncIterable<unknown>, label: string): Promise<string> {
const bytes = await readByteStreamWithLimit(stream, {
maxBytes: MAX_CRON_TRIGGER_SCRIPT_BYTES,
onOverflow: () => new Error(`Trigger script exceeds ${MAX_CRON_TRIGGER_SCRIPT_BYTES} bytes`),
onOverflow: () => new Error(`${label} exceeds ${MAX_CRON_TRIGGER_SCRIPT_BYTES} bytes`),
});
return bytes.toString("utf8");
}
@@ -20,10 +20,24 @@ export async function readCronTriggerScript(
},
): Promise<string> {
const stream = source === "-" ? (deps?.stdin ?? process.stdin) : createReadStream(source);
const raw = await readTriggerScriptStream(stream);
const raw = await readScriptStream(stream, "Trigger script");
const script = raw.trim();
if (!script) {
throw new Error("Trigger script must not be empty");
}
return script;
}
/** Reads a script payload locally before sending the cron RPC. */
export async function readCronPayloadScript(
source: string,
deps?: { stdin?: AsyncIterable<unknown> },
): Promise<string> {
const stream = source === "-" ? (deps?.stdin ?? process.stdin) : createReadStream(source);
const raw = await readScriptStream(stream, "Script payload");
const script = raw.trim();
if (!script) {
throw new Error("Script payload must not be empty");
}
return script;
}
+9 -3
View File
@@ -657,7 +657,9 @@ export function normalizeStoredCronJobs(
}
} else {
const inferredSessionTarget =
payloadKind === "agentTurn" || payloadKind === "command" ? "isolated" : "main";
payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script"
? "isolated"
: "main";
if (raw.sessionTarget !== inferredSessionTarget) {
raw.sessionTarget = inferredSessionTarget;
mutated = true;
@@ -669,14 +671,18 @@ export function normalizeStoredCronJobs(
sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget.startsWith("session:") ||
(sessionTarget === "" && (payloadKind === "agentTurn" || payloadKind === "command"));
(sessionTarget === "" &&
(payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script"));
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
const normalizedLegacy = normalizeLegacyDeliveryInput({
delivery: hasDelivery ? (delivery as Record<string, unknown>) : null,
payload: payloadRecord,
});
if (isIsolatedRunnablePayload && (payloadKind === "agentTurn" || payloadKind === "command")) {
if (
isIsolatedRunnablePayload &&
(payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script")
) {
if (!hasDelivery && normalizedLegacy.delivery) {
raw.delivery = normalizedLegacy.delivery;
mutated = true;
+5 -1
View File
@@ -8,7 +8,11 @@ export function shouldDefaultCronDeliveryToAnnounce(params: {
payloadKind: unknown;
sessionTarget: unknown;
}): boolean {
if (params.payloadKind !== "agentTurn" && params.payloadKind !== "command") {
if (
params.payloadKind !== "agentTurn" &&
params.payloadKind !== "command" &&
params.payloadKind !== "script"
) {
return false;
}
return (
+2
View File
@@ -24,6 +24,7 @@ const cronDeliveryLogger = getChildLogger({ subsystem: "cron-delivery" });
type CronAnnounceTarget = {
channel?: string;
to?: string;
threadId?: string | number;
accountId?: string;
sessionKey?: string;
inheritSessionThread?: boolean;
@@ -55,6 +56,7 @@ async function resolveCronAnnounceDelivery(params: {
{
channel: params.target.channel as CronMessageChannel | undefined,
to: params.target.to,
threadId: params.target.threadId,
accountId: params.target.accountId,
sessionKey: params.target.sessionKey,
},
+262
View File
@@ -0,0 +1,262 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { isRecord } from "../utils.js";
import {
TimeoutSecondsFieldSchema,
TrimmedNonEmptyStringFieldSchema,
parseOptionalField,
} from "./delivery-field-schemas.js";
type UnknownRecord = Record<string, unknown>;
function normalizeTrimmedStringArray(
value: unknown,
options?: { allowNull?: boolean },
): string[] | null | undefined {
if (Array.isArray(value)) {
const normalized = normalizeTrimmedStringList(value);
if (normalized.length === 0 && value.length > 0) {
return undefined;
}
return normalized;
}
if (options?.allowNull && value === null) {
return null;
}
return undefined;
}
function normalizeCommandEnv(value: unknown): Record<string, string> {
if (!isRecord(value)) {
throw new Error("command env must be an object with non-blank keys and string values");
}
const entries: Array<[string, string]> = [];
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey);
if (!key || typeof rawValue !== "string") {
throw new Error("command env must be an object with non-blank keys and string values");
}
entries.push([key, rawValue]);
}
return Object.fromEntries(entries);
}
function normalizeCommandArgv(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.length === 0) {
return undefined;
}
if (value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
return undefined;
}
return [...value];
}
function hasAgentTurnOnlyPayloadHint(payload: UnknownRecord): boolean {
return (
"model" in payload ||
"fallbacks" in payload ||
"thinking" in payload ||
"timeoutSeconds" in payload ||
typeof payload.lightContext === "boolean" ||
typeof payload.allowUnsafeExternalContent === "boolean"
);
}
export function normalizeCronPayload(payload: UnknownRecord): UnknownRecord {
const next: UnknownRecord = { ...payload };
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
if (kindRaw === "agentturn") {
next.kind = "agentTurn";
} else if (kindRaw === "systemevent") {
next.kind = "systemEvent";
} else if (kindRaw === "command") {
next.kind = "command";
} else if (kindRaw === "script") {
next.kind = "script";
} else if (kindRaw) {
next.kind = kindRaw;
}
if (typeof next.message === "string") {
const trimmed = normalizeOptionalString(next.message) ?? "";
if (trimmed) {
next.message = trimmed;
} else {
next.message = "";
}
}
if (typeof next.text === "string") {
const trimmed = normalizeOptionalString(next.text) ?? "";
if (trimmed) {
next.text = trimmed;
} else {
next.text = "";
}
}
if (typeof next.script === "string") {
next.script = next.script.trim();
}
if ("model" in next) {
if (next.model === null) {
next.model = null;
} else {
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
if (model !== undefined) {
next.model = model;
} else {
delete next.model;
}
}
}
if ("thinking" in next) {
// Preserve an explicit null so patches can clear a stored thinking override,
// matching the model/fallbacks/toolsAllow clear paths.
if (next.thinking === null) {
next.thinking = null;
} else {
const thinking = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.thinking);
if (thinking !== undefined) {
next.thinking = thinking;
} else {
delete next.thinking;
}
}
}
if ("timeoutSeconds" in next) {
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
if (timeoutSeconds !== undefined) {
next.timeoutSeconds = timeoutSeconds;
} else {
delete next.timeoutSeconds;
}
}
if ("fallbacks" in next) {
const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
if (fallbacks !== undefined) {
next.fallbacks = fallbacks;
} else {
delete next.fallbacks;
}
}
if ("toolsAllow" in next) {
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
if (toolsAllow !== undefined) {
next.toolsAllow = toolsAllow;
} else {
delete next.toolsAllow;
}
}
if ("argv" in next) {
const argv = normalizeCommandArgv(next.argv);
if (Array.isArray(argv) && argv.length > 0) {
next.argv = argv;
} else {
delete next.argv;
}
}
if ("cwd" in next) {
const cwd = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.cwd);
if (cwd !== undefined) {
next.cwd = cwd;
} else {
delete next.cwd;
}
}
if ("env" in next) {
next.env = normalizeCommandEnv(next.env);
}
if ("input" in next && typeof next.input !== "string") {
delete next.input;
}
if ("noOutputTimeoutSeconds" in next) {
const noOutputTimeoutSeconds = parseOptionalField(
TimeoutSecondsFieldSchema,
next.noOutputTimeoutSeconds,
);
if (noOutputTimeoutSeconds !== undefined) {
next.noOutputTimeoutSeconds = noOutputTimeoutSeconds;
} else {
delete next.noOutputTimeoutSeconds;
}
}
if ("outputMaxBytes" in next) {
const outputMaxBytes = parseOptionalField(TimeoutSecondsFieldSchema, next.outputMaxBytes);
if (outputMaxBytes !== undefined && outputMaxBytes > 0) {
next.outputMaxBytes = Math.floor(outputMaxBytes);
} else {
delete next.outputMaxBytes;
}
}
if ("toolBudget" in next) {
const toolBudget = parseOptionalField(TimeoutSecondsFieldSchema, next.toolBudget);
if (toolBudget !== undefined && toolBudget > 0) {
next.toolBudget = Math.floor(toolBudget);
} else {
delete next.toolBudget;
}
}
if (
"allowUnsafeExternalContent" in next &&
typeof next.allowUnsafeExternalContent !== "boolean"
) {
delete next.allowUnsafeExternalContent;
}
if (!("kind" in next) && typeof next.text === "string" && hasAgentTurnOnlyPayloadHint(next)) {
next.kind = "agentTurn";
next.message = next.text;
}
if (next.kind === "systemEvent") {
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.timeoutSeconds;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
delete next.script;
delete next.toolBudget;
} else if (next.kind === "agentTurn") {
delete next.text;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
delete next.script;
delete next.toolBudget;
} else if (next.kind === "command") {
delete next.text;
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.script;
delete next.toolBudget;
} else if (next.kind === "script") {
delete next.text;
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
}
return next;
}
+4 -225
View File
@@ -6,16 +6,11 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { sanitizeAgentId } from "../routing/session-key.js";
import { isRecord } from "../utils.js";
import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js";
import {
TimeoutSecondsFieldSchema,
TrimmedNonEmptyStringFieldSchema,
parseDeliveryInput,
parseOptionalField,
} from "./delivery-field-schemas.js";
import { parseDeliveryInput } from "./delivery-field-schemas.js";
import { normalizeCronPayload } from "./normalize-payload.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import { inferCronJobName } from "./service/normalize.js";
@@ -38,59 +33,6 @@ const DEFAULT_OPTIONS: NormalizeOptions = {
applyDefaults: false,
};
function normalizeTrimmedStringArray(
value: unknown,
options?: { allowNull?: boolean },
): string[] | null | undefined {
if (Array.isArray(value)) {
const normalized = normalizeTrimmedStringList(value);
if (normalized.length === 0 && value.length > 0) {
return undefined;
}
return normalized;
}
if (options?.allowNull && value === null) {
return null;
}
return undefined;
}
function normalizeCommandEnv(value: unknown): Record<string, string> {
if (!isRecord(value)) {
throw new Error("command env must be an object with non-blank keys and string values");
}
const entries: Array<[string, string]> = [];
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey);
if (!key || typeof rawValue !== "string") {
throw new Error("command env must be an object with non-blank keys and string values");
}
entries.push([key, rawValue]);
}
return Object.fromEntries(entries);
}
function normalizeCommandArgv(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.length === 0) {
return undefined;
}
if (value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
return undefined;
}
return [...value];
}
function hasAgentTurnOnlyPayloadHint(payload: UnknownRecord): boolean {
return (
"model" in payload ||
"fallbacks" in payload ||
"thinking" in payload ||
"timeoutSeconds" in payload ||
typeof payload.lightContext === "boolean" ||
typeof payload.allowUnsafeExternalContent === "boolean"
);
}
function coerceSchedule(schedule: UnknownRecord) {
const next: UnknownRecord = { ...schedule };
const rawKind = normalizeLowercaseStringOrEmpty(schedule.kind);
@@ -188,169 +130,6 @@ function coerceSchedule(schedule: UnknownRecord) {
return next;
}
function coercePayload(payload: UnknownRecord) {
const next: UnknownRecord = { ...payload };
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
if (kindRaw === "agentturn") {
next.kind = "agentTurn";
} else if (kindRaw === "systemevent") {
next.kind = "systemEvent";
} else if (kindRaw === "command") {
next.kind = "command";
} else if (kindRaw) {
next.kind = kindRaw;
}
if (typeof next.message === "string") {
const trimmed = normalizeOptionalString(next.message) ?? "";
if (trimmed) {
next.message = trimmed;
} else {
next.message = "";
}
}
if (typeof next.text === "string") {
const trimmed = normalizeOptionalString(next.text) ?? "";
if (trimmed) {
next.text = trimmed;
} else {
next.text = "";
}
}
if ("model" in next) {
if (next.model === null) {
next.model = null;
} else {
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
if (model !== undefined) {
next.model = model;
} else {
delete next.model;
}
}
}
if ("thinking" in next) {
// Preserve an explicit null so patches can clear a stored thinking override,
// matching the model/fallbacks/toolsAllow clear paths.
if (next.thinking === null) {
next.thinking = null;
} else {
const thinking = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.thinking);
if (thinking !== undefined) {
next.thinking = thinking;
} else {
delete next.thinking;
}
}
}
if ("timeoutSeconds" in next) {
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
if (timeoutSeconds !== undefined) {
next.timeoutSeconds = timeoutSeconds;
} else {
delete next.timeoutSeconds;
}
}
if ("fallbacks" in next) {
const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
if (fallbacks !== undefined) {
next.fallbacks = fallbacks;
} else {
delete next.fallbacks;
}
}
if ("toolsAllow" in next) {
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
if (toolsAllow !== undefined) {
next.toolsAllow = toolsAllow;
} else {
delete next.toolsAllow;
}
}
if ("argv" in next) {
const argv = normalizeCommandArgv(next.argv);
if (Array.isArray(argv) && argv.length > 0) {
next.argv = argv;
} else {
delete next.argv;
}
}
if ("cwd" in next) {
const cwd = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.cwd);
if (cwd !== undefined) {
next.cwd = cwd;
} else {
delete next.cwd;
}
}
if ("env" in next) {
next.env = normalizeCommandEnv(next.env);
}
if ("input" in next && typeof next.input !== "string") {
delete next.input;
}
if ("noOutputTimeoutSeconds" in next) {
const noOutputTimeoutSeconds = parseOptionalField(
TimeoutSecondsFieldSchema,
next.noOutputTimeoutSeconds,
);
if (noOutputTimeoutSeconds !== undefined) {
next.noOutputTimeoutSeconds = noOutputTimeoutSeconds;
} else {
delete next.noOutputTimeoutSeconds;
}
}
if ("outputMaxBytes" in next) {
const outputMaxBytes = parseOptionalField(TimeoutSecondsFieldSchema, next.outputMaxBytes);
if (outputMaxBytes !== undefined && outputMaxBytes > 0) {
next.outputMaxBytes = Math.floor(outputMaxBytes);
} else {
delete next.outputMaxBytes;
}
}
if (
"allowUnsafeExternalContent" in next &&
typeof next.allowUnsafeExternalContent !== "boolean"
) {
delete next.allowUnsafeExternalContent;
}
if (!("kind" in next) && typeof next.text === "string" && hasAgentTurnOnlyPayloadHint(next)) {
next.kind = "agentTurn";
next.message = next.text;
}
if (next.kind === "systemEvent") {
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.timeoutSeconds;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
} else if (next.kind === "agentTurn") {
delete next.text;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
} else if (next.kind === "command") {
delete next.text;
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
}
return next;
}
function coerceTrigger(trigger: UnknownRecord): UnknownRecord {
const script = typeof trigger.script === "string" ? trigger.script.trim() : "";
const once = parseBoolean(trigger.once);
@@ -619,7 +398,7 @@ export function normalizeCronJobInput(
}
if (isRecord(base.payload)) {
next.payload = coercePayload(base.payload);
next.payload = normalizeCronPayload(base.payload);
}
if ("trigger" in base) {
@@ -671,7 +450,7 @@ export function normalizeCronJobInput(
// turns isolate by default to avoid unbounded token accumulation.
if (kind === "systemEvent") {
next.sessionTarget = "main";
} else if (kind === "agentTurn" || kind === "command") {
} else if (kind === "agentTurn" || kind === "command" || kind === "script") {
next.sessionTarget = "isolated";
}
}
+12 -1
View File
@@ -85,7 +85,12 @@ export function getInvalidPersistedCronJobReason(
}
const payloadRecord = payload as Record<string, unknown>;
const payloadKind = payloadRecord.kind;
if (payloadKind !== "systemEvent" && payloadKind !== "agentTurn" && payloadKind !== "command") {
if (
payloadKind !== "systemEvent" &&
payloadKind !== "agentTurn" &&
payloadKind !== "command" &&
payloadKind !== "script"
) {
return "invalid-payload";
}
if (payloadKind === "systemEvent") {
@@ -110,5 +115,11 @@ export function getInvalidPersistedCronJobReason(
return "invalid-payload";
}
}
if (payloadKind === "script") {
const script = payloadRecord.script;
if (typeof script !== "string" || script.trim().length === 0) {
return "invalid-payload";
}
}
return null;
}
+23
View File
@@ -16,4 +16,27 @@ describe("toPublicCronJob", () => {
expect(publicJob.state.pacedNextRunAtMs).toBeUndefined();
expect(job.state.pacedNextRunAtMs).toBe(2_000);
});
it("projects script payload fields without exposing scheduler-only state", () => {
const job = makeCronJob({
sessionTarget: "isolated",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
state: { triggerState: { revision: 1 }, pacedNextRunAtMs: 2_000 },
});
expect(toPublicCronJob(job)).toMatchObject({
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
state: { triggerState: { revision: 1 } },
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { CronPayload } from "./types.js";
export const DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS = 300;
export const MAX_CRON_SCRIPT_TIMEOUT_SECONDS = 900;
export const DEFAULT_CRON_SCRIPT_TOOL_BUDGET = 50;
export const MAX_CRON_SCRIPT_TOOL_BUDGET = 200;
function clampPositiveInteger(value: unknown, fallback: number, maximum: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.min(maximum, Math.max(1, Math.floor(value)));
}
/** Applies the persisted defaults and hard caps for unattended script payloads. */
export function normalizeCronScriptPayload(
payload: Extract<CronPayload, { kind: "script" }>,
): Extract<CronPayload, { kind: "script" }> {
return {
...payload,
script: payload.script.trim(),
timeoutSeconds: clampPositiveInteger(
payload.timeoutSeconds,
DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS,
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
),
toolBudget: clampPositiveInteger(
payload.toolBudget,
DEFAULT_CRON_SCRIPT_TOOL_BUDGET,
MAX_CRON_SCRIPT_TOOL_BUDGET,
),
};
}
+117 -1
View File
@@ -817,15 +817,131 @@ describe("applyJobPatch", () => {
});
});
function createMockState(now: number, opts?: { defaultAgentId?: string }): CronServiceState {
function createMockState(
now: number,
opts?: { defaultAgentId?: string; scriptPayloadsEnabled?: boolean },
): CronServiceState {
return {
deps: {
nowMs: () => now,
defaultAgentId: opts?.defaultAgentId,
cronConfig:
opts?.scriptPayloadsEnabled === undefined
? undefined
: { triggers: { enabled: opts.scriptPayloadsEnabled } },
},
} as unknown as CronServiceState;
}
describe("script payload validation", () => {
const now = Date.parse("2026-07-18T12:00:00.000Z");
const input = (sessionTarget: CronJob["sessionTarget"] = "isolated") => ({
name: "script-job",
enabled: true,
schedule: { kind: "every" as const, everyMs: 60_000 },
sessionTarget,
wakeMode: "now" as const,
payload: {
kind: "script" as const,
script: "return { state: { count: 1 } }",
timeoutSeconds: 4_000,
toolBudget: 4_000,
},
});
it("rejects creation while the trigger gate is disabled", () => {
expect(() =>
createJob(createMockState(now, { scriptPayloadsEnabled: false }), input()),
).toThrow("cron.triggers.enabled=true");
});
it.each(["current", "session:reporting"] as const)(
"rejects the %s session target",
(sessionTarget) => {
expect(() =>
createJob(createMockState(now, { scriptPayloadsEnabled: true }), input(sessionTarget)),
).toThrow('sessionTarget="main" or "isolated"');
},
);
it("persists defaults and hard caps when creation is enabled", () => {
const job = createJob(createMockState(now, { scriptPayloadsEnabled: true }), input());
expect(job.payload).toMatchObject({
kind: "script",
timeoutSeconds: 900,
toolBudget: 200,
});
expect(job.delivery).toEqual({ mode: "announce" });
});
it("allows a main-session script for a named agent", () => {
const job = createJob(
createMockState(now, { defaultAgentId: "main", scriptPayloadsEnabled: true }),
{ ...input("main"), agentId: "reporter" },
);
expect(job.sessionTarget).toBe("main");
expect(job.agentId).toBe("reporter");
});
it("rejects condition triggers because both script kinds own trigger.state", () => {
const state = createMockState(now, { scriptPayloadsEnabled: true });
expect(() =>
createJob(state, {
...input(),
trigger: { script: "return { fire: true }" },
}),
).toThrow("cannot be combined with a condition trigger");
const job = createJob(state, input());
expect(() =>
applyJobPatch(
job,
{ trigger: { script: "return { fire: true }" } },
{ cronConfig: { triggers: { enabled: true } } },
),
).toThrow("cannot be combined with a condition trigger");
});
it("rejects converting a job to script while disabled and caps enabled patches", () => {
const base = createJob(createMockState(now), {
name: "agent-job",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "run" },
});
expect(() =>
applyJobPatch(
structuredClone(base),
{ payload: { kind: "script", script: "return {}" } },
{ cronConfig: { triggers: { enabled: false } } },
),
).toThrow("cron.triggers.enabled=true");
const patched = structuredClone(base);
applyJobPatch(
patched,
{
payload: {
kind: "script",
script: "return {}",
timeoutSeconds: 9_000,
toolBudget: 9_000,
},
},
{ cronConfig: { triggers: { enabled: true } } },
);
expect(patched.payload).toMatchObject({
kind: "script",
timeoutSeconds: 900,
toolBudget: 200,
});
});
});
describe("createJob rejects sessionTarget main for non-default agents", () => {
const now = Date.parse("2026-02-28T12:00:00.000Z");
@@ -45,6 +45,7 @@ describe("resolveInitialCronDelivery", () => {
const payloads: CronJobCreate["payload"][] = [
{ kind: "agentTurn", message: "hello" },
{ kind: "command", argv: ["echo", "hello"] },
{ kind: "script", script: "return { notify: 'hello' }" },
];
for (const payload of payloads) {
expect(resolveInitialCronDelivery(createInput({ sessionTarget, payload }))).toEqual({
+70 -7
View File
@@ -17,6 +17,7 @@ import {
computeNextRunAtMs,
computePreviousRunAtMs,
} from "../schedule.js";
import { normalizeCronScriptPayload } from "../script-payload.js";
import { assertSafeCronSessionTargetId } from "../session-target.js";
import {
normalizeCronStaggerMs,
@@ -315,12 +316,50 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
if (job.sessionTarget.startsWith("session:")) {
assertSafeCronSessionTargetId(job.sessionTarget.slice(8));
}
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
throw new Error('main cron jobs require payload.kind="systemEvent"');
if (
job.sessionTarget === "main" &&
job.payload.kind !== "systemEvent" &&
job.payload.kind !== "script"
) {
throw new Error('main cron jobs require payload.kind="systemEvent" or "script"');
}
if (isIsolatedLike && job.payload.kind !== "agentTurn" && job.payload.kind !== "command") {
if (
job.payload.kind === "script" &&
job.sessionTarget !== "main" &&
job.sessionTarget !== "isolated"
) {
throw new Error('script cron jobs require sessionTarget="main" or "isolated"');
}
if (
isIsolatedLike &&
job.payload.kind !== "agentTurn" &&
job.payload.kind !== "command" &&
!(job.sessionTarget === "isolated" && job.payload.kind === "script")
) {
throw new Error(
'isolated/current/session cron jobs require payload.kind="agentTurn" or "command"',
'isolated cron jobs require payload.kind="agentTurn", "command", or "script"; script payloads do not support current/session targets',
);
}
}
function assertScriptPayloadSupport(
job: Pick<CronJob, "payload" | "trigger">,
opts?: { cronConfig?: CronConfig; requireEnabled?: boolean },
) {
if (job.payload.kind !== "script") {
return;
}
if (!job.payload.script.trim()) {
throw new Error("cron script payload must not be empty");
}
if (job.trigger) {
// Both script kinds expose trigger.state, so composing them would give one
// persisted state slot two owners and make the next trigger run ambiguous.
throw new Error("cron script payloads cannot be combined with a condition trigger");
}
if (opts?.requireEnabled && opts.cronConfig?.triggers?.enabled !== true) {
throw new Error(
"cron script payloads are disabled; set cron.triggers.enabled=true to allow unattended scripts",
);
}
}
@@ -357,7 +396,7 @@ function assertCronExpressionSatisfiable(job: CronJob, nowMs: number) {
}
function assertMainSessionAgentId(
job: Pick<CronJob, "sessionTarget" | "agentId">,
job: Pick<CronJob, "sessionTarget" | "agentId" | "payload">,
defaultAgentId: string | undefined,
) {
if (job.sessionTarget !== "main") {
@@ -366,6 +405,9 @@ function assertMainSessionAgentId(
if (!job.agentId) {
return;
}
if (job.payload.kind === "script") {
return;
}
const normalized = normalizeAgentId(job.agentId);
const normalizedDefault = normalizeAgentId(defaultAgentId);
if (normalized !== normalizedDefault) {
@@ -971,7 +1013,10 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
...(input.pacing !== undefined ? { pacing: structuredClone(input.pacing) } : {}),
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: input.payload,
payload:
input.payload.kind === "script"
? normalizeCronScriptPayload(structuredClone(input.payload))
: input.payload,
delivery: resolveInitialCronDelivery(input),
failureAlert: input.failureAlert,
...(input.trigger ? { trigger: structuredClone(input.trigger) } : {}),
@@ -987,6 +1032,10 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
cronConfig: state.deps.cronConfig,
requireEnabled: job.trigger !== undefined,
});
assertScriptPayloadSupport(job, {
cronConfig: state.deps.cronConfig,
requireEnabled: job.payload.kind === "script",
});
assertMainSessionAgentId(job, state.deps.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@@ -1089,6 +1138,9 @@ export function applyJobPatch(
}
if (patch.payload) {
job.payload = mergeCronPayload(job.payload, patch.payload);
if (job.payload.kind === "script") {
job.payload = normalizeCronScriptPayload(job.payload);
}
}
if (patch.delivery) {
const implicitMode = resolveCronDeliveryPlan(job).mode;
@@ -1132,6 +1184,10 @@ export function applyJobPatch(
cronConfig: opts?.cronConfig,
requireEnabled: patch.trigger !== null && patch.trigger !== undefined,
});
assertScriptPayloadSupport(job, {
cronConfig: opts?.cronConfig,
requireEnabled: patch.payload?.kind === "script",
});
assertMainSessionAgentId(job, opts?.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@@ -1199,7 +1255,10 @@ export function applyDeclarativeJobSpec(
} else {
delete job.pacing;
}
job.payload = structuredClone(input.payload);
job.payload =
input.payload.kind === "script"
? normalizeCronScriptPayload(structuredClone(input.payload))
: structuredClone(input.payload);
if (input.trigger) {
job.trigger = structuredClone(input.trigger);
} else {
@@ -1218,6 +1277,10 @@ export function applyDeclarativeJobSpec(
cronConfig: opts.cronConfig,
requireEnabled: input.trigger !== undefined,
});
assertScriptPayloadSupport(job, {
cronConfig: opts.cronConfig,
requireEnabled: input.payload.kind === "script",
});
assertSupportedJobSpec(job);
if (job.pacing !== undefined) {
+31
View File
@@ -101,6 +101,23 @@ export function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch)
applyToolsAllowPatch(next, patch, existing);
return next;
}
if (patch.kind === "script") {
if (existing.kind !== "script") {
return buildPayloadFromPatch(patch);
}
const next: Extract<CronPayload, { kind: "script" }> = { ...existing };
if (typeof patch.script === "string") {
next.script = patch.script;
}
if (typeof patch.timeoutSeconds === "number") {
next.timeoutSeconds = patch.timeoutSeconds;
}
if (typeof patch.toolBudget === "number") {
next.toolBudget = patch.toolBudget;
}
applyToolsAllowPatch(next, patch, existing);
return next;
}
if (existing.kind !== "agentTurn") {
return buildPayloadFromPatch(patch);
@@ -169,6 +186,20 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
return next;
}
if (patch.kind === "script") {
if (typeof patch.script !== "string" || patch.script.trim().length === 0) {
throw new Error('cron.update payload.kind="script" requires script');
}
const next: Extract<CronPayload, { kind: "script" }> = {
kind: "script",
script: patch.script,
timeoutSeconds: patch.timeoutSeconds,
toolBudget: patch.toolBudget,
};
applyToolsAllowPatch(next, patch);
return next;
}
if (typeof patch.message !== "string" || patch.message.length === 0) {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}
+13
View File
@@ -180,6 +180,19 @@ export type CronServiceDeps = {
delivery?: CronDeliveryTrace;
} & CronRunOutcome
>;
runScriptJob?: (params: { job: CronJob; abortSignal?: AbortSignal }) => Promise<
{
delivered?: boolean;
deliveryAttempted?: boolean;
deliveryError?: string;
delivery?: CronDeliveryTrace;
notify?: string;
wake?: "now" | "next-heartbeat";
stateChanged?: boolean;
state?: unknown;
nextCheck?: CronNextCheckProposal;
} & CronRunOutcome
>;
cleanupTimedOutAgentRun?: (params: {
job: CronJob;
timeoutMs: number;
+7
View File
@@ -48,6 +48,13 @@ describe("timeout-policy", () => {
expect(timeout).toBe(1_900);
});
it("uses a script payload timeout as the outer cron watchdog", () => {
const timeout = resolveCronJobTimeoutMs(
makeJob({ kind: "script", script: "return {}", timeoutSeconds: 300 }),
);
expect(timeout).toBe(300_000);
});
it("caps oversized explicit timeoutSeconds at the timer-safe ceiling", () => {
const timeout = resolveCronJobTimeoutMs(
makeJob({ kind: "agentTurn", message: "hi", timeoutSeconds: Number.MAX_SAFE_INTEGER }),
+3 -1
View File
@@ -18,7 +18,9 @@ const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes
/** Resolves the wall-clock timeout for a cron job, including explicit detached-run overrides. */
export function resolveCronJobTimeoutMs(job: CronJob): number | undefined {
const configuredTimeoutMs =
(job.payload.kind === "agentTurn" || job.payload.kind === "command") &&
(job.payload.kind === "agentTurn" ||
job.payload.kind === "command" ||
job.payload.kind === "script") &&
typeof job.payload.timeoutSeconds === "number"
? (finiteSecondsToTimerSafeMilliseconds(job.payload.timeoutSeconds) ?? 0)
: undefined;
+213
View File
@@ -66,6 +66,32 @@ function createDueCommandJob(params: { now: number }): CronJob {
};
}
function createDueScriptJob(params: {
now: number;
sessionTarget?: "main" | "isolated";
pacing?: CronJob["pacing"];
}): CronJob {
return {
id: "script-job",
agentId: "finn",
name: "script job",
enabled: true,
createdAtMs: params.now - 60_000,
updatedAtMs: params.now - 60_000,
schedule: { kind: "every", everyMs: 60_000, anchorMs: params.now - 60_000 },
pacing: params.pacing,
sessionTarget: params.sessionTarget ?? "isolated",
wakeMode: "now",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 300,
toolBudget: 50,
},
state: { nextRunAtMs: params.now - 1, triggerState: { revision: 1 } },
};
}
function findCronTaskByBaseRunId(baseRunId: string) {
return (
findTaskByRunId(baseRunId) ??
@@ -356,6 +382,193 @@ describe("cron service timer seam coverage", () => {
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
});
it("records an execution error when script payloads are disabled", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const runScriptJob = vi.fn(async () => ({ status: "ok" as const }));
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: false } },
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob,
});
await expect(executeJobCore(state, createDueScriptJob({ now }))).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("cron.triggers.enabled=true"),
});
expect(runScriptJob).not.toHaveBeenCalled();
});
it.each([
["now", "immediate"],
["next-heartbeat", "event"],
] as const)("turns a main script notify and %s wake into one event", async (wake, intent) => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const enqueueSystemEvent = vi.fn();
const requestHeartbeat = vi.fn();
const job = createDueScriptJob({ now, sessionTarget: "main" });
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true } },
log: logger,
nowMs: () => now,
enqueueSystemEvent,
requestHeartbeat,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob: vi.fn(async () => ({
status: "ok" as const,
notify: "queue changed",
wake,
})),
});
await expect(executeJobCore(state, job)).resolves.toMatchObject({
status: "ok",
summary: "queue changed",
});
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("queue changed", {
agentId: "finn",
contextKey: "cron:script-job:script",
});
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
source: "cron",
intent,
reason: "cron:script-job:script",
agentId: "finn",
});
});
it("delivers nothing and enqueues nothing when notify and wake are absent", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const enqueueSystemEvent = vi.fn();
const requestHeartbeat = vi.fn();
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true } },
log: logger,
nowMs: () => now,
enqueueSystemEvent,
requestHeartbeat,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob: vi.fn(async () => ({
status: "ok" as const,
stateChanged: true,
state: { revision: 2 },
delivered: false,
deliveryAttempted: false,
})),
});
await expect(
executeJobCore(state, createDueScriptJob({ now, sessionTarget: "main" })),
).resolves.toMatchObject({ status: "ok", scriptStateChanged: true });
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeat).not.toHaveBeenCalled();
});
it("rejects nextCheck without pacing before applying state", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true } },
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob: vi.fn(async () => ({
status: "ok" as const,
stateChanged: true,
state: { revision: 2 },
nextCheck: { delayMs: 5_000 },
})),
});
await expect(executeJobCore(state, createDueScriptJob({ now }))).resolves.toEqual({
status: "error",
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
});
});
it.each([
["ok", { status: "ok" as const, stateChanged: true, state: { revision: 2 } }, 2, 0],
[
"error",
{
status: "error" as const,
error: "script threw",
stateChanged: true,
state: { revision: 2 },
},
1,
1,
],
] as const)(
"persists script state on %s runs only",
async (_label, outcome, revision, errors) => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const job = createDueScriptJob({ now });
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true } },
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob: vi.fn(async () => outcome),
});
await onTimer(state);
const stored = await loadCronStore(storePath);
expect(stored.jobs[0]?.state.triggerState).toEqual({ revision });
expect(stored.jobs[0]?.state.consecutiveErrors ?? 0).toBe(errors);
},
);
it("clamps a script nextCheck through the shared pacing path", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-18T12:00:00.000Z");
const job = createDueScriptJob({ now, pacing: { min: "15m", max: "4h" } });
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const state = createCronServiceState({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true } },
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runScriptJob: vi.fn(async () => ({
status: "ok" as const,
nextCheck: { delayMs: 5 * 60_000 },
})),
});
await onTimer(state);
const stored = await loadCronStore(storePath);
expect(stored.jobs[0]?.state.nextRunAtMs).toBe(now + 15 * 60_000);
expect(stored.jobs[0]?.state.pacedNextRunAtMs).toBe(now + 15 * 60_000);
});
it("records isolated cron task runs against the backing cron session", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-03-23T12:00:00.000Z");
+76 -3
View File
@@ -114,6 +114,7 @@ import {
finishRetiredCronTaskRuns,
clearUnstartedStartupCatchupReservationMarkers,
} from "./timer-outcome-finalization.js";
import { enqueueCronSystemEvent, requestCronHeartbeat } from "./wake.js";
const MAX_TIMER_DELAY_MS = 60_000;
const HEARTBEAT_SKIP_DISABLED = "disabled";
@@ -146,6 +147,8 @@ type TimedCronRunOutcome = CronRunOutcome &
startedAt: number;
endedAt: number;
triggerEval?: CronTriggerEvalOutcome;
scriptStateChanged?: boolean;
scriptState?: unknown;
nextCheck?: CronNextCheckProposal;
};
@@ -1217,6 +1220,11 @@ function applyOutcomeToStoredJob(
const shouldDelete = applyJobResult(state, job, result);
applyTriggerRunResult(job, result);
if (result.status === "ok" && result.scriptStateChanged === true) {
// Both trigger and payload scripts expose frozen trigger.state. Advance it
// only after the complete cron run has succeeded.
job.state.triggerState = result.scriptState;
}
job.state.startupCatchupAtMs = undefined;
emitJobFinished(state, job, result, result.startedAt);
@@ -2457,6 +2465,8 @@ async function executeJobCore(
deliveryError?: string;
delivery?: CronDeliveryTrace;
nextCheck?: CronNextCheckProposal;
scriptStateChanged?: boolean;
scriptState?: unknown;
triggerEval?: CronTriggerEvalOutcome;
}
> {
@@ -2537,6 +2547,10 @@ async function executeJobCore(
effectiveJob = { ...job, payload };
}
}
if (effectiveJob.payload.kind === "script") {
const result = await executeScriptCronJob(state, effectiveJob, abortSignal);
return triggerEval ? { ...result, triggerEval } : result;
}
if (effectiveJob.sessionTarget === "main") {
const result = await executeMainSessionCronJob(
state,
@@ -2593,7 +2607,7 @@ async function executeMainSessionCronJob(
// Main-session jobs enqueue text into a per-run child session so each cron
// execution has its own transcript and task drill-down target.
const queuedSystemEvent = normalizeQueuedSystemEventHandle(
state.deps.enqueueSystemEvent(text, {
enqueueCronSystemEvent(state, text, {
agentId: job.agentId,
sessionKey: cronRunSessionKey,
contextKey: `cron:${job.id}`,
@@ -2692,8 +2706,7 @@ async function executeMainSessionCronJob(
removeQueuedSystemEventHandle(state, job, queuedSystemEvent);
return { status: "error", error: timeoutErrorMessage() };
}
state.deps.requestHeartbeat({
source: "cron",
requestCronHeartbeat(state, {
intent: job.wakeMode === "now" ? "immediate" : "event",
reason: `cron:${job.id}`,
agentId: job.agentId,
@@ -2820,6 +2833,66 @@ async function executeDetachedCronJob(
};
}
async function executeScriptCronJob(
state: CronServiceState,
job: CronJob,
abortSignal: AbortSignal | undefined,
) {
if (state.deps.cronConfig?.triggers?.enabled !== true) {
return {
status: "error" as const,
error:
"cron script payload execution is disabled; set cron.triggers.enabled=true to allow unattended scripts",
};
}
if (!state.deps.runScriptJob) {
return { status: "error" as const, error: "cron script payload executor is unavailable" };
}
const result = await state.deps.runScriptJob({ job, abortSignal });
if (result.status !== "ok") {
return result;
}
if (result.nextCheck && !job.pacing) {
return {
status: "error" as const,
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
};
}
const notify = result.notify?.trim() ? result.notify : undefined;
if (job.sessionTarget === "main" && notify) {
enqueueCronSystemEvent(state, notify, {
agentId: job.agentId,
contextKey: `cron:${job.id}:script`,
});
}
if (result.wake) {
const eventText = notify ?? `script job ${job.name} completed`;
if (job.sessionTarget !== "main" || !notify) {
enqueueCronSystemEvent(state, eventText, {
agentId: job.agentId,
contextKey: `cron:${job.id}:script-wake`,
});
}
requestCronHeartbeat(state, {
intent: result.wake === "now" ? "immediate" : "event",
reason: `cron:${job.id}:script`,
agentId: job.agentId,
});
}
return {
status: "ok" as const,
...(notify ? { summary: notify } : {}),
delivered: result.delivered,
deliveryAttempted: result.deliveryAttempted,
deliveryError: result.deliveryError,
delivery: result.delivery,
nextCheck: result.nextCheck,
scriptStateChanged: result.stateChanged === true,
...(result.stateChanged === true ? { scriptState: result.state } : {}),
};
}
function emitJobFinished(
state: CronServiceState,
job: CronJob,
+21
View File
@@ -2,6 +2,27 @@
import { isSubagentSessionKey } from "../../routing/session-key.js";
import type { CronServiceState } from "./state.js";
export function enqueueCronSystemEvent(
state: CronServiceState,
text: string,
opts?: Parameters<CronServiceState["deps"]["enqueueSystemEvent"]>[1],
) {
return state.deps.enqueueSystemEvent(text, opts);
}
export function requestCronHeartbeat(
state: CronServiceState,
opts: {
intent: "immediate" | "event";
reason: string;
agentId?: string;
sessionKey?: string;
heartbeat?: { target?: "last" };
},
) {
state.deps.requestHeartbeat({ source: "cron", ...opts });
}
/** Enqueues a manual cron wake event and optionally pokes the targeted heartbeat loop. */
export function wake(
state: CronServiceState,
+55
View File
@@ -89,6 +89,28 @@ function parseCommandPayloadMessage(
};
}
function parseScriptPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "script" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (typeof record.script !== "string" || !record.script.trim()) {
return null;
}
const toolBudget = normalizeNumber(
typeof record.toolBudget === "number" || typeof record.toolBudget === "bigint"
? record.toolBudget
: null,
);
return {
script: record.script,
...(toolBudget != null ? { toolBudget } : {}),
};
}
/** Maps cron payload variants into normalized SQLite columns. */
export function bindPayloadColumns(
payload: CronPayload,
@@ -140,6 +162,26 @@ export function bindPayloadColumns(
...bindPayloadToolAllowColumns(payload),
};
}
if (payload.kind === "script") {
const {
timeoutSeconds: _timeoutSeconds,
toolsAllow: _toolsAllow,
toolsAllowIsDefault: _toolsAllowIsDefault,
...payloadMessage
} = payload;
return {
payload_kind: "script",
payload_message: serializeJson(payloadMessage),
payload_model: null,
payload_fallbacks_json: null,
payload_thinking: null,
payload_timeout_seconds: payload.timeoutSeconds ?? null,
payload_allow_unsafe_external_content: null,
payload_external_content_source_json: null,
payload_light_context: null,
...bindPayloadToolAllowColumns(payload),
};
}
return {
payload_kind: "agentTurn",
payload_message: payload.message,
@@ -209,5 +251,18 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
...payloadToolAllowFromRow(row),
};
}
if (row.payload_kind === "script") {
const script = parseScriptPayloadMessage(row.payload_message);
if (!script) {
return null;
}
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
return {
kind: "script",
...script,
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
...payloadToolAllowFromRow(row),
};
}
return null;
}
+146 -6
View File
@@ -4,9 +4,9 @@ import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metada
import type { CodeModeHeadlessResult } from "../agents/code-mode.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createCronTriggerEvaluator } from "./trigger-script.js";
import { createCronScriptRuntime } from "./trigger-script.js";
type EvaluatorDeps = Parameters<typeof createCronTriggerEvaluator>[0];
type EvaluatorDeps = Parameters<typeof createCronScriptRuntime>[0];
type HeadlessParams = Parameters<NonNullable<EvaluatorDeps["runHeadless"]>>[0];
type PrepareParams = Parameters<NonNullable<EvaluatorDeps["prepareRuntime"]>>[0];
@@ -52,18 +52,22 @@ function createPreparedRuntime(config: OpenClawConfig) {
function createEvaluator(
runHeadless: (
params: Parameters<
NonNullable<Parameters<typeof createCronTriggerEvaluator>[0]["runHeadless"]>
NonNullable<Parameters<typeof createCronScriptRuntime>[0]["runHeadless"]>
>[0],
) => Promise<CodeModeHeadlessResult>,
) {
const config = {} as OpenClawConfig;
const prepareRuntime = vi.fn(async () => createPreparedRuntime(config));
return {
evaluate: createCronTriggerEvaluator({ config, runHeadless, prepareRuntime }),
evaluate: createCronScriptRuntime({ config, runHeadless, prepareRuntime }).evaluateTrigger,
prepareRuntime,
};
}
function createCronTriggerEvaluator(deps: EvaluatorDeps) {
return createCronScriptRuntime(deps).evaluateTrigger;
}
describe("cron trigger script evaluator", () => {
it("prefers a valid returned value and injects trigger state", async () => {
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
@@ -103,11 +107,11 @@ describe("cron trigger script evaluator", () => {
);
});
it("falls back to the last json output entry", async () => {
it("falls back to the last json output entry when the returned object is not a trigger result", async () => {
const { evaluate } = createEvaluator(
vi.fn(async () =>
completed({
value: null,
value: { ignored: true },
output: [
{ type: "json", value: { fire: false, state: { old: true } } },
{ type: "text", text: "ignored" },
@@ -395,3 +399,139 @@ describe("cron trigger script evaluator", () => {
}
});
});
describe("cron script payload evaluator", () => {
it("uses payload-grade capped budgets and exposes frozen trigger state", async () => {
const config = {} as OpenClawConfig;
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
completed({
value: {
notify: "queue changed",
wake: "now",
state: { revision: 2 },
nextCheck: "5m",
},
}),
);
const runtime = createCronScriptRuntime({
config,
runHeadless,
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
});
await expect(
runtime.executePayload({
jobId: "payload-job",
script: "return result",
state: { revision: 1 },
timeoutSeconds: 10_000,
toolBudget: 10_000,
}),
).resolves.toEqual({
kind: "completed",
notify: "queue changed",
wake: "now",
stateChanged: true,
state: { revision: 2 },
nextCheck: { delayMs: 300_000 },
});
expect(runHeadless).toHaveBeenCalledWith(
expect.objectContaining({
maxToolCalls: 200,
extraNamespaces: [
{
id: "cron:trigger",
globalName: "trigger",
scope: {
kind: "object",
entries: [["state", { kind: "value", value: { revision: 1 } }]],
},
},
],
}),
);
const headlessParams = runHeadless.mock.calls[0]?.[0];
expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(899_000);
expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(900_000);
});
it("uses payload defaults and accepts an omitted result state", async () => {
const config = {} as OpenClawConfig;
const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: {} }));
const runtime = createCronScriptRuntime({
config,
runHeadless,
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
});
await expect(
runtime.executePayload({ jobId: "payload-defaults", script: "return {}", state: null }),
).resolves.toEqual({ kind: "completed", stateChanged: false });
expect(runHeadless).toHaveBeenCalledWith(expect.objectContaining({ maxToolCalls: 50 }));
const headlessParams = runHeadless.mock.calls[0]?.[0];
expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(299_000);
expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(300_000);
});
it("canonicalizes returned state to the JSON value that will be persisted", async () => {
const config = {} as OpenClawConfig;
const runtime = createCronScriptRuntime({
config,
runHeadless: vi.fn(async () =>
completed({
value: { state: { keep: 1, dropped: undefined, nonFinite: Number.NaN } },
}),
),
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
});
await expect(
runtime.executePayload({ jobId: "payload-json-state", script: "return state", state: null }),
).resolves.toEqual({
kind: "completed",
stateChanged: true,
state: { keep: 1, nonFinite: null },
});
});
it.each([
[{ notify: 42 }, "notify must be a string"],
[{ wake: "later" }, 'wake must be "now" or "next-heartbeat"'],
[{ nextCheck: "tomorrowish" }, "nextCheck must be a positive duration"],
[{ state: "x".repeat(17 * 1024) }, "state exceeds the 16KB limit"],
] as const)("rejects an invalid result %#", async (value, error) => {
const config = {} as OpenClawConfig;
const runtime = createCronScriptRuntime({
config,
runHeadless: vi.fn(async () => completed({ value })),
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
});
await expect(
runtime.executePayload({ jobId: "payload-invalid", script: "return result", state: null }),
).resolves.toMatchObject({ kind: "error", error: expect.stringContaining(error) });
});
it("surfaces executor failures through the cron error contract", async () => {
const config = {} as OpenClawConfig;
const runtime = createCronScriptRuntime({
config,
runHeadless: vi.fn(async () => ({
status: "failed" as const,
code: "tool_budget_exceeded" as const,
error: "tool budget exceeded",
output: [],
toolCallCount: 51,
})),
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
});
await expect(
runtime.executePayload({ jobId: "payload-failed", script: "return result", state: null }),
).resolves.toEqual({
kind: "error",
code: "tool_budget_exceeded",
error: "tool budget exceeded",
});
});
});
+227 -49
View File
@@ -32,6 +32,7 @@ import {
} from "../agents/tool-search.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -40,6 +41,12 @@ import {
resolveCronActiveRuntimeConfig,
} from "./isolated-agent/run-config.js";
import { resolveCronAgentSessionKey } from "./isolated-agent/session-key.js";
import {
DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS,
DEFAULT_CRON_SCRIPT_TOOL_BUDGET,
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
MAX_CRON_SCRIPT_TOOL_BUDGET,
} from "./script-payload.js";
import type { CronTriggerEvaluationResult, CronTriggerFailureCode } from "./types.js";
const MAX_CONCURRENT_TRIGGER_EVALS = 3;
@@ -212,6 +219,21 @@ function triggerResultCandidate(result: Extract<CodeModeHeadlessResult, { status
return undefined;
}
function scriptPayloadResultCandidate(
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
) {
if (isRecord(result.value)) {
return result.value;
}
for (let index = result.output.length - 1; index >= 0; index -= 1) {
const entry = result.output[index];
if (isRecord(entry) && entry.type === "json") {
return entry.value;
}
}
return undefined;
}
function parseTriggerResult(
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
): CronTriggerEvaluationResult {
@@ -230,59 +252,40 @@ function parseTriggerResult(
error: "cron trigger script message must be a string",
};
}
const hasState = Object.hasOwn(candidate, "state");
if (hasState) {
let serialized: string | undefined;
try {
serialized = JSON.stringify(candidate.state);
} catch (error) {
return {
kind: "error",
code: "internal_error",
error: `cron trigger state is not JSON-serializable: ${String(error)}`,
};
}
if (serialized === undefined) {
return {
kind: "error",
code: "internal_error",
error: "cron trigger state is not JSON-serializable",
};
}
if (Buffer.byteLength(serialized, "utf8") > MAX_TRIGGER_STATE_BYTES) {
return {
kind: "error",
code: "output_limit_exceeded",
error: "cron trigger state exceeds the 16KB limit",
};
}
const state = validateCronState(candidate, "cron trigger");
if (!state.ok) {
return { kind: "error", code: state.code, error: state.error };
}
return {
kind: "evaluated",
fire: candidate.fire,
...(typeof candidate.message === "string" ? { message: candidate.message } : {}),
...(hasState ? { state: candidate.state } : {}),
...(state.stateChanged ? { state: state.state } : {}),
};
}
function createTriggerDeadlineScope(externalSignal?: AbortSignal) {
function createHeadlessDeadlineScope(params: {
externalSignal?: AbortSignal;
wallClockMs: number;
label: string;
}) {
const controller = new AbortController();
const onExternalAbort = () =>
controller.abort(new CodeModeHeadlessAbortError("cron trigger evaluation aborted"));
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
if (externalSignal?.aborted) {
controller.abort(new CodeModeHeadlessAbortError(`${params.label} aborted`));
params.externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
if (params.externalSignal?.aborted) {
onExternalAbort();
}
const timer = setTimeout(
() => controller.abort(new CodeModeHeadlessTimeoutError("cron trigger evaluation timed out")),
HEADLESS_TRIGGER_WALL_CLOCK_MS,
() => controller.abort(new CodeModeHeadlessTimeoutError(`${params.label} timed out`)),
params.wallClockMs,
);
return {
deadline: Date.now() + HEADLESS_TRIGGER_WALL_CLOCK_MS,
deadline: Date.now() + params.wallClockMs,
signal: controller.signal,
cleanup: () => {
clearTimeout(timer);
externalSignal?.removeEventListener("abort", onExternalAbort);
params.externalSignal?.removeEventListener("abort", onExternalAbort);
},
};
}
@@ -306,7 +309,7 @@ async function awaitTriggerSignal<T>(promise: Promise<T>, signal: AbortSignal):
}
}
export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
const runHeadless = deps.runHeadless ?? runCodeModeScriptHeadless;
const prepareRuntime = deps.prepareRuntime ?? prepareTriggerRuntime;
// Config identity is the reload epoch; caching the preparation promise makes
@@ -382,19 +385,25 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
return await awaitTriggerSignal(entry.promise, request.signal);
};
return async function evaluateCronTrigger(params: {
return async function runCronCodeModeScript(params: {
jobId: string;
agentId?: string;
script: string;
state: unknown;
toolsAllow?: string[];
abortSignal?: AbortSignal;
}): Promise<CronTriggerEvaluationResult> {
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
return { kind: "busy" };
}
activeTriggerEvaluations += 1;
const evaluationScope = createTriggerDeadlineScope(params.abortSignal);
wallClockMs: number;
maxToolCalls: number;
label: string;
namespaces: CodeModeNamespaceDescriptor[];
}): Promise<
| { kind: "completed"; result: Extract<CodeModeHeadlessResult, { status: "completed" }> }
| { kind: "error"; code: CronTriggerFailureCode; error: string }
> {
const evaluationScope = createHeadlessDeadlineScope({
externalSignal: params.abortSignal,
wallClockMs: params.wallClockMs,
label: params.label,
});
try {
const runtimeConfig = resolveCronActiveRuntimeConfig(deps.config);
const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId);
@@ -418,20 +427,20 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
});
const remainingWallClockMs = evaluationScope.deadline - Date.now();
if (remainingWallClockMs <= 0) {
throw new CodeModeHeadlessTimeoutError("cron trigger evaluation timed out");
throw new CodeModeHeadlessTimeoutError(`${params.label} timed out`);
}
const result = await runHeadless({
ctx: { ...runtime.ctx, catalogRef, abortSignal: evaluationScope.signal },
code: params.script,
wallClockMs: remainingWallClockMs,
maxToolCalls: HEADLESS_TRIGGER_TOOL_BUDGET,
extraNamespaces: [triggerStateNamespace(params.state)],
maxToolCalls: params.maxToolCalls,
extraNamespaces: params.namespaces,
signal: evaluationScope.signal,
});
if (result.status === "failed") {
return { kind: "error", code: result.code, error: result.error };
}
return parseTriggerResult(result);
return { kind: "completed", result };
} catch (error) {
return {
kind: "error",
@@ -445,7 +454,176 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
};
} finally {
evaluationScope.cleanup();
activeTriggerEvaluations -= 1;
}
};
}
type CronScriptPayloadExecutionResult =
| {
kind: "completed";
notify?: string;
wake?: "now" | "next-heartbeat";
stateChanged: boolean;
state?: unknown;
nextCheck?: { delayMs: number };
}
| { kind: "error"; code: CronTriggerFailureCode; error: string };
function validateCronState(candidate: Record<string, unknown>, label: string) {
if (!Object.hasOwn(candidate, "state")) {
return { ok: true as const, stateChanged: false as const };
}
let serialized: string | undefined;
try {
serialized = JSON.stringify(candidate.state);
} catch (error) {
return {
ok: false as const,
code: "internal_error" as const,
error: `${label} state is not JSON-serializable: ${String(error)}`,
};
}
if (serialized === undefined) {
return {
ok: false as const,
code: "internal_error" as const,
error: `${label} state is not JSON-serializable`,
};
}
if (Buffer.byteLength(serialized, "utf8") > MAX_TRIGGER_STATE_BYTES) {
return {
ok: false as const,
code: "output_limit_exceeded" as const,
error: `${label} state exceeds the 16KB limit`,
};
}
return {
ok: true as const,
stateChanged: true as const,
state: JSON.parse(serialized) as unknown,
};
}
function parseScriptPayloadResult(
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
): CronScriptPayloadExecutionResult {
const candidate = scriptPayloadResultCandidate(result);
if (!isRecord(candidate)) {
return {
kind: "error",
code: "internal_error",
error: "cron script payload must return an object",
};
}
if (candidate.notify !== undefined && typeof candidate.notify !== "string") {
return {
kind: "error",
code: "internal_error",
error: "cron script payload notify must be a string",
};
}
if (
candidate.wake !== undefined &&
candidate.wake !== "now" &&
candidate.wake !== "next-heartbeat"
) {
return {
kind: "error",
code: "internal_error",
error: 'cron script payload wake must be "now" or "next-heartbeat"',
};
}
let nextCheck: { delayMs: number } | undefined;
if (candidate.nextCheck !== undefined) {
if (typeof candidate.nextCheck !== "string") {
return {
kind: "error",
code: "internal_error",
error: "cron script payload nextCheck must be a duration string",
};
}
try {
const delayMs = parseDurationMs(candidate.nextCheck);
if (delayMs <= 0) {
throw new Error("duration must be positive");
}
nextCheck = { delayMs };
} catch {
return {
kind: "error",
code: "internal_error",
error: "cron script payload nextCheck must be a positive duration",
};
}
}
const state = validateCronState(candidate, "cron script payload");
if (!state.ok) {
return { kind: "error", code: state.code, error: state.error };
}
return {
kind: "completed",
...(candidate.notify !== undefined ? { notify: candidate.notify } : {}),
...(candidate.wake !== undefined ? { wake: candidate.wake } : {}),
stateChanged: state.stateChanged,
...(state.stateChanged ? { state: state.state } : {}),
...(nextCheck ? { nextCheck } : {}),
};
}
export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
const run = createCronCodeModeRunner(deps);
return {
evaluateTrigger: async (params: {
jobId: string;
agentId?: string;
script: string;
state: unknown;
toolsAllow?: string[];
abortSignal?: AbortSignal;
}): Promise<CronTriggerEvaluationResult> => {
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
return { kind: "busy" };
}
activeTriggerEvaluations += 1;
try {
const outcome = await run({
...params,
wallClockMs: HEADLESS_TRIGGER_WALL_CLOCK_MS,
maxToolCalls: HEADLESS_TRIGGER_TOOL_BUDGET,
label: "cron trigger evaluation",
namespaces: [triggerStateNamespace(params.state)],
});
return outcome.kind === "completed" ? parseTriggerResult(outcome.result) : outcome;
} finally {
activeTriggerEvaluations -= 1;
}
},
executePayload: async (params: {
jobId: string;
agentId?: string;
script: string;
state: unknown;
toolsAllow?: string[];
timeoutSeconds?: number;
toolBudget?: number;
abortSignal?: AbortSignal;
}): Promise<CronScriptPayloadExecutionResult> => {
const timeoutSeconds = Math.min(
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
Math.max(1, Math.floor(params.timeoutSeconds ?? DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS)),
);
const toolBudget = Math.min(
MAX_CRON_SCRIPT_TOOL_BUDGET,
Math.max(1, Math.floor(params.toolBudget ?? DEFAULT_CRON_SCRIPT_TOOL_BUDGET)),
);
const outcome = await run({
...params,
wallClockMs: timeoutSeconds * 1000,
maxToolCalls: toolBudget,
label: "cron script payload",
namespaces: [triggerStateNamespace(params.state)],
});
return outcome.kind === "completed" ? parseScriptPayloadResult(outcome.result) : outcome;
},
};
}
+18 -2
View File
@@ -251,13 +251,15 @@ export type CronFailureAlertPatch = {
export type CronPayload =
| ({ kind: "systemEvent"; text: string } & CronPayloadToolAllow)
| (CronAgentTurnPayload & CronPayloadToolAllow)
| (CronCommandPayload & CronPayloadToolAllow);
| (CronCommandPayload & CronPayloadToolAllow)
| (CronScriptPayload & CronPayloadToolAllow);
/** Partial payload update shape used by cron patch/edit flows. */
export type CronPayloadPatch =
| ({ kind: "systemEvent"; text?: string } & CronPayloadToolAllowPatch)
| (CronAgentTurnPayloadPatch & CronPayloadToolAllowPatch)
| (CronCommandPayloadPatch & CronPayloadToolAllowPatch);
| (CronCommandPayloadPatch & CronPayloadToolAllowPatch)
| (CronScriptPayloadPatch & CronPayloadToolAllowPatch);
type CronPayloadToolAllow = {
/** Restricts agentTurn execution, or the trigger runtime for other payload kinds. */
@@ -317,6 +319,20 @@ type CronCommandPayload = {
type CronCommandPayloadPatch = {
kind: "command";
} & Partial<CronCommandPayloadFields>;
type CronScriptPayloadFields = {
script: string;
timeoutSeconds?: number;
toolBudget?: number;
};
type CronScriptPayload = {
kind: "script";
} & CronScriptPayloadFields;
type CronScriptPayloadPatch = {
kind: "script";
} & Partial<CronScriptPayloadFields>;
/** Mutable runtime state persisted beside the immutable cron job spec. */
export type CronJobState = {
nextRunAtMs?: number;
+7 -1
View File
@@ -340,6 +340,10 @@ export function dispatchGatewayCronFinishedNotifications(params: {
}): void {
const webhookToken = normalizeOptionalString(params.webhookToken);
const redactedWebhookEvent = redactCommandCronEventForExternalDelivery(params.evt, params.job);
const completionSummary =
params.job?.payload.kind === "script"
? normalizeOptionalString(redactedWebhookEvent.summary)
: params.evt.summary;
const webhookTargets = resolveCronWebhookTargets({
delivery:
params.job?.delivery && typeof params.job.delivery.mode === "string"
@@ -377,7 +381,9 @@ export function dispatchGatewayCronFinishedNotifications(params: {
);
}
if (params.evt.summary) {
// Script notify is carried as the completion summary, so its absence uses
// the same silent-summary suppression path as NO_REPLY output.
if (completionSummary) {
for (const webhookTarget of webhookTargets) {
const payload = buildCronFinishedWebhookPayload(redactedWebhookEvent);
// Completion notification fanout is best-effort; the cron service has
+170 -5
View File
@@ -32,8 +32,9 @@ const {
retireSessionMcpRuntimeMock,
requestSafeGatewayRestartMock,
getProcessSupervisorMock,
createCronTriggerEvaluatorMock,
createCronScriptRuntimeMock,
cronTriggerEvaluatorMock,
cronScriptExecutorMock,
} = vi.hoisted(() => ({
enqueueSystemEventMock: vi.fn(),
consumeSelectedSystemEventEntriesMock: vi.fn((_sessionKey, entries) => entries ?? []),
@@ -90,8 +91,9 @@ const {
spawn: vi.fn(),
cancelScope: vi.fn(),
})),
createCronTriggerEvaluatorMock: vi.fn(),
createCronScriptRuntimeMock: vi.fn(),
cronTriggerEvaluatorMock: vi.fn(),
cronScriptExecutorMock: vi.fn(),
}));
function enqueueSystemEvent(text: string, opts?: unknown) {
@@ -204,7 +206,7 @@ vi.mock("../process/supervisor/index.js", () => ({
}));
vi.mock("../cron/trigger-script.js", () => ({
createCronTriggerEvaluator: createCronTriggerEvaluatorMock,
createCronScriptRuntime: createCronScriptRuntimeMock,
}));
import type { CronJob } from "../cron/types.js";
@@ -320,8 +322,13 @@ describe("buildGatewayCronService", () => {
});
cronTriggerEvaluatorMock.mockReset();
cronTriggerEvaluatorMock.mockResolvedValue({ kind: "evaluated", fire: false });
createCronTriggerEvaluatorMock.mockReset();
createCronTriggerEvaluatorMock.mockReturnValue(cronTriggerEvaluatorMock);
cronScriptExecutorMock.mockReset();
cronScriptExecutorMock.mockResolvedValue({ kind: "completed", stateChanged: false });
createCronScriptRuntimeMock.mockReset();
createCronScriptRuntimeMock.mockReturnValue({
evaluateTrigger: cronTriggerEvaluatorMock,
executePayload: cronScriptExecutorMock,
});
getGlobalHookRunnerMock.mockReturnValue({
hasHooks: (hookName: string) => hookName === "cron_changed",
runCronChanged: runCronChangedMock,
@@ -761,6 +768,164 @@ describe("buildGatewayCronService", () => {
}
});
it("delivers isolated script notify through the cron announce path", async () => {
const cfg = createCronConfig("server-cron-script-announce");
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
loadConfigMock.mockReturnValue(cfg);
cronScriptExecutorMock.mockResolvedValueOnce({
kind: "completed",
notify: "queue changed",
stateChanged: false,
});
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "script-announce",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "script", script: "return { notify: 'queue changed' }" },
delivery: { mode: "announce", channel: "telegram", to: "123", threadId: 456 },
});
await state.cron.run(job.id, "force");
expect(cronScriptExecutorMock).toHaveBeenCalledWith(
expect.objectContaining({
jobId: job.id,
script: "return { notify: 'queue changed' }",
timeoutSeconds: 300,
toolBudget: 50,
}),
);
expect(sendCronAnnouncePayloadStrictMock).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
message: "queue changed",
jobId: job.id,
target: expect.objectContaining({ threadId: 456 }),
}),
);
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
} finally {
state.cron.stop();
}
});
it("delivers isolated script notify through the cron webhook path", async () => {
const cfg = createCronConfig("server-cron-script-webhook");
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
loadConfigMock.mockReturnValue(cfg);
cronScriptExecutorMock.mockResolvedValueOnce({
kind: "completed",
notify: "queue changed",
stateChanged: false,
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ release: vi.fn() });
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "script-webhook",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "script", script: "return { notify: 'queue changed' }" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
await state.cron.run(job.id, "force");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
const request = requireRecord(
callArg(fetchWithSsrFGuardMock, 0, 0, "script webhook request"),
"script webhook request",
);
expect(String(requireRecord(request.init, "fetch init").body)).toContain(
'"summary":"queue changed"',
);
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
} finally {
state.cron.stop();
}
});
it("does not deliver a script webhook when notify is absent", async () => {
const cfg = createCronConfig("server-cron-script-webhook-silent");
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
loadConfigMock.mockReturnValue(cfg);
cronScriptExecutorMock.mockResolvedValueOnce({ kind: "completed", stateChanged: false });
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "silent-script-webhook",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "script", script: "return {}" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
await state.cron.run(job.id, "force");
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
} finally {
state.cron.stop();
}
});
it("does not invoke delivery when a script omits notify", async () => {
const cfg = createCronConfig("server-cron-script-silent");
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
loadConfigMock.mockReturnValue(cfg);
cronScriptExecutorMock.mockResolvedValueOnce({ kind: "completed", stateChanged: false });
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "silent-script",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "script", script: "return {}" },
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
await state.cron.run(job.id, "force");
expect(sendCronAnnouncePayloadStrictMock).not.toHaveBeenCalled();
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
} finally {
state.cron.stop();
}
});
it("suppresses command cron NO_REPLY output before webhook delivery", async () => {
const cfg = createCronConfig("server-cron-command-webhook-no-reply");
loadConfigMock.mockReturnValue(cfg);
+97 -6
View File
@@ -31,7 +31,7 @@ import {
resolveCronSessionTargetSessionKey,
} from "../cron/session-target.js";
import { resolveCronJobsStorePath } from "../cron/store.js";
import { createCronTriggerEvaluator } from "../cron/trigger-script.js";
import { createCronScriptRuntime } from "../cron/trigger-script.js";
import type { CronJob, CronPayload } from "../cron/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
@@ -368,9 +368,9 @@ export function buildGatewayCronService(params: {
agentId: agentId ?? defaultAgentId,
});
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
const triggerEvaluator =
const scriptRuntime =
params.cfg.cron?.triggers?.enabled === true
? createCronTriggerEvaluator({ config: params.cfg })
? createCronScriptRuntime({ config: params.cfg })
: undefined;
const runCronChangedHook = (evt: PluginHookCronChangedEvent) => {
@@ -457,10 +457,10 @@ export function buildGatewayCronService(params: {
storePath,
cronEnabled,
cronConfig: params.cfg.cron,
...(triggerEvaluator
...(scriptRuntime
? {
evaluateCronTrigger: ({ job, script, state, abortSignal }) =>
triggerEvaluator({
scriptRuntime.evaluateTrigger({
jobId: job.id,
agentId: job.agentId,
script,
@@ -581,8 +581,8 @@ export function buildGatewayCronService(params: {
{
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
accountId: plan.accountId,
source: "explicit" as const,
},
["channel", "to", "accountId", "threadId", "source"],
@@ -630,6 +630,7 @@ export function buildGatewayCronService(params: {
target: {
channel: plan.channel,
to: plan.to,
threadId: plan.threadId,
accountId: plan.accountId,
sessionKey: resolveCronDeliverySessionKey(job),
},
@@ -670,6 +671,96 @@ export function buildGatewayCronService(params: {
};
}
},
runScriptJob: async ({ job, abortSignal }) => {
if (!scriptRuntime || job.payload.kind !== "script") {
return { status: "error", error: "cron script payload executor is unavailable" };
}
const execution = await scriptRuntime.executePayload({
jobId: job.id,
agentId: job.agentId,
script: job.payload.script,
state: job.state.triggerState,
toolsAllow: job.payload.toolsAllow,
timeoutSeconds: job.payload.timeoutSeconds,
toolBudget: job.payload.toolBudget,
abortSignal,
});
if (execution.kind === "error") {
return {
status: "error",
error: `cron script payload failed (${execution.code}): ${execution.error}`,
};
}
if (execution.nextCheck && !job.pacing) {
return {
status: "error",
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
};
}
const notify = execution.notify?.trim() ? execution.notify : undefined;
const plan = resolveCronDeliveryPlan(job);
const deliveryTrace = {
intended: pickDefined(
{
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit" as const,
},
["channel", "to", "accountId", "threadId", "source"],
),
};
const base = {
status: "ok" as const,
notify,
wake: execution.wake,
stateChanged: execution.stateChanged,
...(execution.stateChanged ? { state: execution.state } : {}),
nextCheck: execution.nextCheck,
delivery: deliveryTrace,
};
if (job.sessionTarget === "main" || plan.mode !== "announce" || !notify) {
return { ...base, deliveryAttempted: false, delivered: false };
}
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
try {
await sendCronAnnouncePayloadStrict({
deps: params.deps,
cfg: runtimeConfig,
agentId,
jobId: job.id,
target: {
channel: plan.channel,
to: plan.to,
threadId: plan.threadId,
accountId: plan.accountId,
sessionKey: resolveCronDeliverySessionKey(job),
},
message: notify,
abortSignal: abortSignal ?? new AbortController().signal,
});
return {
...base,
deliveryAttempted: true,
delivered: true,
delivery: { ...deliveryTrace, delivered: true },
};
} catch (err) {
const error = formatErrorMessage(err);
cronLogger.warn({ jobId: job.id, err: error }, "cron: script payload delivery failed");
return {
...base,
status: job.delivery?.bestEffort ? ("ok" as const) : ("error" as const),
...(job.delivery?.bestEffort ? { deliveryError: error } : { error }),
deliveryAttempted: true,
delivered: false,
delivery: { ...deliveryTrace, delivered: false },
};
}
},
cleanupTimedOutAgentRun: async ({ job, execution }) => {
if (!execution?.sessionId) {
return;
@@ -2175,7 +2175,8 @@ describe("cron method validation", () => {
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: 'isolated/current/session cron jobs require payload.kind="agentTurn"',
messageIncludes:
'isolated cron jobs require payload.kind="agentTurn", "command", or "script"; script payloads do not support current/session targets',
});
});
@@ -427,7 +427,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -656,7 +656,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -670,6 +670,10 @@
"description": "Model override",
"type": "string"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -682,6 +686,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"description": "Allowed tools",
"items": {
@@ -1017,7 +1026,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -1038,6 +1047,10 @@
],
"description": "Model override, or null to clear"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -1050,6 +1063,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"anyOf": [
{
@@ -423,7 +423,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -652,7 +652,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -666,6 +666,10 @@
"description": "Model override",
"type": "string"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -678,6 +682,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"description": "Allowed tools",
"items": {
@@ -1013,7 +1022,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -1034,6 +1043,10 @@
],
"description": "Model override, or null to clear"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -1046,6 +1059,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"anyOf": [
{
@@ -423,7 +423,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -652,7 +652,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -666,6 +666,10 @@
"description": "Model override",
"type": "string"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -678,6 +682,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"description": "Allowed tools",
"items": {
@@ -1013,7 +1022,7 @@
},
"kind": {
"description": "Payload kind",
"enum": ["systemEvent", "agentTurn"],
"enum": ["systemEvent", "agentTurn", "script"],
"type": "string"
},
"lightContext": {
@@ -1034,6 +1043,10 @@
],
"description": "Model override, or null to clear"
},
"script": {
"description": "Headless code-mode script",
"type": "string"
},
"text": {
"description": "systemEvent text",
"type": "string"
@@ -1046,6 +1059,11 @@
"minimum": 0,
"type": "number"
},
"toolBudget": {
"description": "Maximum script tool calls",
"minimum": 1,
"type": "integer"
},
"toolsAllow": {
"anyOf": [
{
@@ -216,8 +216,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 54868,
"roughTokens": 13717
"chars": 55806,
"roughTokens": 13952
},
"openClawDeveloperInstructions": {
"chars": 3559,
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 7021
},
"totalWithDynamicToolsJson": {
"chars": 82954,
"roughTokens": 20739
"chars": 83892,
"roughTokens": 20973
},
"userInputText": {
"chars": 1442,
@@ -216,8 +216,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 54595,
"roughTokens": 13649
"chars": 55533,
"roughTokens": 13884
},
"openClawDeveloperInstructions": {
"chars": 2450,
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6642
},
"totalWithDynamicToolsJson": {
"chars": 81163,
"roughTokens": 20291
"chars": 82101,
"roughTokens": 20526
},
"userInputText": {
"chars": 1033,
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 55885,
"roughTokens": 13972
"chars": 56823,
"roughTokens": 14206
},
"openClawDeveloperInstructions": {
"chars": 2469,
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6777
},
"totalWithDynamicToolsJson": {
"chars": 82992,
"roughTokens": 20748
"chars": 83930,
"roughTokens": 20983
},
"userInputText": {
"chars": 1271,