feat(cron): event triggers — polled condition-watcher scripts via code mode (#101195)

* feat(cron): add headless code-mode driver and trigger-script evaluator

Part A of cron event triggers: runCodeModeScriptHeadless runs a script
to completion (exec/settle/resume, no snapshots, no session), plus the
cron-facing evaluator with per-job tool catalog cache, semaphore, 16KB
state cap, and closed failure taxonomy.

* fix(cron): correct trigger-script bootstrap flags and cache narrowing

* feat(cron): add event triggers (polled condition-watcher scripts)

Part B: trigger field on cron jobs gated by cron.triggers.enabled;
timer evaluates the script each due tick, quiet ticks leave no run
history, fired runs append the script message to the payload; once
semantics, min-interval floor, SQLite columns, RPC/CLI/agent-tool
surfaces, and docs.

* fix(cron): propagate triggerEval through startup catch-up outcomes

* fix(cron): honor cron staggering on quiet trigger ticks; fix trigger test types

* fix(cron): reject with Error reason in trigger-script abort test

* fix(cron): regenerate protocol/docs/snapshot artifacts and break madge cycle for trigger types

CI fixes for #101195: trigger evaluator result types move to the cron
types leaf (madge cycle), cron tool schema inventory gains trigger,
Swift protocol bindings + docs map + Linux prompt snapshots regenerated.

* fix(cron): drop underscore-dangle names in trigger code sync assert

* fix(cron): adopt registerHeadlessToolSearchCatalog after tool-search symbol localization

Upstream #101831 made registerToolSearchCatalog module-private; fold the
headless ref-only catalog registration into one public seam.
This commit is contained in:
Peter Steinberger 2026-07-07 19:12:38 +01:00 committed by GitHub
parent 525f58e9c3
commit a6768d9de5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2827 additions and 86 deletions

View file

@ -7057,6 +7057,7 @@ public struct CronJob: Codable, Sendable {
public let createdatms: Int
public let updatedatms: Int
public let schedule: AnyCodable
public let trigger: [String: AnyCodable]?
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
@ -7088,6 +7089,7 @@ public struct CronJob: Codable, Sendable {
createdatms: Int,
updatedatms: Int,
schedule: AnyCodable,
trigger: [String: AnyCodable]?,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
@ -7118,6 +7120,7 @@ public struct CronJob: Codable, Sendable {
self.createdatms = createdatms
self.updatedatms = updatedatms
self.schedule = schedule
self.trigger = trigger
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
@ -7150,6 +7153,7 @@ public struct CronJob: Codable, Sendable {
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case schedule
case trigger
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
@ -7236,6 +7240,7 @@ public struct CronAddParams: Codable, Sendable {
public let enabled: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let trigger: [String: AnyCodable]?
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
@ -7253,6 +7258,7 @@ public struct CronAddParams: Codable, Sendable {
enabled: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
trigger: [String: AnyCodable]?,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
@ -7269,6 +7275,7 @@ public struct CronAddParams: Codable, Sendable {
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.trigger = trigger
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
@ -7287,6 +7294,7 @@ public struct CronAddParams: Codable, Sendable {
case enabled
case deleteafterrun = "deleteAfterRun"
case schedule
case trigger
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
@ -7394,6 +7402,7 @@ public struct CronRunLogEntry: Codable, Sendable {
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
public let triggerfired: Bool?
public let model: String?
public let provider: String?
public let usage: [String: AnyCodable]?
@ -7418,6 +7427,7 @@ public struct CronRunLogEntry: Codable, Sendable {
runatms: Int?,
durationms: Int?,
nextrunatms: Int?,
triggerfired: Bool?,
model: String?,
provider: String?,
usage: [String: AnyCodable]?,
@ -7441,6 +7451,7 @@ public struct CronRunLogEntry: Codable, Sendable {
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
self.triggerfired = triggerfired
self.model = model
self.provider = provider
self.usage = usage
@ -7466,6 +7477,7 @@ public struct CronRunLogEntry: Codable, Sendable {
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"
case triggerfired = "triggerFired"
case model
case provider
case usage

View file

@ -88,6 +88,41 @@ Cron expressions are parsed by [croner](https://github.com/Hexagon/croner). When
This fires roughly 5-6 times a month instead of 0-1 times a month. To require both conditions, use croner's `+` day-of-week modifier (`0 9 15 * +1`), or schedule on one field and guard the other in your job's prompt or command.
## Event triggers (condition watchers)
An event trigger adds a headless condition script to an `every` or `cron` schedule. Cron evaluates the script when the job is due and runs the normal payload only when the script returns `fire: true`:
```json5
{
schedule: { kind: "every", everyMs: 30000 },
trigger: {
// Fires only when the observed status differs from the last evaluation.
script: "const res = await tools.call('exec', { command: 'gh pr checks 123 --json state -q \\'.[].state\\' | sort -u' }); const status = String(res?.result?.details?.aggregated ?? '').trim(); json({ fire: status !== trigger.state?.status, message: `PR 123 CI: ${trigger.state?.status ?? 'unknown'} -> ${status}`, state: { status } });",
once: false,
},
payload: { kind: "agentTurn", message: "Investigate the CI status change." },
}
```
The script must return `{ fire, message?, state? }`. The previous JSON state is available as the deeply frozen `trigger.state`; return a new `state` value to persist it. State is capped at 16 KB. When a firing result includes `message`, cron appends it to the system-event text or agent-turn message before execution. `once: true` disables the job after its first successful fired payload.
`fire: false` persists evaluation state and counters, then reschedules without creating run history. If a fired payload run fails, the returned `state` is **not** persisted — the next evaluation sees the previous state and can fire again, so write scripts as read-only checks and keep actions in the payload. Trigger schedules have a configurable minimum interval (30 seconds by default). Each evaluation has a 30-second wall-clock budget and up to 5 tool calls.
<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.
</Warning>
Create a watcher from a local script file (`-` reads the script from stdin):
```bash
openclaw cron add \
--name "PR CI watcher" \
--every 30s \
--trigger-script ./watch-pr-ci.js \
--message "Respond to the CI status change" \
--session isolated
```
## Payloads
Every job carries exactly one payload kind, chosen by flag:
@ -497,6 +532,10 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8,
triggers: {
enabled: false,
minIntervalMs: 30000,
},
retry: {
maxAttempts: 3,
backoffMs: [30000, 60000, 300000],

View file

@ -66,6 +66,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: How cron works
- H2: Schedule types
- H3: Day-of-month and day-of-week use OR logic
- H2: Event triggers (condition watchers)
- H2: Payloads
- H3: Agent-turn options
- H3: Command payloads

View file

@ -36,6 +36,38 @@ describe("cron protocol validators", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true);
});
it("accepts trigger add, patch, and clear shapes", () => {
expect(
validateCronAddParams({
...minimalAddParams,
trigger: { script: "json({ fire: true })", once: true },
}),
).toBe(true);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { trigger: { script: "json({ fire: false })" } },
}),
).toBe(true);
expect(validateCronUpdateParams({ id: "job-1", patch: { trigger: null } })).toBe(true);
});
it("rejects invalid trigger scripts and additional properties", () => {
expect(validateCronAddParams({ ...minimalAddParams, trigger: { script: "" } })).toBe(false);
expect(
validateCronAddParams({
...minimalAddParams,
trigger: { script: "json({ fire: true })", unexpected: true },
}),
).toBe(false);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { trigger: { script: "json({ fire: true })", unexpected: true } },
}),
).toBe(false);
});
it("rejects public caller scope on cron admin params", () => {
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);

View file

@ -247,6 +247,15 @@ export const CronScheduleSchema = Type.Union([
),
]);
/** Headless condition script evaluated before a recurring cron payload runs. */
export const CronTriggerSchema = Type.Object(
{
script: Type.String({ minLength: 1, maxLength: 65_536 }),
once: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
/** Full cron payload for new jobs. */
export const CronPayloadSchema = Type.Union([
Type.Object(
@ -431,6 +440,10 @@ export const CronJobStateSchema = Type.Object(
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
triggerState: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
@ -454,6 +467,10 @@ const CronJobStatePatchSchema = Type.Object(
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
triggerState: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
@ -474,6 +491,7 @@ export const CronJobSchema = Type.Object(
createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }),
schedule: CronScheduleSchema,
trigger: Type.Optional(CronTriggerSchema),
sessionTarget: CronSessionTargetSchema,
wakeMode: CronWakeModeSchema,
payload: CronPayloadSchema,
@ -527,6 +545,7 @@ export const CronAddParamsSchema = Type.Object(
owner: Type.Optional(CronOwnerSchema),
...CronCommonOptionalFields,
schedule: CronScheduleSchema,
trigger: Type.Optional(CronTriggerSchema),
sessionTarget: CronSessionTargetSchema,
wakeMode: CronWakeModeSchema,
payload: CronPayloadSchema,
@ -556,6 +575,7 @@ export const CronJobPatchSchema = Type.Object(
displayName: Type.Optional(Type.Union([CronDisplayNameSchema, Type.Null()])),
...CronCommonOptionalFields,
schedule: Type.Optional(CronScheduleSchema),
trigger: Type.Optional(Type.Union([CronTriggerSchema, Type.Null()])),
sessionTarget: Type.Optional(CronSessionTargetSchema),
wakeMode: Type.Optional(CronWakeModeSchema),
payload: Type.Optional(CronPayloadPatchSchema),
@ -621,6 +641,7 @@ export const CronRunLogEntrySchema = Type.Object(
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
triggerFired: Type.Optional(Type.Boolean()),
model: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
usage: Type.Optional(

View file

@ -0,0 +1,288 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { runCodeModeScriptHeadless, testing, type CodeModeHeadlessResult } from "./code-mode.js";
import {
createToolSearchCatalogRef,
registerHeadlessToolSearchCatalog,
type ToolSearchToolContext,
} from "./tool-search.js";
import { jsonResult, type AnyAgentTool } from "./tools/common.js";
function fakeTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
return {
name,
label: name,
description: `Test tool ${name}`,
parameters: { type: "object", properties: {} },
execute: vi.fn(execute) as AnyAgentTool["execute"],
};
}
function createHeadlessHarness(tools: AnyAgentTool[] = []): ToolSearchToolContext {
const config = {
tools: { codeMode: { enabled: false, timeoutMs: 60_000 } },
} as never;
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({ catalogRef, tools });
return {
config,
runtimeConfig: config,
agentId: "main",
catalogRef,
};
}
function expectCompleted(result: CodeModeHeadlessResult) {
expect(result.status).toBe("completed");
if (result.status !== "completed") {
throw new Error(result.error);
}
return result;
}
function expectFailed(result: CodeModeHeadlessResult) {
expect(result.status).toBe("failed");
if (result.status !== "failed") {
throw new Error("expected headless code mode failure");
}
return result;
}
describe("headless Code Mode", () => {
afterEach(() => {
expect(testing.activeRuns.size).toBe(0);
testing.activeRuns.clear();
testing.resumingRunIds.clear();
});
it("completes multi-round tool calls without publishing active runs", async () => {
const first = fakeTool("headless_first", async () => {
expect(testing.activeRuns.size).toBe(0);
return jsonResult({ value: 2 });
});
const second = fakeTool("headless_second", async (_toolCallId, input) => {
expect(testing.activeRuns.size).toBe(0);
return jsonResult({ input });
});
const ctx = createHeadlessHarness([first, second]);
const result = expectCompleted(
await runCodeModeScriptHeadless({
ctx,
code: `
const first = await tools.call("openclaw:core:headless_first", {});
const second = await tools.call("openclaw:core:headless_second", {
value: first.result.details.value,
});
return second.result.details;
`,
wallClockMs: 120_000,
}),
);
expect(result.value).toEqual({ input: { value: 2 } });
expect(result.toolCallCount).toBe(2);
expect(first.execute).toHaveBeenCalledOnce();
expect(second.execute).toHaveBeenCalledOnce();
});
it("injects deeply frozen trigger state and emits replacement state through json", async () => {
const result = expectCompleted(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness(),
code: `
json({
fire: true,
frozen: Object.isFrozen(trigger) &&
Object.isFrozen(trigger.state) &&
Object.isFrozen(trigger.state.nested),
emptyKey: trigger.state[""],
state: { count: trigger.state.count + 1 },
});
return "done";
`,
extraNamespaces: [
{
id: "cron:trigger",
globalName: "trigger",
scope: {
kind: "object",
entries: [
[
"state",
{
kind: "value",
value: { "": 7, count: 4, nested: { stable: true } },
},
],
],
},
},
],
}),
);
expect(result.output).toEqual([
{
type: "json",
value: { fire: true, frozen: true, emptyKey: 7, state: { count: 5 } },
},
]);
});
it("rejects colliding injected namespace globals", async () => {
const result = expectFailed(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness(),
code: "return true;",
extraNamespaces: [
{
id: "cron:trigger",
globalName: "trigger",
scope: { kind: "object", entries: [] },
},
{
id: "plugin:trigger",
globalName: "trigger",
scope: { kind: "object", entries: [] },
},
],
}),
);
expect(result.code).toBe("invalid_input");
expect(result.error).toContain("namespace collision");
});
it("fails before settling tool calls beyond the total budget", async () => {
const tool = fakeTool("budgeted", async () => jsonResult({ ok: true }));
const result = expectFailed(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness([tool]),
code: `
await tools.call("openclaw:core:budgeted", {});
await tools.call("openclaw:core:budgeted", {});
return true;
`,
maxToolCalls: 1,
wallClockMs: 120_000,
}),
);
expect(result.code).toBe("tool_budget_exceeded");
expect(result.toolCallCount).toBe(2);
expect(tool.execute).toHaveBeenCalledOnce();
});
it("enforces one wall-clock deadline across worker and tool legs", async () => {
const slow = fakeTool("slow_leg", async (_toolCallId, _input, signal) => {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 30_000);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("aborted"));
},
{ once: true },
);
});
return jsonResult({ ok: true });
});
const result = expectFailed(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness([slow]),
code: `
await tools.call("openclaw:core:slow_leg", {});
return true;
`,
wallClockMs: 15_000,
}),
);
expect(result.code).toBe("timeout");
expect(result.toolCallCount).toBe(1);
});
it("settles yield_control inline and resumes to completion", async () => {
const result = expectCompleted(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness(),
code: `
const yielded = await yield_control("pause");
return { yielded, resumed: true };
`,
}),
);
expect(result.value).toEqual({
yielded: { status: "yielded", reason: "pause" },
resumed: true,
});
expect(result.toolCallCount).toBe(0);
});
it("terminates an in-flight worker leg when aborted", async () => {
const ctx = createHeadlessHarness();
const config = testing.resolveCodeModeHeadlessConfig(ctx);
const controller = new AbortController();
const resultPromise = testing.runCodeModeWorker(
{
kind: "exec",
source: "while (true) {}",
config,
catalog: [],
apiFiles: [],
namespaces: [],
},
5000,
undefined,
controller.signal,
);
setTimeout(() => controller.abort(), 100);
await expect(resultPromise).resolves.toMatchObject({ status: "failed", code: "timeout" });
});
it.each([
{
name: "syntax errors",
code: "return (;",
expectedCode: "internal_error",
overrides: undefined,
},
{
name: "output overages",
code: `text("x".repeat(2048)); return true;`,
expectedCode: "output_limit_exceeded",
overrides: { maxOutputBytes: 1024 },
},
])("classifies $name", async ({ code, expectedCode, overrides }) => {
const result = expectFailed(
await runCodeModeScriptHeadless({
ctx: createHeadlessHarness(),
code,
overrides,
}),
);
expect(result.code).toBe(expectedCode);
});
it("clamps headless limit overrides to worker-safe bounds", () => {
const config = testing.resolveCodeModeHeadlessConfig(createHeadlessHarness(), {
timeoutMs: 1,
memoryLimitBytes: 1,
maxOutputBytes: 1,
maxSnapshotBytes: 1,
maxPendingToolCalls: 999,
});
expect(config).toMatchObject({
timeoutMs: 100,
memoryLimitBytes: 1024 * 1024,
maxOutputBytes: 1024,
maxSnapshotBytes: 1024,
maxPendingToolCalls: 128,
});
});
});

View file

@ -29,7 +29,9 @@ import {
createCodeModeApiVirtualFiles,
createCodeModeNamespaceRuntime,
describeCodeModeNamespacesForPrompt,
type CodeModeNamespaceDescriptor,
type CodeModeNamespaceRuntime,
type SerializedCodeModeNamespaceValue,
} from "./code-mode-namespaces.js";
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { optionalStringEnum } from "./schema/typebox.js";
@ -123,7 +125,7 @@ type CodeModeRunState = {
type CodeModeToolContext = ToolSearchToolContext;
type CodeModeFailureCode =
export type CodeModeFailureCode =
| "invalid_input"
| "runtime_unavailable"
| "timeout"
@ -131,6 +133,21 @@ type CodeModeFailureCode =
| "snapshot_limit_exceeded"
| "internal_error";
export type CodeModeHeadlessResult =
| {
status: "completed";
value: unknown;
output: unknown[];
toolCallCount: number;
}
| {
status: "failed";
code: CodeModeFailureCode | "tool_budget_exceeded";
error: string;
output: unknown[];
toolCallCount: number;
};
type CodeModeWorkerResult =
| {
status: "completed";
@ -255,6 +272,46 @@ function toToolSearchConfig(config: CodeModeConfig): ToolSearchConfig {
};
}
function resolveCodeModeHeadlessConfig(
ctx: ToolSearchToolContext,
overrides?: Partial<
Pick<
CodeModeConfig,
| "timeoutMs"
| "memoryLimitBytes"
| "maxOutputBytes"
| "maxSnapshotBytes"
| "maxPendingToolCalls"
>
>,
): CodeModeConfig {
const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId);
return {
...base,
timeoutMs: clampNumber(readPositiveInteger(overrides?.timeoutMs, base.timeoutMs), 100, 60_000),
memoryLimitBytes: clampNumber(
readPositiveInteger(overrides?.memoryLimitBytes, base.memoryLimitBytes),
1024 * 1024,
1024 * 1024 * 1024,
),
maxOutputBytes: clampNumber(
readPositiveInteger(overrides?.maxOutputBytes, base.maxOutputBytes),
1024,
10 * 1024 * 1024,
),
maxSnapshotBytes: clampNumber(
readPositiveInteger(overrides?.maxSnapshotBytes, base.maxSnapshotBytes),
1024,
256 * 1024 * 1024,
),
maxPendingToolCalls: clampNumber(
readPositiveInteger(overrides?.maxPendingToolCalls, base.maxPendingToolCalls),
1,
128,
),
};
}
function removeExpiredRuns(now = Date.now()): void {
for (const [runId, state] of activeRuns) {
if (!isFutureDateTimestampMs(state.expiresAt, { nowMs: now })) {
@ -646,6 +703,7 @@ async function runCodeModeWorker(
workerData: unknown,
timeoutMs: number,
workerUrl?: URL,
signal?: AbortSignal,
): Promise<CodeModeWorkerResult> {
const resolvedWorkerUrl = workerUrl ?? codeModeWorkerUrl();
const sourceWorkerExecArgv = resolvedWorkerUrl.pathname.endsWith(".ts")
@ -661,6 +719,7 @@ async function runCodeModeWorker(
return failedCodeModeWorkerResult(error, "runtime_unavailable");
}
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
return await new Promise<CodeModeWorkerResult>((resolve) => {
let settled = false;
@ -680,6 +739,19 @@ async function runCodeModeWorker(
output: [],
});
}, timeoutMs);
onAbort = () => {
void worker.terminate();
finish({
status: "failed",
error: "code mode timeout exceeded",
code: "timeout",
output: [],
});
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
}
worker.once("message", (message: unknown) => {
void worker.terminate();
const result = isRecord(message)
@ -710,6 +782,305 @@ async function runCodeModeWorker(
if (timer) {
clearTimeout(timer);
}
if (onAbort) {
signal?.removeEventListener("abort", onAbort);
}
}
}
class CodeModeHeadlessTimeoutError extends Error {
constructor(message = "code mode headless wall-clock timeout exceeded") {
super(message);
this.name = "CodeModeHeadlessTimeoutError";
}
}
function createHeadlessAbortScope(signal: AbortSignal | undefined, wallClockMs: number) {
const controller = new AbortController();
const onAbort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
}
const timer = setTimeout(() => controller.abort(new CodeModeHeadlessTimeoutError()), wallClockMs);
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
},
};
}
function headlessAbortError(signal: AbortSignal): CodeModeHeadlessTimeoutError {
return signal.reason instanceof CodeModeHeadlessTimeoutError
? signal.reason
: new CodeModeHeadlessTimeoutError("code mode headless execution aborted");
}
function headlessFailure(params: {
code: CodeModeFailureCode | "tool_budget_exceeded";
error: string;
output: unknown[];
toolCallCount: number;
}): CodeModeHeadlessResult {
return { status: "failed", ...params };
}
function remainingHeadlessMs(deadline: number): number {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new CodeModeHeadlessTimeoutError();
}
return remaining;
}
async function awaitHeadlessDeadline<T>(params: {
promise: Promise<T>;
deadline: number;
signal?: AbortSignal;
}): Promise<T> {
const remainingMs = remainingHeadlessMs(params.deadline);
if (params.signal?.aborted) {
throw headlessAbortError(params.signal);
}
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new CodeModeHeadlessTimeoutError()), remainingMs);
const signal = params.signal;
if (signal) {
onAbort = () => reject(headlessAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
}
});
return await Promise.race([params.promise, timeout]);
} finally {
if (timer) {
clearTimeout(timer);
}
if (params.signal && onAbort) {
params.signal.removeEventListener("abort", onAbort);
}
}
}
async function runHeadlessWorkerLeg(params: {
input: Record<string, unknown>;
config: CodeModeConfig;
deadline: number;
signal: AbortSignal;
}): Promise<CodeModeWorkerResult> {
const remainingMs = remainingHeadlessMs(params.deadline);
const timeoutMs = Math.max(1, Math.min(params.config.timeoutMs, remainingMs));
const workerTimeoutMs = Math.max(1, Math.min(remainingMs, timeoutMs + 1000));
return await runCodeModeWorker(
{
...params.input,
config: { ...params.config, timeoutMs },
},
workerTimeoutMs,
undefined,
params.signal,
);
}
function normalizeHeadlessNamespaceValue(
descriptor: SerializedCodeModeNamespaceValue,
): SerializedCodeModeNamespaceValue {
if (descriptor.kind === "array") {
return { kind: "array", items: descriptor.items.map(normalizeHeadlessNamespaceValue) };
}
if (descriptor.kind === "object") {
return {
kind: "object",
entries: descriptor.entries.map(([key, value]) => {
if (!key) {
throw new ToolInputError("code mode namespace descriptor keys must not be empty");
}
return [key, normalizeHeadlessNamespaceValue(value)];
}),
};
}
if (descriptor.kind !== "value") {
return descriptor;
}
return { kind: "value", value: toCodeModeJsonSafe(descriptor.value) };
}
function normalizeHeadlessNamespace(
descriptor: CodeModeNamespaceDescriptor,
): CodeModeNamespaceDescriptor {
return { ...descriptor, scope: normalizeHeadlessNamespaceValue(descriptor.scope) };
}
function mergeHeadlessNamespaces(
registered: CodeModeNamespaceDescriptor[],
extra: CodeModeNamespaceDescriptor[],
): CodeModeNamespaceDescriptor[] {
const ids = new Set(registered.map((descriptor) => descriptor.id));
const globalNames = new Set(registered.map((descriptor) => descriptor.globalName));
const merged = [...registered];
for (const descriptor of extra) {
if (ids.has(descriptor.id) || globalNames.has(descriptor.globalName)) {
throw new ToolInputError(
`code mode namespace collision for ${descriptor.id} (${descriptor.globalName})`,
);
}
ids.add(descriptor.id);
globalNames.add(descriptor.globalName);
merged.push(normalizeHeadlessNamespace(descriptor));
}
return merged;
}
function headlessNamespaceFreezePrelude(descriptors: CodeModeNamespaceDescriptor[]): string {
const globalNames = JSON.stringify(descriptors.map((descriptor) => descriptor.globalName));
return `;(() => {
const seen = new WeakSet();
const freeze = (value) => {
if ((value === null || (typeof value !== "object" && typeof value !== "function")) || seen.has(value)) return value;
seen.add(value);
for (const key of Object.keys(value)) freeze(value[key]);
return Object.freeze(value);
};
for (const name of ${globalNames}) freeze(globalThis[name]);
})();\n`;
}
/** Run Code Mode to completion without publishing resumable snapshot state. */
export async function runCodeModeScriptHeadless(params: {
ctx: ToolSearchToolContext;
code: string;
language?: "javascript" | "typescript";
overrides?: Partial<
Pick<
CodeModeConfig,
| "timeoutMs"
| "memoryLimitBytes"
| "maxOutputBytes"
| "maxSnapshotBytes"
| "maxPendingToolCalls"
>
>;
wallClockMs?: number;
maxToolCalls?: number;
extraNamespaces?: CodeModeNamespaceDescriptor[];
signal?: AbortSignal;
}): Promise<CodeModeHeadlessResult> {
const config = resolveCodeModeHeadlessConfig(params.ctx, params.overrides);
const wallClockMs = clampNumber(readPositiveInteger(params.wallClockMs, 30_000), 1, 300_000);
const maxToolCalls = clampNumber(readPositiveInteger(params.maxToolCalls, 5), 1, 128);
const deadline = Date.now() + wallClockMs;
const abortScope = createHeadlessAbortScope(params.signal, wallClockMs);
const output: unknown[] = [];
let toolCallCount = 0;
try {
const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
const catalog = runtime.all({ includeMcp: false });
const namespaceCatalog = runtime.namespaceEntries();
const namespaceRuntime = await awaitHeadlessDeadline({
promise: createCodeModeNamespaceRuntime(params.ctx, namespaceCatalog),
deadline,
signal: abortScope.signal,
});
const preparedSource = await awaitHeadlessDeadline({
promise: prepareSource({ code: params.code, language: params.language, config }),
deadline,
signal: abortScope.signal,
});
const namespaces = mergeHeadlessNamespaces(
namespaceRuntime.descriptors,
params.extraNamespaces ?? [],
);
const source = `${headlessNamespaceFreezePrelude(namespaces)}${preparedSource}`;
const parentToolCallId = `headless:${randomUUID()}`;
let result = normalizeCodeModeWorkerResult(
await runHeadlessWorkerLeg({
input: {
kind: "exec",
source,
catalog,
apiFiles: createCodeModeApiVirtualFiles(namespaceCatalog),
namespaces,
},
config,
deadline,
signal: abortScope.signal,
}),
);
while (true) {
output.push(...result.output);
enforceOutputLimit(output, config);
if (result.status === "completed") {
enforceResultLimit({ output, value: result.value, config });
return { status: "completed", value: result.value, output, toolCallCount };
}
if (result.status === "failed") {
return headlessFailure({
code: result.code,
error: result.error,
output,
toolCallCount,
});
}
enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config, output });
const requestedToolCalls = result.pendingRequests.filter(
(request) => request.method === "call" || request.method === "namespace",
).length;
toolCallCount += requestedToolCalls;
if (toolCallCount > maxToolCalls) {
return headlessFailure({
code: "tool_budget_exceeded",
error: `code mode headless tool budget exceeded (${maxToolCalls})`,
output,
toolCallCount,
});
}
const settledRequests = await awaitHeadlessDeadline({
promise: Promise.all(
result.pendingRequests.map((request) =>
runBridgeRequest({
runtime,
namespaceRuntime,
parentToolCallId,
request,
signal: abortScope.signal,
}),
),
),
deadline,
signal: abortScope.signal,
});
result = normalizeCodeModeWorkerResult(
await runHeadlessWorkerLeg({
input: {
kind: "resume",
snapshotBytes: result.snapshotBytes,
settledRequests,
},
config,
deadline,
signal: abortScope.signal,
}),
);
}
} catch (error) {
return headlessFailure({
code: error instanceof CodeModeHeadlessTimeoutError ? "timeout" : codeModeFailureCode(error),
error:
error instanceof CodeModeHeadlessTimeoutError
? error.message
: codeModeFailureMessage(error),
output,
toolCallCount,
});
} finally {
abortScope.cleanup();
}
}
@ -1272,6 +1643,7 @@ export const testing = {
codeModeWorkerUrl,
normalizeCodeModeWorkerResult,
runCodeModeWorker,
resolveCodeModeHeadlessConfig,
resolveCodeModeWorkerUrl,
resolveCodeModeConfig,
getTypescriptRuntimePromise: (): Promise<typeof import("typescript")> | null =>

View file

@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js";
import {
isToolWrappedWithBeforeToolCallHook,
rewrapToolWithBeforeToolCallHook,
type HookContext,
wrapToolWithBeforeToolCallHook,
} from "./agent-tools.before-tool-call.js";
@ -712,6 +713,29 @@ function shouldCatalogTool(tool: AnyAgentTool): boolean {
return true;
}
/**
* Register a catalog owned only by an explicit ref (no session keys), for
* headless callers like cron trigger evaluation. Registration internals stay
* module-private; this is the single public seam for ref-only catalogs.
*/
export function registerHeadlessToolSearchCatalog(params: {
catalogRef: ToolSearchCatalogRef;
tools: readonly AnyAgentTool[];
hookContext?: HookContext;
}): void {
const { catalogRef, tools, hookContext } = params;
const entries = tools
.filter((tool) => shouldCatalogTool(tool))
.map((tool) => {
const scopedTool =
hookContext && isToolWrappedWithBeforeToolCallHook(tool)
? rewrapToolWithBeforeToolCallHook(tool, hookContext)
: tool;
return toCatalogEntry(scopedTool, undefined, hookContext);
});
registerToolSearchCatalog({ catalogRef, entries });
}
export function collectUniqueCatalogToolNames(tools: readonly AnyAgentTool[]): Set<string> {
const nameCounts = new Map<string, number>();
for (const tool of tools) {

View file

@ -58,6 +58,7 @@ describe("createCronToolSchema", () => {
"schedule",
"sessionKey",
"sessionTarget",
"trigger",
"wakeMode",
].toSorted(),
);
@ -78,6 +79,7 @@ describe("createCronToolSchema", () => {
"schedule",
"sessionKey",
"sessionTarget",
"trigger",
"wakeMode",
].toSorted(),
);

View file

@ -170,6 +170,17 @@ function createCronPayloadSchema(): TSchema {
);
}
function createCronTriggerSchema(params: { nullableClears: boolean }): TSchema {
const trigger = Type.Object(
{
script: Type.String({ minLength: 1, maxLength: 65_536 }),
once: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
return Type.Optional(params.nullableClears ? Type.Union([trigger, Type.Null()]) : trigger);
}
function cronDeliverySchema(params: { nullableClears: boolean }) {
const failureDestinationObject = Type.Object(
{
@ -281,6 +292,7 @@ function createCronJobObjectSchema(): TSchema {
),
),
schedule: createCronScheduleSchema(),
trigger: createCronTriggerSchema({ nullableClears: false }),
sessionTarget: Type.Optional(
Type.String({
description: "main | isolated | current | session:<id>",
@ -312,6 +324,7 @@ function createCronPatchObjectSchema(): TSchema {
}),
),
schedule: createCronScheduleSchema(),
trigger: createCronTriggerSchema({ nullableClears: true }),
sessionTarget: Type.Optional(Type.String({ description: "Session target" })),
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
payload: Type.Optional(
@ -901,6 +914,7 @@ JOB SCHEMA (for add action):
{
"name": "string",
"schedule": { ... }, // required
"trigger": { "script": "...", "once": false }, // optional condition gate for every/cron
"payload": { ... }, // required
"delivery": { ... }, // optional announce for isolated/current/session, webhook for any target
"sessionTarget": "main" | "isolated" | "current" | "session:<id>",
@ -929,6 +943,8 @@ SCHEDULE TYPES (schedule.kind):
tz omitted => Gateway host local timezone, not UTC.
Example 6pm Shanghai daily: { "kind": "cron", "expr": "0 18 * * *", "tz": "Asia/Shanghai" }
Optional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.
For "at", ISO timestamps without timezone are UTC.
PAYLOAD TYPES (payload.kind):

View file

@ -26,6 +26,7 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
import { readCronTriggerScript } from "./trigger-options.js";
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
@ -113,6 +114,8 @@ export function registerCronAddCommand(cron: Command) {
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
.option("--trigger-script <path|->", "Condition script file, or - for stdin")
.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("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
@ -382,6 +385,16 @@ export function registerCronAddCommand(cron: Command) {
}
const sessionKey = normalizeOptionalString(opts.sessionKey);
const triggerScriptPath = normalizeOptionalString(opts.triggerScript);
if (opts.triggerOnce && !triggerScriptPath) {
throw new Error("--trigger-once requires --trigger-script");
}
const trigger = triggerScriptPath
? {
script: await readCronTriggerScript(triggerScriptPath),
...(opts.triggerOnce ? { once: true } : {}),
}
: undefined;
if ((payload.kind === "agentTurn" || payload.kind === "command") && !agentId) {
defaultRuntime.error(
@ -404,6 +417,7 @@ export function registerCronAddCommand(cron: Command) {
agentId,
sessionKey,
schedule,
trigger,
sessionTarget,
wakeMode,
payload,

View file

@ -24,6 +24,7 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } 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;
@ -118,6 +119,9 @@ export function registerCronEditCommand(cron: Command) {
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)")
.option("--trigger-script <path|->", "Set condition script from file, or - for stdin")
.option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--clear-trigger", "Remove the condition trigger", false)
.option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message")
.option("--command <shell>", "Set command payload run as sh -lc <shell> on the Gateway")
@ -276,6 +280,25 @@ export function registerCronEditCommand(cron: Command) {
patch.sessionKey = null;
}
const triggerScriptPath = normalizeOptionalString(opts.triggerScript);
if (opts.clearTrigger && (triggerScriptPath || opts.triggerOnce)) {
throw new Error("Use --clear-trigger or trigger options, not both");
}
if (opts.clearTrigger) {
patch.trigger = null;
} else if (triggerScriptPath) {
patch.trigger = {
script: await readCronTriggerScript(triggerScriptPath),
...(opts.triggerOnce ? { once: true } : {}),
};
} else if (opts.triggerOnce) {
const existing = await readCronJobForEdit(opts, String(id));
if (!existing.trigger) {
throw new Error("--trigger-once requires an existing trigger or --trigger-script");
}
patch.trigger = { ...existing.trigger, once: true };
}
const scheduleRequest = resolveCronEditScheduleRequest({
at: opts.at,
cron: opts.cron,

View file

@ -155,6 +155,26 @@ describe("printCronList", () => {
expectLogsToInclude(logs, "(stagger 5m)");
});
it("marks trigger schedules and shows evaluation details", () => {
const job = createBaseJob({
schedule: { kind: "every", everyMs: 30_000 },
trigger: { script: "json({ fire: true })", once: true },
state: {
triggerEvalCount: 4,
lastTriggerEvalAtMs: Date.now() - 30_000,
lastTriggerFireAtMs: Date.now() - 60_000,
},
});
const list = createRuntimeLogCapture();
printCronList([job], list.runtime);
expectLogsToInclude(list.logs, "every 30s+trigger");
const show = createRuntimeLogCapture();
printCronShow(job, show.runtime);
expectLogsToInclude(show.logs, "trigger: once=yes; evals=4;");
});
it("shows on-exit schedules in list and show output", () => {
const job = createBaseJob({
id: "on-exit-job",

View file

@ -397,12 +397,13 @@ const formatRelative = (ms: number | null | undefined, nowMs: number) => {
return delta >= 0 ? `in ${label}` : `${label} ago`;
};
const formatSchedule = (schedule: CronSchedule | undefined) => {
const formatSchedule = (schedule: CronSchedule | undefined, hasTrigger = false) => {
const suffix = hasTrigger ? "+trigger" : "";
if (schedule?.kind === "at") {
return `at ${formatIsoMinute(schedule.at)}`;
return `at ${formatIsoMinute(schedule.at)}${suffix}`;
}
if (schedule?.kind === "every") {
return `every ${formatDurationHuman(schedule.everyMs)}`;
return `every ${formatDurationHuman(schedule.everyMs)}${suffix}`;
}
if (schedule?.kind === "on-exit") {
const cwd = schedule.cwd ? ` @ ${schedule.cwd}` : "";
@ -411,7 +412,9 @@ const formatSchedule = (schedule: CronSchedule | undefined) => {
if (schedule?.kind !== "cron") {
return "-";
}
const base = schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
const base = schedule.tz
? `cron ${schedule.expr} @ ${schedule.tz}${suffix}`
: `cron ${schedule.expr}${suffix}`;
const staggerMs = resolveCronStaggerMs(schedule);
if (staggerMs <= 0) {
return `${base} (exact)`;
@ -482,7 +485,7 @@ export function printCronList(
CRON_NAME_PAD,
);
const scheduleLabel = pad(
truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
truncate(formatSchedule(job.schedule, job.trigger !== undefined), CRON_SCHEDULE_PAD),
CRON_SCHEDULE_PAD,
);
const nextLabel = pad(
@ -571,7 +574,10 @@ export function printCronShow(
runtime.log(`owner agent: ${job.owner?.agentId ?? "-"}`);
runtime.log(`owner session: ${job.owner?.sessionKey ?? "-"}`);
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
runtime.log(`schedule: ${formatSchedule(job.schedule, job.trigger !== undefined)}`);
runtime.log(
`trigger: ${job.trigger ? `once=${job.trigger.once === true ? "yes" : "no"}; evals=${job.state.triggerEvalCount ?? 0}; last eval=${formatRelative(job.state.lastTriggerEvalAtMs, Date.now())}; last fire=${formatRelative(job.state.lastTriggerFireAtMs, Date.now())}` : "-"}`,
);
runtime.log(`session: ${job.sessionTarget ?? "-"}`);
runtime.log(`agent: ${job.agentId ?? "-"}`);
runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);

View file

@ -0,0 +1,79 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn();
vi.mock("../gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("../gateway-rpc.js")>("../gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
callGatewayFromCli(...args),
};
});
const { registerCronAddCommand } = await import("./register.cron-add.js");
const { registerCronEditCommand } = await import("./register.cron-edit.js");
describe("cron trigger CLI options", () => {
let fixtureRoot = "";
beforeEach(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-trigger-cli-"));
callGatewayFromCli.mockReset();
callGatewayFromCli.mockResolvedValue({ ok: true });
});
afterEach(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("reads --trigger-script client-side and sends trigger metadata on add", async () => {
const scriptPath = path.join(fixtureRoot, "watch.js");
await fs.writeFile(scriptPath, " json({ fire: true }) \n", "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
await program.parseAsync(
[
"add",
"--name",
"watcher",
"--every",
"30s",
"--trigger-script",
scriptPath,
"--trigger-once",
"--system-event",
"changed",
"--session",
"main",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.add",
expect.objectContaining({ triggerScript: scriptPath, triggerOnce: true }),
expect.objectContaining({
trigger: { script: "json({ fire: true })", once: true },
}),
);
});
it("maps --clear-trigger to a nullable edit patch", async () => {
const program = new Command().exitOverride();
registerCronEditCommand(program);
await program.parseAsync(["edit", "job-1", "--clear-trigger"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ clearTrigger: true }),
{ id: "job-1", patch: { trigger: null } },
);
});
});

View file

@ -0,0 +1,40 @@
// Client-side trigger script loading for cron create/edit commands.
import fs from "node:fs/promises";
const MAX_CRON_TRIGGER_SCRIPT_BYTES = 65_536;
async function readStdin(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.byteLength;
if (total > MAX_CRON_TRIGGER_SCRIPT_BYTES) {
throw new Error("Trigger script exceeds 65536 bytes");
}
chunks.push(buffer);
}
return Buffer.concat(chunks, total).toString("utf8");
}
/** Reads a trigger script locally before sending the cron RPC. */
export async function readCronTriggerScript(
source: string,
deps?: {
readFile?: (path: string) => Promise<string>;
stdin?: NodeJS.ReadableStream;
},
): Promise<string> {
const raw =
source === "-"
? await readStdin(deps?.stdin ?? process.stdin)
: await (deps?.readFile ?? ((path) => fs.readFile(path, "utf8")))(source);
if (Buffer.byteLength(raw, "utf8") > MAX_CRON_TRIGGER_SCRIPT_BYTES) {
throw new Error("Trigger script exceeds 65536 bytes");
}
const script = raw.trim();
if (!script) {
throw new Error("Trigger script must not be empty");
}
return script;
}

View file

@ -3,6 +3,7 @@ import type { CronConfig } from "./types.cron.js";
/** Default maximum number of cron jobs allowed to run at once. */
export const DEFAULT_CRON_MAX_CONCURRENT_RUNS = 8;
export const DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS = 30_000;
/** Resolves cron concurrency config, flooring finite values and clamping to at least one. */
export function resolveCronMaxConcurrentRuns(
@ -14,3 +15,12 @@ export function resolveCronMaxConcurrentRuns(
}
return DEFAULT_CRON_MAX_CONCURRENT_RUNS;
}
/** Resolves the minimum cadence for trigger-bearing cron jobs. */
export function resolveCronTriggerMinIntervalMs(cronConfig?: Pick<CronConfig, "triggers">): number {
const raw = cronConfig?.triggers?.minIntervalMs;
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(1, Math.floor(raw));
}
return DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS;
}

View file

@ -33,6 +33,10 @@ export type CronConfig = {
enabled?: boolean;
store?: string;
maxConcurrentRuns?: number;
triggers?: {
enabled?: boolean;
minIntervalMs?: number;
};
/** Override default retry policy for one-shot jobs on transient errors. */
retry?: CronRetryConfig;
/**

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { OpenClawSchema } from "./zod-schema.js";
describe("OpenClawSchema cron triggers", () => {
it("accepts the strict trigger gate and interval floor", () => {
expect(
OpenClawSchema.parse({ cron: { triggers: { enabled: true, minIntervalMs: 45_000 } } }).cron
?.triggers,
).toEqual({ enabled: true, minIntervalMs: 45_000 });
});
it("rejects invalid and unknown trigger settings", () => {
expect(OpenClawSchema.safeParse({ cron: { triggers: { minIntervalMs: 0 } } }).success).toBe(
false,
);
expect(
OpenClawSchema.safeParse({ cron: { triggers: { enabled: true, extra: true } } }).success,
).toBe(false);
});
});

View file

@ -921,6 +921,13 @@ export const OpenClawSchema = z
enabled: z.boolean().optional(),
store: z.string().optional(),
maxConcurrentRuns: z.number().int().positive().optional(),
triggers: z
.object({
enabled: z.boolean().optional(),
minIntervalMs: z.number().int().positive().optional(),
})
.strict()
.optional(),
retry: z
.object({
maxAttempts: z.number().int().min(0).max(10).optional(),

View file

@ -1,9 +1,27 @@
/** Builds isolated cron runner config from global defaults plus agent overrides. */
import type { resolveAgentConfig } from "../../agents/agent-scope.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../../config/config.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
type ResolvedAgentConfig = NonNullable<ReturnType<typeof resolveAgentConfig>>;
/** Selects the active reloadable config when it descends from the cron caller's snapshot. */
export function resolveCronActiveRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
if (!runtimeConfig || !runtimeSourceConfig) {
return cfg;
}
return (
selectApplicableRuntimeConfig({ inputConfig: cfg, runtimeConfig, runtimeSourceConfig }) ?? cfg
);
}
function extractCronAgentDefaultsOverride(agentConfigOverride?: ResolvedAgentConfig) {
const {
model: overrideModel,

View file

@ -11,11 +11,6 @@ import { expandToolGroups, normalizeToolName } from "../../agents/tool-policy.js
import { deriveContextPromptTokens } from "../../agents/usage.js";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { CliDeps } from "../../cli/outbound-send-deps.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../../config/config.js";
import { resolveAgentModelPrimaryValue } from "../../config/model-input.js";
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
@ -80,7 +75,7 @@ import {
resolveHeartbeatAckMaxChars,
} from "./helpers.js";
import { resolveCronModelSelection } from "./model-selection.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import { buildCronAgentDefaultsConfig, resolveCronActiveRuntimeConfig } from "./run-config.js";
import { resolveCronPreflightCandidates } from "./run-fallback-policy.js";
import {
adoptCronRunSessionMetadata,
@ -570,21 +565,6 @@ type CronPreparationResult =
| { ok: true; context: PreparedCronRunContext }
| { ok: false; result: RunCronAgentTurnResult };
function resolveCronActiveRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
if (!runtimeConfig || !runtimeSourceConfig) {
return cfg;
}
return (
selectApplicableRuntimeConfig({
inputConfig: cfg,
runtimeConfig,
runtimeSourceConfig,
}) ?? cfg
);
}
async function prepareCronRunContext(params: {
input: RunCronAgentTurnParams;
isFastTestEnv: boolean;

View file

@ -73,6 +73,21 @@ function normalizeMainSystemEventCreateJob(params: {
}
describe("normalizeCronJobCreate", () => {
it("normalizes trigger scripts and preserves patch clears", () => {
const normalized = normalizeCronJobCreate({
name: "watcher",
enabled: true,
schedule: { kind: "every", everyMs: 30_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "changed" },
trigger: { script: " json({ fire: true }) ", once: "true", ignored: true },
}) as unknown as Record<string, unknown>;
expect(normalized.trigger).toEqual({ script: "json({ fire: true })", once: true });
expect(normalizeCronJobPatch({ trigger: null })).toEqual({ trigger: null });
});
it("trims agentId and drops null", () => {
const normalized = normalizeCronJobCreate({
name: "agent-set",

View file

@ -353,6 +353,15 @@ function coercePayload(payload: UnknownRecord) {
return next;
}
function coerceTrigger(trigger: UnknownRecord): UnknownRecord {
const script = typeof trigger.script === "string" ? trigger.script.trim() : "";
const once = parseBoolean(trigger.once);
return {
script,
...(once !== undefined ? { once } : {}),
};
}
function coerceDelivery(delivery: UnknownRecord) {
const next: UnknownRecord = { ...delivery };
const parsed = parseDeliveryInput(delivery);
@ -615,6 +624,16 @@ export function normalizeCronJobInput(
next.payload = coercePayload(base.payload);
}
if ("trigger" in base) {
if (base.trigger === null) {
next.trigger = null;
} else if (isRecord(base.trigger)) {
next.trigger = coerceTrigger(base.trigger);
} else {
delete next.trigger;
}
}
if (isRecord(base.delivery)) {
next.delivery = coerceDelivery(base.delivery);
}

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getInvalidPersistedCronJobReason } from "./persisted-shape.js";
function candidate(overrides: Record<string, unknown> = {}) {
return {
id: "job-1",
schedule: { kind: "every", everyMs: 30_000 },
trigger: { script: "json({ fire: true })" },
payload: { kind: "systemEvent", text: "changed" },
sessionTarget: "main",
...overrides,
};
}
describe("persisted cron trigger shape", () => {
it("accepts a non-empty trigger on recurring schedules", () => {
expect(getInvalidPersistedCronJobReason(candidate())).toBeNull();
expect(
getInvalidPersistedCronJobReason(
candidate({ schedule: { kind: "cron", expr: "* * * * *" } }),
),
).toBeNull();
});
it("rejects empty scripts and non-object triggers", () => {
expect(getInvalidPersistedCronJobReason(candidate({ trigger: { script: " " } }))).toBe(
"invalid-trigger",
);
expect(getInvalidPersistedCronJobReason(candidate({ trigger: "script" }))).toBe(
"invalid-trigger",
);
});
it("rejects triggers on at and on-exit schedules", () => {
expect(
getInvalidPersistedCronJobReason(
candidate({ schedule: { kind: "at", at: "2026-08-01T00:00:00.000Z" } }),
),
).toBe("invalid-trigger");
expect(
getInvalidPersistedCronJobReason(
candidate({ schedule: { kind: "on-exit", command: "make build" } }),
),
).toBe("invalid-trigger");
});
});

View file

@ -6,6 +6,7 @@ export type InvalidPersistedCronJobReason =
| "missing-id"
| "missing-schedule"
| "invalid-schedule"
| "invalid-trigger"
| "missing-payload"
| "invalid-payload";
@ -63,6 +64,21 @@ export function getInvalidPersistedCronJobReason(
return "invalid-schedule";
}
}
if ("trigger" in candidate) {
const trigger = candidate.trigger;
if (!trigger || typeof trigger !== "object" || Array.isArray(trigger)) {
return "invalid-trigger";
}
const script = (trigger as Record<string, unknown>).script;
if (
typeof script !== "string" ||
script.trim().length === 0 ||
scheduleKind === "at" ||
scheduleKind === "on-exit"
) {
return "invalid-trigger";
}
}
const payload = candidate.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return "missing-payload";

View file

@ -30,4 +30,5 @@ export type CronRunLogEntry = {
runAtMs?: number;
durationMs?: number;
nextRunAtMs?: number;
triggerFired?: boolean;
} & CronRunTelemetry;

View file

@ -81,6 +81,7 @@ export function parseCronRunLogEntryObject(
runAtMs: entryObj.runAtMs,
durationMs: entryObj.durationMs,
nextRunAtMs: entryObj.nextRunAtMs,
triggerFired: entryObj.triggerFired === true ? true : undefined,
model: typeof entryObj.model === "string" && entryObj.model.trim() ? entryObj.model : undefined,
provider: normalizedProvider,
usage: usage

View file

@ -0,0 +1,338 @@
import { describe, expect, it, vi } from "vitest";
import { appendCronRunLog, readCronRunLogEntriesSync } from "./run-log.js";
import type { CronEvent, CronServiceDeps } from "./service.js";
import { CronService } from "./service.js";
import { setupCronServiceSuite } from "./service.test-harness.js";
import { computeJobNextRunAtMs } from "./service/jobs.js";
import type { CronJobCreate } from "./types.js";
const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-trigger-eval-" });
type Evaluator = NonNullable<CronServiceDeps["evaluateCronTrigger"]>;
type IsolatedRunner = CronServiceDeps["runIsolatedAgentJob"];
function watcher(overrides: Partial<CronJobCreate> = {}): CronJobCreate {
return {
name: "watcher",
enabled: true,
schedule: { kind: "cron", expr: "* * * * * *", staggerMs: 0 },
trigger: { script: "json({ fire: false })" },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "base message" },
...overrides,
};
}
async function createHarness(params: {
evaluateCronTrigger?: Evaluator;
runIsolatedAgentJob?: IsolatedRunner;
}) {
const { storePath } = await makeStorePath();
const events: CronEvent[] = [];
const enqueueSystemEvent = vi.fn();
const runIsolatedAgentJob =
params.runIsolatedAgentJob ?? vi.fn(async () => ({ status: "ok" as const }));
const cron = new CronService({
storePath,
cronEnabled: true,
cronConfig: { triggers: { enabled: true, minIntervalMs: 30_000 } },
log: logger,
enqueueSystemEvent,
requestHeartbeat: vi.fn(),
runIsolatedAgentJob,
...(params.evaluateCronTrigger ? { evaluateCronTrigger: params.evaluateCronTrigger } : {}),
onEvent: (event) => events.push(event),
});
await cron.start();
return { cron, enqueueSystemEvent, events, runIsolatedAgentJob, storePath };
}
async function runWhenDue(cron: CronService, jobId: string) {
const nextRunAtMs = cron.getJob(jobId)?.state.nextRunAtMs;
if (nextRunAtMs === undefined) {
throw new Error("test job has no next run");
}
vi.setSystemTime(nextRunAtMs);
return cron.run(jobId, "due");
}
describe("cron trigger evaluation", () => {
it("persists quiet evaluations without payload execution or run history", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: false,
state: { status: "green" },
}));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(watcher());
const dueAt = job.state.nextRunAtMs ?? 0;
expect(await runWhenDue(harness.cron, job.id)).toEqual({ ok: true, ran: true });
const stored = harness.cron.getJob(job.id);
expect(stored?.state).toMatchObject({
lastTriggerEvalAtMs: dueAt,
triggerEvalCount: 1,
triggerState: { status: "green" },
consecutiveErrors: 0,
scheduleErrorCount: 0,
});
expect(stored?.state.lastRunAtMs).toBeUndefined();
expect((stored?.state.nextRunAtMs ?? 0) - dueAt).toBeGreaterThanOrEqual(30_000);
expect(harness.runIsolatedAgentJob).not.toHaveBeenCalled();
expect(harness.events.filter((event) => event.action === "finished")).toHaveLength(0);
expect(readCronRunLogEntriesSync({ storePath: harness.storePath, jobId: job.id })).toEqual(
[],
);
} finally {
harness.cron.stop();
}
});
it("appends the trigger message and marks fired run history", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: true,
message: "CI became red",
state: { status: "red" },
}));
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
const harness = await createHarness({ evaluateCronTrigger, runIsolatedAgentJob });
try {
const job = await harness.cron.add(watcher());
await runWhenDue(harness.cron, job.id);
expect(runIsolatedAgentJob).toHaveBeenCalledWith(
expect.objectContaining({ message: "base message\n\nCI became red" }),
);
const finished = harness.events.find((event) => event.action === "finished");
expect(finished).toMatchObject({ status: "ok", triggerFired: true });
if (!finished) {
throw new Error("missing finished event");
}
await appendCronRunLog({
storePath: harness.storePath,
entry: {
ts: Date.now(),
jobId: job.id,
action: "finished",
status: finished.status,
triggerFired: finished.triggerFired,
},
});
expect(readCronRunLogEntriesSync({ storePath: harness.storePath, jobId: job.id })).toEqual([
expect.objectContaining({ triggerFired: true }),
]);
expect(harness.cron.getJob(job.id)?.state).toMatchObject({
triggerEvalCount: 1,
lastTriggerFireAtMs: expect.any(Number),
triggerState: { status: "red" },
});
} finally {
harness.cron.stop();
}
});
it("appends the trigger message to main-session system events", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: true,
message: "deploy completed",
}));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(
watcher({
sessionTarget: "main",
payload: { kind: "systemEvent", text: "base event" },
}),
);
await runWhenDue(harness.cron, job.id);
expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
status: "ok",
triggerFired: true,
});
expect(harness.enqueueSystemEvent).toHaveBeenCalledWith(
"base event\n\ndeploy completed",
expect.any(Object),
);
} finally {
harness.cron.stop();
}
});
it("routes evaluator errors through execution backoff", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "error" as const,
code: "timeout" as const,
error: "deadline exceeded",
}));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(watcher());
const dueAt = job.state.nextRunAtMs ?? 0;
await runWhenDue(harness.cron, job.id);
expect(harness.cron.getJob(job.id)?.state).toMatchObject({
consecutiveErrors: 1,
triggerEvalCount: 1,
lastRunStatus: "error",
});
expect(harness.cron.getJob(job.id)?.state.nextRunAtMs).toBeGreaterThan(dueAt);
expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
status: "error",
error: expect.stringContaining("deadline exceeded"),
});
} finally {
harness.cron.stop();
}
});
it("treats evaluator saturation as a quiet skip with no trigger state update", async () => {
const evaluateCronTrigger = vi.fn(async () => ({ kind: "busy" as const }));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(watcher());
await runWhenDue(harness.cron, job.id);
const state = harness.cron.getJob(job.id)?.state;
expect(state?.triggerEvalCount).toBeUndefined();
expect(state?.lastTriggerEvalAtMs).toBeUndefined();
expect(state?.triggerState).toBeUndefined();
expect(harness.events.filter((event) => event.action === "finished")).toHaveLength(0);
expect(logger.debug).toHaveBeenCalledWith(
{ jobId: job.id },
"cron: trigger evaluation skipped while busy",
);
} finally {
harness.cron.stop();
}
});
it("disables once triggers only after a successful fired payload", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: true,
}));
const success = await createHarness({ evaluateCronTrigger });
try {
const job = await success.cron.add(watcher({ trigger: { script: "fire", once: true } }));
await runWhenDue(success.cron, job.id);
expect(success.cron.getJob(job.id)).toMatchObject({ enabled: false });
expect(success.cron.getJob(job.id)?.state.nextRunAtMs).toBeUndefined();
} finally {
success.cron.stop();
}
const failed = await createHarness({
evaluateCronTrigger,
runIsolatedAgentJob: vi.fn(async () => ({
status: "error" as const,
error: "payload failed",
})),
});
try {
const job = await failed.cron.add(watcher({ trigger: { script: "fire", once: true } }));
await runWhenDue(failed.cron, job.id);
expect(failed.cron.getJob(job.id)).toMatchObject({ enabled: true });
expect(failed.cron.getJob(job.id)?.state.nextRunAtMs).toEqual(expect.any(Number));
} finally {
failed.cron.stop();
}
});
it("keeps per-job cron staggering when rescheduling quiet ticks", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: false,
}));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(
watcher({ schedule: { kind: "cron", expr: "0 * * * *", staggerMs: 300_000 } }),
);
const dueAt = job.state.nextRunAtMs ?? 0;
await runWhenDue(harness.cron, job.id);
const stored = harness.cron.getJob(job.id);
if (!stored) {
throw new Error("missing job");
}
// Must match the job-level (stagger-aware) computation, not the raw boundary.
expect(stored.state.nextRunAtMs).toBe(computeJobNextRunAtMs(stored, dueAt));
} finally {
harness.cron.stop();
}
});
it("keeps prior trigger state when the fired payload run fails", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: true,
message: "CI became red",
state: { status: "red" },
}));
const harness = await createHarness({
evaluateCronTrigger,
runIsolatedAgentJob: vi.fn(async () => ({
status: "error" as const,
error: "payload failed",
})),
});
try {
const job = await harness.cron.add(watcher());
await runWhenDue(harness.cron, job.id);
const state = harness.cron.getJob(job.id)?.state;
expect(state).toMatchObject({
triggerEvalCount: 1,
lastTriggerFireAtMs: expect.any(Number),
lastRunStatus: "error",
});
// Old state survives so the next evaluation re-detects the change.
expect(state?.triggerState).toBeUndefined();
} finally {
harness.cron.stop();
}
});
it("reports a missing evaluator as an execution error", async () => {
const harness = await createHarness({});
try {
const job = await harness.cron.add(watcher());
await runWhenDue(harness.cron, job.id);
expect(harness.cron.getJob(job.id)?.state).toMatchObject({
consecutiveErrors: 1,
lastRunStatus: "error",
lastError: "cron trigger evaluator is unavailable",
});
} finally {
harness.cron.stop();
}
});
it("bypasses trigger evaluation for force runs", async () => {
const evaluateCronTrigger = vi.fn(async () => ({
kind: "evaluated" as const,
fire: false,
}));
const harness = await createHarness({ evaluateCronTrigger });
try {
const job = await harness.cron.add(watcher());
expect(await harness.cron.run(job.id, "force")).toEqual({ ok: true, ran: true });
expect(evaluateCronTrigger).not.toHaveBeenCalled();
expect(harness.runIsolatedAgentJob).toHaveBeenCalledOnce();
expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
status: "ok",
});
expect(
harness.events.find((event) => event.action === "finished")?.triggerFired,
).toBeUndefined();
} finally {
harness.cron.stop();
}
});
});

View file

@ -4,6 +4,8 @@ import {
normalizeOptionalString,
normalizeOptionalThreadValue,
} from "@openclaw/normalization-core/string-coerce";
import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
import type { CronConfig } from "../../config/types.cron.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { resolveCronDeliveryPlan } from "../delivery-plan.js";
import { parseAbsoluteTimeMs } from "../parse.js";
@ -304,6 +306,25 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
}
}
function assertTriggerSupport(
job: Pick<CronJob, "schedule" | "trigger">,
opts?: { cronConfig?: CronConfig; requireEnabled?: boolean },
) {
if (!job.trigger) {
return;
}
if (opts?.requireEnabled && opts.cronConfig?.triggers?.enabled !== true) {
throw new Error("cron triggers are disabled; set cron.triggers.enabled=true");
}
if (job.schedule.kind !== "every" && job.schedule.kind !== "cron") {
throw new Error("cron triggers require an every or cron schedule");
}
const minIntervalMs = resolveCronTriggerMinIntervalMs(opts?.cronConfig);
if (job.schedule.kind === "every" && job.schedule.everyMs < minIntervalMs) {
throw new Error(`cron trigger every interval must be at least ${minIntervalMs}ms`);
}
}
function assertCronExpressionSatisfiable(job: CronJob, nowMs: number) {
if (job.schedule.kind !== "cron") {
return;
@ -883,11 +904,16 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
payload: input.payload,
delivery: resolveInitialCronDelivery(input),
failureAlert: input.failureAlert,
...(input.trigger ? { trigger: structuredClone(input.trigger) } : {}),
state: {
...input.state,
},
};
assertSupportedJobSpec(job);
assertTriggerSupport(job, {
cronConfig: state.deps.cronConfig,
requireEnabled: job.trigger !== undefined,
});
assertMainSessionAgentId(job, state.deps.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@ -900,7 +926,11 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
export function applyJobPatch(
job: CronJob,
patch: CronJobPatch,
opts?: { defaultAgentId?: string; scheduleValidationNowMs?: number },
opts?: {
defaultAgentId?: string;
scheduleValidationNowMs?: number;
cronConfig?: CronConfig;
},
) {
if ("name" in patch) {
job.name = normalizeRequiredName(patch.name);
@ -950,6 +980,13 @@ export function applyJobPatch(
job.schedule = patch.schedule;
}
}
if ("trigger" in patch) {
if (patch.trigger === null || patch.trigger === undefined) {
delete job.trigger;
} else {
job.trigger = structuredClone(patch.trigger);
}
}
if (patch.sessionTarget) {
job.sessionTarget = patch.sessionTarget;
}
@ -994,6 +1031,10 @@ export function applyJobPatch(
job.sessionKey = normalizeOptionalString((patch as { sessionKey?: unknown }).sessionKey);
}
assertSupportedJobSpec(job);
assertTriggerSupport(job, {
cronConfig: opts?.cronConfig,
requireEnabled: patch.trigger !== null && patch.trigger !== undefined,
});
assertMainSessionAgentId(job, opts?.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@ -1009,7 +1050,12 @@ export function applyJobPatch(
export function applyDeclarativeJobSpec(
job: CronJob,
input: CronJobCreate,
opts: { defaultAgentId?: string; enabledExplicit: boolean; nowMs: number },
opts: {
defaultAgentId?: string;
enabledExplicit: boolean;
nowMs: number;
cronConfig?: CronConfig;
},
) {
// Name, target, routing, owner, and run policy remain outside declaration
// convergence; changing those uses cron.update and cannot retarget an identity.
@ -1052,6 +1098,11 @@ export function applyDeclarativeJobSpec(
job.schedule = structuredClone(input.schedule);
}
job.payload = structuredClone(input.payload);
if (input.trigger) {
job.trigger = structuredClone(input.trigger);
} else {
delete job.trigger;
}
const delivery = resolveInitialCronDelivery(input);
if (delivery) {
job.delivery = structuredClone(delivery);
@ -1061,6 +1112,10 @@ export function applyDeclarativeJobSpec(
if (opts.enabledExplicit) {
job.enabled = input.enabled;
}
assertTriggerSupport(job, {
cronConfig: opts.cronConfig,
requireEnabled: input.trigger !== undefined,
});
assertSupportedJobSpec(job);
assertMainSessionAgentId(job, opts.defaultAgentId);

View file

@ -61,6 +61,8 @@ import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
import { CRON_TASK_RUNNING_PROGRESS_SUMMARY } from "./task-ledger.js";
import {
applyJobResult,
applyTriggerNoFireResult,
applyTriggerRunResult,
armTimer,
emit,
executeJobCoreWithTimeout,
@ -606,6 +608,7 @@ async function persistUpdatedJob(params: {
function declarativeFields(job: CronJob, includeEnabled: boolean) {
return {
schedule: job.schedule,
trigger: job.trigger,
payload: job.payload,
delivery: job.delivery,
displayName: job.displayName,
@ -644,6 +647,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?:
defaultAgentId: state.deps.defaultAgentId,
enabledExplicit: opts?.enabledExplicit === true,
nowMs: now,
cronConfig: state.deps.cronConfig,
});
const includeEnabled = opts?.enabledExplicit === true;
if (
@ -722,12 +726,14 @@ async function updateLoadedJob(params: {
applyJobPatch(nextJob, patch, {
defaultAgentId: state.deps.defaultAgentId,
scheduleValidationNowMs: now,
cronConfig: state.deps.cronConfig,
});
finalizeUpdatedJob({
job,
nextJob,
now,
schedulingInputsRequested: patch.schedule !== undefined || patch.enabled !== undefined,
schedulingInputsRequested:
patch.schedule !== undefined || patch.enabled !== undefined || patch.trigger !== undefined,
scheduleChanged: patch.schedule !== undefined,
});
await persistUpdatedJob({ state, snapshot, nextJob });
@ -1060,6 +1066,11 @@ async function prepareManualRun(
// Execute against a snapshot so later reload/merge can preserve delivery
// target writeback from disk without mutating the running object.
const executionJob = structuredClone(job);
if (mode === "force" && executionJob.trigger) {
// Force means run the payload now; strip the gate only from this snapshot
// so persisted trigger state and future due evaluations stay intact.
delete executionJob.trigger;
}
if (opts?.payload) {
executionJob.payload = structuredClone(opts.payload);
}
@ -1120,44 +1131,61 @@ async function finishPreparedManualRun(
return;
}
const shouldDelete = applyJobResult(
state,
job,
{
status: coreResult.status,
error: coreResult.error,
diagnostics: coreResult.diagnostics,
delivered: coreResult.delivered,
provider: coreResult.provider,
let shouldDelete = false;
if (coreResult.status === "ok" && coreResult.triggerEval?.fired === false) {
// Manual due checks share scheduled quiet-tick semantics: persist the
// evaluation but create no finished event or run-history entry.
applyTriggerNoFireResult(state, job, {
startedAt,
endedAt,
},
{ preserveSchedule: mode === "force" },
);
triggerEval: coreResult.triggerEval,
});
} else {
shouldDelete = applyJobResult(
state,
job,
{
status: coreResult.status,
error: coreResult.error,
diagnostics: coreResult.diagnostics,
delivered: coreResult.delivered,
provider: coreResult.provider,
startedAt,
endedAt,
},
{ preserveSchedule: mode === "force" },
);
applyTriggerRunResult(job, {
status: coreResult.status,
endedAt,
triggerEval: coreResult.triggerEval,
});
emit(state, {
jobId: job.id,
action: "finished",
job,
status: coreResult.status,
error: coreResult.error,
summary: coreResult.summary,
diagnostics: coreResult.diagnostics,
delivered: job.state.lastDelivered,
deliveryStatus: job.state.lastDeliveryStatus,
deliveryError: job.state.lastDeliveryError,
failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
delivery: coreResult.delivery,
sessionId: coreResult.sessionId,
sessionKey: coreResult.sessionKey,
runId,
runAtMs: startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
model: coreResult.model,
provider: coreResult.provider,
usage: coreResult.usage,
});
emit(state, {
jobId: job.id,
action: "finished",
job,
status: coreResult.status,
error: coreResult.error,
summary: coreResult.summary,
diagnostics: coreResult.diagnostics,
delivered: job.state.lastDelivered,
deliveryStatus: job.state.lastDeliveryStatus,
deliveryError: job.state.lastDeliveryError,
failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
delivery: coreResult.delivery,
sessionId: coreResult.sessionId,
sessionKey: coreResult.sessionKey,
runId,
runAtMs: startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
...(coreResult.triggerEval?.fired ? { triggerFired: true } : {}),
model: coreResult.model,
provider: coreResult.provider,
usage: coreResult.usage,
});
}
if (shouldDelete && state.store) {
state.store.jobs = state.store.jobs.filter((entry) => entry.id !== job.id);

View file

@ -4,6 +4,7 @@ import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heart
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { QuarantinedCronConfigJob } from "../store.js";
import type {
CronTriggerEvaluationResult,
CronAgentExecutionPhaseUpdate,
CronAgentExecutionStarted,
CronFailureNotificationDelivery,
@ -41,6 +42,7 @@ export type CronEvent = {
sessionKey?: string;
runId?: string;
nextRunAtMs?: number;
triggerFired?: boolean;
} & CronRunTelemetry;
/** Logger contract consumed by cron service internals. */
@ -67,6 +69,12 @@ export type CronServiceDeps = {
cronEnabled: boolean;
/** CronConfig for session retention settings. */
cronConfig?: CronConfig;
evaluateCronTrigger?: (params: {
job: CronJob;
script: string;
state: unknown;
abortSignal?: AbortSignal;
}) => Promise<CronTriggerEvaluationResult>;
/** Default agent id for jobs without an agent id. */
defaultAgentId?: string;
/** Resolve session store path for a given agent id. */

View file

@ -1,6 +1,7 @@
/** Cron timer loop, execution, catch-up, and run-result state transitions. */
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
import { resolveFailoverReasonFromError } from "../../agents/failover-error.js";
import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
import type { CronConfig, CronRetryOn } from "../../config/types.cron.js";
import { formatErrorMessage } from "../../infra/errors.js";
@ -119,8 +120,16 @@ type TimedCronRunOutcome = CronRunOutcome &
activeJobMarker?: CronActiveJobMarker;
startedAt: number;
endedAt: number;
triggerEval?: CronTriggerEvalOutcome;
};
export type CronTriggerEvalOutcome = {
fired: boolean;
stateChanged: boolean;
state?: unknown;
busy?: true;
};
export type IsolatedAgentSetupTimeoutSignal = {
error: string;
timeoutMs: number;
@ -977,7 +986,14 @@ export function applyJobResult(
// after the current run ended. Prevents spin-loops when the
// schedule computation lands in the same second due to
// timezone/croner edge cases (see #17821).
const minNext = result.endedAt + MIN_REFIRE_GAP_MS;
// Trigger schedules obey the operator floor even when a cron expression
// would otherwise refire sooner after a successful payload run.
const minNext =
result.endedAt +
Math.max(
MIN_REFIRE_GAP_MS,
job.trigger ? resolveCronTriggerMinIntervalMs(state.deps.cronConfig) : 0,
);
job.state.nextRunAtMs = resolveCronNextRunWithLowerBound({
state,
job,
@ -986,7 +1002,13 @@ export function applyJobResult(
context: "completion",
});
} else {
job.state.nextRunAtMs = naturalNext;
job.state.nextRunAtMs =
naturalNext !== undefined && job.trigger
? Math.max(
naturalNext,
result.endedAt + resolveCronTriggerMinIntervalMs(state.deps.cronConfig),
)
: naturalNext;
}
} else {
job.state.nextRunAtMs = undefined;
@ -996,6 +1018,81 @@ export function applyJobResult(
return shouldDelete;
}
function applyTriggerEvaluationState(
job: CronJob,
triggerEval: CronTriggerEvalOutcome,
evaluatedAtMs: number,
): void {
if (triggerEval.busy) {
return;
}
job.state.lastTriggerEvalAtMs = evaluatedAtMs;
job.state.triggerEvalCount = (job.state.triggerEvalCount ?? 0) + 1;
if (triggerEval.stateChanged) {
job.state.triggerState = triggerEval.state;
}
if (triggerEval.fired) {
job.state.lastTriggerFireAtMs = evaluatedAtMs;
}
}
/** Persists fired/error evaluation metadata and applies successful once-disarm policy. */
export function applyTriggerRunResult(
job: CronJob,
result: { status: CronRunStatus; endedAt: number; triggerEval?: CronTriggerEvalOutcome },
): void {
if (!result.triggerEval) {
return;
}
// Fired-run trigger state persists only on payload success: a failed or
// skipped run keeps the previous state so the next evaluation re-detects
// the change and fires again instead of silently losing the event.
const persistedEval =
result.status === "ok"
? result.triggerEval
: { ...result.triggerEval, stateChanged: false, state: undefined };
applyTriggerEvaluationState(job, persistedEval, result.endedAt);
// A once trigger disarms only after the fired payload succeeds. Errors keep
// it armed so the normal backoff path can evaluate and retry later.
if (result.triggerEval.fired && job.trigger?.once === true && result.status === "ok") {
job.enabled = false;
job.state.nextRunAtMs = undefined;
}
}
/** Applies a quiet trigger tick without mutating normal run-history state. */
export function applyTriggerNoFireResult(
state: CronServiceState,
job: CronJob,
result: { startedAt: number; endedAt: number; triggerEval: CronTriggerEvalOutcome },
): void {
job.state.runningAtMs = undefined;
job.updatedAtMs = result.endedAt;
if (!result.triggerEval.busy) {
// A non-firing evaluation is successful scheduler work, not a payload run;
// reset error machinery while leaving lastRun/delivery history untouched.
job.state.consecutiveErrors = 0;
job.state.scheduleErrorCount = 0;
job.state.lastFailureAlertAtMs = undefined;
applyTriggerEvaluationState(job, result.triggerEval, result.endedAt);
}
try {
// Job-level computation keeps per-job cron staggering intact on quiet
// ticks; raw schedule math would collapse watchers onto exact boundaries.
const naturalNext = computeJobNextRunAtMs(job, result.endedAt);
const floorMs = Math.max(
MIN_REFIRE_GAP_MS,
resolveCronTriggerMinIntervalMs(state.deps.cronConfig),
);
// Quiet ticks still advance the schedule; the floor prevents scripts from
// becoming a headless hot loop even when cron resolves inside the window.
job.state.nextRunAtMs =
naturalNext === undefined ? undefined : Math.max(naturalNext, result.endedAt + floorMs);
} catch (err) {
recordScheduleComputeError({ state, job, err });
}
}
function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOutcome): void {
tryFinishCronTaskRun(state, result);
const store = state.store;
@ -1005,6 +1102,9 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
const jobs = store.jobs;
const job = jobs.find((entry) => entry.id === result.jobId);
if (!job) {
if (result.status === "ok" && result.triggerEval?.fired === false) {
return;
}
if (result.status === "ok") {
// A manual/queued run may finish after the job was removed. Preserve the
// successful run log state without resurrecting the job in the store.
@ -1031,6 +1131,18 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
return;
}
if (result.status === "ok" && result.triggerEval && !result.triggerEval.fired) {
// Quiet trigger ticks intentionally emit no finished event: run history,
// plugin hooks, and completion notifications represent payload runs only.
applyTriggerNoFireResult(state, job, {
startedAt: result.startedAt,
endedAt: result.endedAt,
triggerEval: result.triggerEval,
});
state.pendingCatchupDeferralJobIds.delete(job.id);
return;
}
const shouldDelete = applyJobResult(state, job, {
status: result.status,
error: result.error,
@ -1040,6 +1152,7 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
startedAt: result.startedAt,
endedAt: result.endedAt,
});
applyTriggerRunResult(job, result);
state.pendingCatchupDeferralJobIds.delete(job.id);
emitJobFinished(state, job, result, result.startedAt);
@ -1801,6 +1914,9 @@ async function runStartupCatchupCandidate(
provider: result.provider,
usage: result.usage,
isolatedAgentSetupTimeout: result.isolatedAgentSetupTimeout,
// Quiet trigger ticks during startup catch-up must keep their eval
// outcome; dropping it would record them as successful payload runs.
triggerEval: result.triggerEval,
startedAt,
endedAt: state.deps.nowMs(),
};
@ -1916,6 +2032,7 @@ export async function executeJobCore(
delivered?: boolean;
deliveryAttempted?: boolean;
delivery?: CronDeliveryTrace;
triggerEval?: CronTriggerEvalOutcome;
}
> {
const resolveAbortError = () => ({
@ -1949,11 +2066,65 @@ export async function executeJobCore(
if (abortSignal?.aborted) {
return resolveAbortError();
}
if (job.sessionTarget === "main") {
return await executeMainSessionCronJob(state, job, abortSignal, waitWithAbort);
let effectiveJob = job;
let triggerEval: CronTriggerEvalOutcome | undefined;
if (job.trigger) {
const evaluator = state.deps.evaluateCronTrigger;
if (!evaluator) {
return { status: "error", error: "cron trigger evaluator is unavailable" };
}
const evaluation = await evaluator({
job,
script: job.trigger.script,
state: job.state.triggerState,
abortSignal,
});
if (evaluation.kind === "busy") {
state.deps.log.debug({ jobId: job.id }, "cron: trigger evaluation skipped while busy");
return {
status: "ok",
triggerEval: { fired: false, stateChanged: false, busy: true },
};
}
if (evaluation.kind === "error") {
return {
status: "error",
error: `cron trigger evaluation failed (${evaluation.code}): ${evaluation.error}`,
triggerEval: { fired: false, stateChanged: false },
};
}
const stateChanged = Object.hasOwn(evaluation, "state");
triggerEval = {
fired: evaluation.fire,
stateChanged,
...(stateChanged ? { state: evaluation.state } : {}),
};
if (!evaluation.fire) {
return { status: "ok", triggerEval };
}
if (evaluation.message !== undefined) {
const payload =
job.payload.kind === "systemEvent"
? { ...job.payload, text: `${job.payload.text}\n\n${evaluation.message}` }
: job.payload.kind === "agentTurn"
? { ...job.payload, message: `${job.payload.message}\n\n${evaluation.message}` }
: job.payload;
effectiveJob = { ...job, payload };
}
}
if (effectiveJob.sessionTarget === "main") {
const result = await executeMainSessionCronJob(state, effectiveJob, abortSignal, waitWithAbort);
return triggerEval ? { ...result, triggerEval } : result;
}
return await executeDetachedCronJob(state, job, abortSignal, resolveAbortError, options);
const result = await executeDetachedCronJob(
state,
effectiveJob,
abortSignal,
resolveAbortError,
options,
);
return triggerEval ? { ...result, triggerEval } : result;
}
async function executeMainSessionCronJob(
@ -2209,6 +2380,7 @@ function emitJobFinished(
status: CronRunStatus;
delivered?: boolean;
delivery?: CronDeliveryTrace;
triggerEval?: CronTriggerEvalOutcome;
} & CronRunOutcome &
CronRunTelemetry,
runAtMs: number,
@ -2231,6 +2403,7 @@ function emitJobFinished(
runAtMs,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
...(result.triggerEval?.fired ? { triggerFired: true } : {}),
model: result.model,
provider: result.provider,
usage: result.usage,

View file

@ -19,6 +19,7 @@ import {
import type { CronJobInsert, CronJobRow } from "./schema.js";
import { getCronStoreKysely } from "./schema.js";
import { bindStateColumns, stateFromRow } from "./state-codec.js";
import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
import type { LoadedCronStore } from "./types.js";
export function bindScheduleColumns(
@ -154,6 +155,7 @@ function bindCronJobRow(storeKey: string, job: CronJob, sortOrder: number): Cron
session_key: job.sessionKey ?? null,
session_target: job.sessionTarget,
wake_mode: job.wakeMode,
...bindTriggerColumns(job.trigger),
...bindScheduleColumns(job.schedule),
...bindPayloadColumns(job.payload),
...bindDeliveryColumns(job.delivery),
@ -242,6 +244,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
const payload = payloadFromRow(row);
const delivery = deliveryFromRow(row);
const failureAlert = failureAlertFromRow(row);
const trigger = triggerFromRow(row);
if (!schedule || !payload) {
return null;
}
@ -272,6 +275,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
schedule,
sessionTarget: row.session_target as CronJob["sessionTarget"],
wakeMode: row.wake_mode as CronJob["wakeMode"],
...(trigger ? { trigger } : {}),
payload,
...(delivery ? { delivery } : {}),
...(failureAlert !== undefined ? { failureAlert } : {}),

View file

@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
import type { CronJob } from "../types.js";
import {
loadedCronStoreFromRows,
loadCronRows,
replaceCronRows,
updateCronRuntimeRows,
} from "./row-codec.js";
import type { CronJobRow } from "./schema.js";
import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
describe("cron trigger SQLite codec", () => {
it("round-trips trigger columns", () => {
const columns = bindTriggerColumns({ script: "json({ fire: true })", once: true });
expect(columns).toEqual({ trigger_script: "json({ fire: true })", trigger_once: 1 });
expect(triggerFromRow(columns as CronJobRow)).toEqual({
script: "json({ fire: true })",
once: true,
});
expect(triggerFromRow(bindTriggerColumns(undefined) as CronJobRow)).toBeUndefined();
});
it("round-trips trigger state through updateCronRuntimeRows", async () => {
const job = {
id: "job-1",
name: "watcher",
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: "every", everyMs: 30_000 },
trigger: { script: "json({ fire: false })", once: false },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "changed" },
state: {
lastTriggerEvalAtMs: 10,
triggerEvalCount: 3,
lastTriggerFireAtMs: 8,
triggerState: { status: "green" },
},
} satisfies CronJob;
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-trigger-codec-"));
const handle = openOpenClawStateDatabase({ path: path.join(fixtureRoot, "state.sqlite") });
try {
replaceCronRows(handle.db, "test", { version: 1, jobs: [{ ...job, state: {} }] });
updateCronRuntimeRows(handle.db, "test", { version: 1, jobs: [job] });
const [decoded] = loadedCronStoreFromRows(loadCronRows(handle.db, "test")).store.jobs;
expect(decoded?.trigger).toEqual(job.trigger);
expect(decoded?.state).toEqual(job.state);
} finally {
handle.walMaintenance.close();
handle.db.close();
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,25 @@
/** SQLite column codec for cron trigger configuration. */
import type { CronTrigger } from "../types.js";
import { booleanToInteger, integerToBoolean } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps cron trigger config into normalized SQLite columns. */
export function bindTriggerColumns(
trigger: CronTrigger | undefined,
): Pick<CronJobInsert, "trigger_script" | "trigger_once"> {
return {
trigger_script: trigger?.script ?? null,
trigger_once: booleanToInteger(trigger?.once),
};
}
/** Reconstructs trigger config from normalized SQLite columns. */
export function triggerFromRow(row: CronJobRow): CronTrigger | undefined {
if (!row.trigger_script) {
return undefined;
}
return {
script: row.trigger_script,
...(row.trigger_once != null ? { once: integerToBoolean(row.trigger_once) } : {}),
};
}

View file

@ -0,0 +1,280 @@
import { describe, expect, it, vi } from "vitest";
import {
testing as beforeToolCallTesting,
wrapToolWithBeforeToolCallHook,
} from "../agents/agent-tools.before-tool-call.js";
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";
type EvaluatorDeps = Parameters<typeof createCronTriggerEvaluator>[0];
type HeadlessParams = Parameters<NonNullable<EvaluatorDeps["runHeadless"]>>[0];
type PrepareParams = Parameters<NonNullable<EvaluatorDeps["prepareRuntime"]>>[0];
function completed(params: { value: unknown; output?: unknown[] }): CodeModeHeadlessResult {
return {
status: "completed",
value: params.value,
output: params.output ?? [],
toolCallCount: 0,
};
}
function createPreparedRuntime(config: OpenClawConfig) {
const tool = wrapToolWithBeforeToolCallHook(
{
name: "probe",
label: "Probe",
description: "Probe tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
} satisfies AnyAgentTool,
{ config, agentId: "main", sessionKey: "cron:test:trigger" },
);
return {
tools: [tool],
ctx: {
config,
runtimeConfig: config,
agentId: "main",
sessionKey: "cron:test:trigger",
},
hookContext: { config, agentId: "main", sessionKey: "cron:test:trigger" },
};
}
function createEvaluator(
runHeadless: (
params: Parameters<
NonNullable<Parameters<typeof createCronTriggerEvaluator>[0]["runHeadless"]>
>[0],
) => Promise<CodeModeHeadlessResult>,
) {
const config = {} as OpenClawConfig;
const prepareRuntime = vi.fn(async () => createPreparedRuntime(config));
return {
evaluate: createCronTriggerEvaluator({ config, runHeadless, prepareRuntime }),
prepareRuntime,
};
}
describe("cron trigger script evaluator", () => {
it("prefers a valid returned value and injects trigger state", async () => {
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
completed({
value: { fire: true, message: "changed", state: { revision: 2 } },
output: [{ type: "json", value: { fire: false, state: { revision: 1 } } }],
}),
);
const { evaluate } = createEvaluator(runHeadless);
await expect(
evaluate({
jobId: "job-value",
script: "return result",
state: { revision: 1 },
}),
).resolves.toEqual({
kind: "evaluated",
fire: true,
message: "changed",
state: { revision: 2 },
});
expect(runHeadless).toHaveBeenCalledOnce();
expect(runHeadless).toHaveBeenCalledWith(
expect.objectContaining({
extraNamespaces: [
{
id: "cron:trigger",
globalName: "trigger",
scope: {
kind: "object",
entries: [["state", { kind: "value", value: { revision: 1 } }]],
},
},
],
}),
);
});
it("falls back to the last json output entry", async () => {
const { evaluate } = createEvaluator(
vi.fn(async () =>
completed({
value: null,
output: [
{ type: "json", value: { fire: false, state: { old: true } } },
{ type: "text", text: "ignored" },
{ type: "json", value: { fire: true, state: { current: true } } },
],
}),
),
);
await expect(
evaluate({ jobId: "job-json", script: "json(result)", state: null }),
).resolves.toEqual({
kind: "evaluated",
fire: true,
state: { current: true },
});
});
it("uses a fresh hook run scope for each evaluation", async () => {
const contexts: Array<Record<symbol, unknown>> = [];
const { evaluate, prepareRuntime } = createEvaluator(
vi.fn(async (params) => {
const wrapped = params.ctx.catalogRef?.current?.entries[0]?.tool;
contexts.push((wrapped ?? {}) as Record<symbol, unknown>);
return completed({ value: { fire: false } });
}),
);
await evaluate({ jobId: "job-loop-scope", script: "return result", state: null });
await evaluate({ jobId: "job-loop-scope", script: "return result", state: null });
expect(prepareRuntime).toHaveBeenCalledOnce();
const runIds = contexts.map((tool) => {
const context = tool[beforeToolCallTesting.BEFORE_TOOL_CALL_HOOK_CONTEXT];
return (context as { runId?: string } | undefined)?.runId;
});
expect(runIds[0]).toMatch(/^cron-trigger:job-loop-scope:/);
expect(runIds[1]).toMatch(/^cron-trigger:job-loop-scope:/);
expect(runIds[1]).not.toBe(runIds[0]);
});
it("single-flights concurrent runtime preparation for the same job", async () => {
const config = {} as OpenClawConfig;
let release: ((runtime: ReturnType<typeof createPreparedRuntime>) => void) | undefined;
const pending = new Promise<ReturnType<typeof createPreparedRuntime>>((resolve) => {
release = resolve;
});
const prepareRuntime = vi.fn(async () => await pending);
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
const first = evaluate({ jobId: "job-single-flight", script: "return result", state: null });
const second = evaluate({ jobId: "job-single-flight", script: "return result", state: null });
await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce());
release?.(createPreparedRuntime(config));
await expect(Promise.all([first, second])).resolves.toEqual([
{ kind: "evaluated", fire: false },
{ kind: "evaluated", fire: false },
]);
expect(runHeadless).toHaveBeenCalledTimes(2);
});
it("invalidates a cached runtime when toolsAllow changes", async () => {
const config = {} as OpenClawConfig;
const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config));
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
await evaluate({
jobId: "job-tools-allow",
script: "return result",
state: null,
toolsAllow: ["probe"],
});
await evaluate({
jobId: "job-tools-allow",
script: "return result",
state: null,
toolsAllow: ["exec"],
});
expect(prepareRuntime).toHaveBeenCalledTimes(2);
expect(prepareRuntime.mock.calls.map(([params]) => params.toolsAllow)).toEqual([
["probe"],
["exec"],
]);
});
it.each([
completed({ value: null }),
completed({ value: { fire: "yes" } }),
completed({ value: { fire: true, message: 42 } }),
])("rejects invalid result shapes", async (headlessResult) => {
const { evaluate } = createEvaluator(vi.fn(async () => headlessResult));
const result = await evaluate({ jobId: "job-invalid", script: "return bad", state: null });
expect(result).toMatchObject({ kind: "error", code: "internal_error" });
});
it("rejects returned state larger than 16KB", async () => {
const { evaluate } = createEvaluator(
vi.fn(async () =>
completed({ value: { fire: false, state: { value: "x".repeat(16 * 1024) } } }),
),
);
await expect(
evaluate({ jobId: "job-large-state", script: "return result", state: null }),
).resolves.toEqual({
kind: "error",
code: "output_limit_exceeded",
error: "cron trigger state exceeds the 16KB limit",
});
});
it("returns busy instead of queueing a fourth concurrent evaluation", async () => {
let release: ((result: CodeModeHeadlessResult) => void) | undefined;
const pending = new Promise<CodeModeHeadlessResult>((resolve) => {
release = resolve;
});
const runHeadless = vi.fn(async () => await pending);
const { evaluate } = createEvaluator(runHeadless);
const running = ["one", "two", "three"].map((jobId) =>
evaluate({ jobId, script: "return result", state: null }),
);
await vi.waitFor(() => expect(runHeadless).toHaveBeenCalledTimes(3));
const saturated = await evaluate({
jobId: "four",
script: "return result",
state: null,
});
release?.(completed({ value: { fire: false } }));
await Promise.all(running);
expect(saturated).toEqual({ kind: "busy" });
expect(runHeadless).toHaveBeenCalledTimes(3);
});
it("cancels runtime preparation when its only evaluator aborts", async () => {
const config = {} as OpenClawConfig;
let preparationSignal: AbortSignal | undefined;
const prepareRuntime = vi.fn(async (params: { signal?: AbortSignal }): Promise<never> => {
preparationSignal = params.signal;
return await new Promise<never>((_resolve, reject) => {
params.signal?.addEventListener(
"abort",
() => {
const reason = params.signal?.reason;
reject(reason instanceof Error ? reason : new Error("aborted"));
},
{ once: true },
);
});
});
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
const controller = new AbortController();
const evaluation = evaluate({
jobId: "job-abort-preparation",
script: "return result",
state: null,
abortSignal: controller.signal,
});
await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce());
controller.abort();
await expect(evaluation).resolves.toMatchObject({ kind: "error", code: "timeout" });
await vi.waitFor(() => expect(preparationSignal?.aborted).toBe(true));
expect(runHeadless).not.toHaveBeenCalled();
});
});

438
src/cron/trigger-script.ts Normal file
View file

@ -0,0 +1,438 @@
import crypto from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
resolveAgentConfig,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import type { HookContext } from "../agents/agent-tools.before-tool-call.js";
import {
createOpenClawCodingTools,
resolveToolLoopDetectionConfig,
} from "../agents/agent-tools.js";
import type { CodeModeNamespaceDescriptor } from "../agents/code-mode-namespaces.js";
import {
runCodeModeScriptHeadless,
type CodeModeFailureCode,
type CodeModeHeadlessResult,
} from "../agents/code-mode.js";
import {
applyEmbeddedAttemptToolsAllow,
resolveEmbeddedAttemptToolConstructionPlan,
} from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js";
import { resolveSandboxContext } from "../agents/sandbox.js";
import {
createToolSearchCatalogRef,
registerHeadlessToolSearchCatalog,
type ToolSearchToolContext,
} from "../agents/tool-search.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
buildCronAgentDefaultsConfig,
resolveCronActiveRuntimeConfig,
} from "./isolated-agent/run-config.js";
import { resolveCronAgentSessionKey } from "./isolated-agent/session-key.js";
import type { CronTriggerEvaluationResult, CronTriggerFailureCode } from "./types.js";
const MAX_CONCURRENT_TRIGGER_EVALS = 3;
const MAX_TRIGGER_STATE_BYTES = 16 * 1024;
const MAX_CACHED_TRIGGER_RUNTIMES = 128;
const HEADLESS_TRIGGER_WALL_CLOCK_MS = 30_000;
const HEADLESS_TRIGGER_TOOL_BUDGET = 5;
let activeTriggerEvaluations = 0;
// Compile-time sync with the leaf contract in ./types.ts: a new code-mode
// failure code must be added to CronTriggerFailureCode or this line errors.
type AssertTriggerCodesCoverHeadless = [CodeModeFailureCode | "tool_budget_exceeded"] extends [
CronTriggerFailureCode,
]
? true
: never;
const assertTriggerCodesCoverHeadless: AssertTriggerCodesCoverHeadless = true;
void assertTriggerCodesCoverHeadless;
type PreparedTriggerRuntime = {
tools: AnyAgentTool[];
ctx: Omit<ToolSearchToolContext, "catalogRef">;
hookContext: Omit<HookContext, "runId">;
};
type PrepareTriggerRuntime = (params: {
runtimeConfig: OpenClawConfig;
jobId: string;
agentId?: string;
toolsAllow?: string[];
signal?: AbortSignal;
}) => Promise<PreparedTriggerRuntime>;
type CronTriggerEvaluatorDeps = {
config: OpenClawConfig;
runHeadless?: typeof runCodeModeScriptHeadless;
prepareRuntime?: PrepareTriggerRuntime;
};
type TriggerRuntimeCacheEntry = {
promise: Promise<PreparedTriggerRuntime>;
configEpoch: OpenClawConfig;
agentId: string;
toolsAllowKey: string;
};
function resolveTriggerAgentId(config: OpenClawConfig, agentId?: string): string {
return agentId?.trim() ? normalizeAgentId(agentId) : resolveDefaultAgentId(config);
}
async function prepareTriggerRuntime(params: {
runtimeConfig: OpenClawConfig;
jobId: string;
agentId?: string;
toolsAllow?: string[];
signal?: AbortSignal;
}): Promise<PreparedTriggerRuntime> {
params.signal?.throwIfAborted();
const agentId = resolveTriggerAgentId(params.runtimeConfig, params.agentId);
const selectedAgentConfig = resolveAgentConfig(params.runtimeConfig, agentId);
const agentConfigOverride = params.agentId?.trim() ? selectedAgentConfig : undefined;
const agentDefaults = buildCronAgentDefaultsConfig({
defaults: params.runtimeConfig.agents?.defaults,
agentConfigOverride,
});
const config: OpenClawConfig = {
...params.runtimeConfig,
agents: Object.assign({}, params.runtimeConfig.agents, { defaults: agentDefaults }),
};
const workspaceDirRaw = resolveAgentWorkspaceDir(config, agentId);
const agentDir = resolveAgentDir(config, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !agentDefaults.skipBootstrap,
skipOptionalBootstrapFiles: agentDefaults.skipOptionalBootstrapFiles,
});
params.signal?.throwIfAborted();
const workspaceDir = workspace.dir;
ensureRuntimePluginsLoaded({
config,
workspaceDir,
allowGatewaySubagentBinding: true,
});
const rawSessionKey = `cron:${params.jobId}:trigger`;
const sessionKey = resolveCronAgentSessionKey({
sessionKey: rawSessionKey,
agentId,
mainKey: config.session?.mainKey,
cfg: config,
});
const sandbox = await resolveSandboxContext({
config,
sessionKey,
workspaceDir,
});
params.signal?.throwIfAborted();
const effectiveWorkspace =
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir;
const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({
toolsEnabled: true,
toolsAllow: params.toolsAllow,
});
// Bundle MCP tools are source:"mcp", which the headless bridge excludes.
// LSP runtimes are session-scoped and intentionally outside trigger v1.
const allTools = toolPlan.constructTools
? createOpenClawCodingTools({
agentId,
exec: { config },
sandbox,
sessionKey,
trigger: "cron",
jobId: params.jobId,
agentDir,
cwd: effectiveWorkspace,
workspaceDir: effectiveWorkspace,
spawnWorkspaceDir: workspaceDir,
config,
allowGatewaySubagentBinding: true,
includeCoreTools: toolPlan.includeCoreTools,
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
})
: [];
const tools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, {
toolMeta: (tool) => getPluginToolMeta(tool),
});
const hookContext: HookContext = {
agentId,
config,
cwd: effectiveWorkspace,
workspaceDir: effectiveWorkspace,
sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg: config, agentId }),
};
return {
tools,
hookContext,
ctx: {
config,
runtimeConfig: config,
agentId,
sessionKey,
},
};
}
function triggerStateNamespace(state: unknown): CodeModeNamespaceDescriptor {
return {
id: "cron:trigger",
globalName: "trigger",
scope: {
kind: "object",
entries: [["state", { kind: "value", value: state }]],
},
};
}
function triggerResultCandidate(result: Extract<CodeModeHeadlessResult, { status: "completed" }>) {
if (isRecord(result.value) && typeof result.value.fire === "boolean") {
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 {
const candidate = triggerResultCandidate(result);
if (!isRecord(candidate) || typeof candidate.fire !== "boolean") {
return {
kind: "error",
code: "internal_error",
error: "cron trigger script must return an object with boolean fire",
};
}
if (candidate.message !== undefined && typeof candidate.message !== "string") {
return {
kind: "error",
code: "internal_error",
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",
};
}
}
return {
kind: "evaluated",
fire: candidate.fire,
...(typeof candidate.message === "string" ? { message: candidate.message } : {}),
...(hasState ? { state: candidate.state } : {}),
};
}
class TriggerEvaluationTimeoutError extends Error {
constructor(message = "cron trigger evaluation timed out") {
super(message);
this.name = "TriggerEvaluationTimeoutError";
}
}
function createTriggerDeadlineScope(externalSignal?: AbortSignal) {
const controller = new AbortController();
const onExternalAbort = () =>
controller.abort(new TriggerEvaluationTimeoutError("cron trigger evaluation aborted"));
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
if (externalSignal?.aborted) {
onExternalAbort();
}
const timer = setTimeout(
() => controller.abort(new TriggerEvaluationTimeoutError()),
HEADLESS_TRIGGER_WALL_CLOCK_MS,
);
return {
deadline: Date.now() + HEADLESS_TRIGGER_WALL_CLOCK_MS,
signal: controller.signal,
cleanup: () => {
clearTimeout(timer);
externalSignal?.removeEventListener("abort", onExternalAbort);
},
};
}
async function awaitTriggerSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
throw signal.reason instanceof Error ? signal.reason : new TriggerEvaluationTimeoutError();
}
let onAbort: (() => void) | undefined;
try {
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () =>
reject(
signal.reason instanceof Error ? signal.reason : new TriggerEvaluationTimeoutError(),
);
signal.addEventListener("abort", onAbort, { once: true });
});
return await Promise.race([promise, aborted]);
} finally {
if (onAbort) {
signal.removeEventListener("abort", onAbort);
}
}
}
export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
const runHeadless = deps.runHeadless ?? runCodeModeScriptHeadless;
const prepareRuntime = deps.prepareRuntime ?? prepareTriggerRuntime;
// Config identity is the reload epoch; caching the preparation promise makes
// concurrent cold evaluations for one job single-flight.
const runtimeCache = new Map<string, TriggerRuntimeCacheEntry>();
const trimRuntimeCache = () => {
while (runtimeCache.size > MAX_CACHED_TRIGGER_RUNTIMES) {
const oldestJobId = runtimeCache.keys().next().value;
if (oldestJobId === undefined) {
return;
}
runtimeCache.delete(oldestJobId);
}
};
const resolveCachedRuntime = async (request: {
runtimeConfig: OpenClawConfig;
jobId: string;
requestedAgentId?: string;
agentId: string;
toolsAllow?: string[];
toolsAllowKey: string;
signal: AbortSignal;
}): Promise<PreparedTriggerRuntime> => {
const cached = runtimeCache.get(request.jobId);
if (
cached &&
cached.configEpoch === request.runtimeConfig &&
cached.agentId === request.agentId &&
cached.toolsAllowKey === request.toolsAllowKey
) {
runtimeCache.delete(request.jobId);
runtimeCache.set(request.jobId, cached);
return await awaitTriggerSignal(cached.promise, request.signal);
}
const promise = prepareRuntime({
runtimeConfig: request.runtimeConfig,
jobId: request.jobId,
agentId: request.requestedAgentId,
toolsAllow: request.toolsAllow,
signal: request.signal,
});
const entry: TriggerRuntimeCacheEntry = {
promise,
configEpoch: request.runtimeConfig,
agentId: request.agentId,
toolsAllowKey: request.toolsAllowKey,
};
runtimeCache.delete(request.jobId);
runtimeCache.set(request.jobId, entry);
trimRuntimeCache();
// Failed preparations evict themselves so the next tick retries cold.
void promise.catch(() => {
if (runtimeCache.get(request.jobId) === entry) {
runtimeCache.delete(request.jobId);
}
});
return await awaitTriggerSignal(entry.promise, request.signal);
};
return async function evaluateCronTrigger(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);
try {
const runtimeConfig = resolveCronActiveRuntimeConfig(deps.config);
const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId);
const toolsAllowKey = JSON.stringify(params.toolsAllow ?? null);
const runtime = await resolveCachedRuntime({
runtimeConfig,
jobId: params.jobId,
requestedAgentId: params.agentId,
agentId,
toolsAllow: params.toolsAllow,
toolsAllowKey,
signal: evaluationScope.signal,
});
const catalogRef = createToolSearchCatalogRef();
const runId = `cron-trigger:${params.jobId}:${crypto.randomUUID()}`;
registerHeadlessToolSearchCatalog({
catalogRef,
tools: runtime.tools,
hookContext: { ...runtime.hookContext, runId },
});
const remainingWallClockMs = evaluationScope.deadline - Date.now();
if (remainingWallClockMs <= 0) {
throw new TriggerEvaluationTimeoutError();
}
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)],
signal: evaluationScope.signal,
});
if (result.status === "failed") {
return { kind: "error", code: result.code, error: result.error };
}
return parseTriggerResult(result);
} catch (error) {
return {
kind: "error",
code: error instanceof TriggerEvaluationTimeoutError ? "timeout" : "internal_error",
error: error instanceof Error ? error.message : String(error),
};
} finally {
evaluationScope.cleanup();
activeTriggerEvaluations -= 1;
}
};
}

View file

@ -320,6 +320,14 @@ export type CronJobState = {
lastFailureAlertAtMs?: number;
/** Number of consecutive schedule computation errors. Auto-disables job after threshold. */
scheduleErrorCount?: number;
/** Timestamp of the last trigger script evaluation. */
lastTriggerEvalAtMs?: number;
/** Number of completed trigger script evaluations. */
triggerEvalCount?: number;
/** Timestamp of the last trigger evaluation that fired. */
lastTriggerFireAtMs?: number;
/** JSON state returned by the last trigger script evaluation. */
triggerState?: unknown;
/** Explicit delivery outcome, separate from execution outcome. */
lastDeliveryStatus?: CronDeliveryStatus;
/** Delivery-specific error text when available. */
@ -334,6 +342,32 @@ export type CronJobState = {
lastFailureNotificationDeliveryError?: string;
};
export type CronTrigger = {
script: string;
once?: boolean;
};
/**
* Closed failure taxonomy for trigger-script evaluation. Mirrors the code-mode
* failure codes plus the trigger tool budget; trigger-script.ts asserts the
* union stays in sync at compile time. Lives here (leaf module) so the cron
* service contract never imports the agents runtime.
*/
export type CronTriggerFailureCode =
| "invalid_input"
| "runtime_unavailable"
| "timeout"
| "output_limit_exceeded"
| "snapshot_limit_exceeded"
| "internal_error"
| "tool_budget_exceeded";
/** Result union returned by the cron trigger-script evaluator. */
export type CronTriggerEvaluationResult =
| { kind: "evaluated"; fire: boolean; message?: string; state?: unknown }
| { kind: "busy" }
| { kind: "error"; code: CronTriggerFailureCode; error: string };
/** Fully persisted cron job with spec fields and mutable run state. */
export type CronJob = CronJobBase<
CronSchedule,
@ -349,6 +383,7 @@ export type CronJob = CronJobBase<
agentId?: string;
sessionKey?: string;
};
trigger?: CronTrigger;
state: CronJobState;
};
@ -380,6 +415,7 @@ export type CronJobPatch = Partial<
>
> & {
displayName?: string | null;
trigger?: CronTrigger | null;
payload?: CronPayloadPatch;
delivery?: CronDeliveryPatch;
state?: Partial<CronJobState>;

View file

@ -31,6 +31,7 @@ import {
resolveCronSessionTargetSessionKey,
} from "../cron/session-target.js";
import { resolveCronJobsStorePath } from "../cron/store.js";
import { createCronTriggerEvaluator } 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";
@ -356,6 +357,10 @@ export function buildGatewayCronService(params: {
agentId: agentId ?? defaultAgentId,
});
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
const triggerEvaluator =
params.cfg.cron?.triggers?.enabled === true
? createCronTriggerEvaluator({ config: params.cfg })
: undefined;
const runCronChangedHook = (evt: PluginHookCronChangedEvent) => {
const hookRunner = getGlobalHookRunner();
@ -399,6 +404,19 @@ export function buildGatewayCronService(params: {
storePath,
cronEnabled,
cronConfig: params.cfg.cron,
...(triggerEvaluator
? {
evaluateCronTrigger: ({ job, script, state, abortSignal }) =>
triggerEvaluator({
jobId: job.id,
agentId: job.agentId,
script,
state,
toolsAllow: job.payload.kind === "agentTurn" ? job.payload.toolsAllow : undefined,
abortSignal,
}),
}
: {}),
defaultAgentId,
resolveSessionStorePath,
sessionStorePath,
@ -731,6 +749,7 @@ export function buildGatewayCronService(params: {
runAtMs: evt.runAtMs,
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
triggerFired: evt.triggerFired,
model: evt.model,
provider: evt.provider,
usage: evt.usage,

View file

@ -8,6 +8,9 @@ export function isCronInvalidRequestError(err: unknown): boolean {
message.includes("cron job id must not be blank") ||
message.includes("cron declarationKey") ||
message.includes("cron displayName") ||
message.includes("cron triggers are disabled") ||
message.includes("cron triggers require") ||
message.includes("cron trigger every interval") ||
message.includes("cron job is missing sessionTarget") ||
message.includes("invalid cron sessionTarget session id") ||
message.includes('main cron jobs require payload.kind="systemEvent"') ||

View file

@ -101,6 +101,7 @@ function compactCronListJob(job: CronJob) {
enabled: job.enabled,
nextRunAtMs: job.state.nextRunAtMs ?? null,
scheduleKind: job.schedule.kind,
...(job.trigger ? { trigger: true } : {}),
lastRunAtMs: job.state.lastRunAtMs ?? null,
lastRunStatus: job.state.lastRunStatus ?? job.state.lastStatus ?? null,
lastRunError: job.state.lastError ?? null,
@ -189,6 +190,7 @@ async function assertValidCronUpdatePatch(params: {
const nextJob = structuredClone(params.currentJob);
applyJobPatch(nextJob, params.patch, {
defaultAgentId: params.defaultAgentId,
cronConfig: params.cfg.cron,
});
if ("delivery" in params.patch) {
const delivery =

View file

@ -575,6 +575,32 @@ describe("gateway server cron", () => {
}
});
test("returns INVALID_REQUEST when cron trigger authoring is disabled", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-trigger-gate-",
cronEnabled: false,
});
const cronState = await createDirectCronState();
try {
const response = await directCronReq(cronState, "cron.add", {
name: "disabled watcher",
enabled: true,
schedule: { kind: "every", everyMs: 30_000 },
trigger: { script: "json({ fire: true })" },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "changed" },
});
expect(response.ok).toBe(false);
expect(response.error?.code).toBe("INVALID_REQUEST");
expect(response.error?.message).toContain("cron triggers are disabled");
} finally {
await cleanupCronTestRun({ cronState, prevSkipCron });
}
});
test("cron.add leaves legacy top-level array stores for doctor migration", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-legacy-array-",

View file

@ -346,6 +346,8 @@ export interface CronJobs {
stagger_ms: number | null;
state_json: Generated<string>;
store_key: string;
trigger_once: number | null;
trigger_script: string | null;
updated_at: number;
wake_mode: string;
}

View file

@ -746,6 +746,8 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "cron_jobs", "stagger_ms INTEGER");
ensureColumn(db, "cron_jobs", "session_target TEXT NOT NULL DEFAULT 'main'");
ensureColumn(db, "cron_jobs", "wake_mode TEXT NOT NULL DEFAULT 'auto'");
ensureColumn(db, "cron_jobs", "trigger_script TEXT");
ensureColumn(db, "cron_jobs", "trigger_once INTEGER");
ensureColumn(db, "cron_jobs", "payload_kind TEXT NOT NULL DEFAULT 'message'");
ensureColumn(db, "cron_jobs", "payload_message TEXT");
ensureColumn(db, "cron_jobs", "payload_model TEXT");

View file

@ -1000,6 +1000,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
stagger_ms INTEGER,
session_target TEXT NOT NULL,
wake_mode TEXT NOT NULL,
trigger_script TEXT,
trigger_once INTEGER,
payload_kind TEXT NOT NULL,
payload_message TEXT,
payload_model TEXT,

View file

@ -995,6 +995,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
stagger_ms INTEGER,
session_target TEXT NOT NULL,
wake_mode TEXT NOT NULL,
trigger_script TEXT,
trigger_once INTEGER,
payload_kind TEXT NOT NULL,
payload_message TEXT,
payload_model TEXT,

View file

@ -395,7 +395,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@ -684,6 +684,21 @@
"description": "main | isolated | current | session:<id>",
"type": "string"
},
"trigger": {
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@ -1022,6 +1037,28 @@
"description": "Session target",
"type": "string"
},
"trigger": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
{
"type": "null"
}
]
},
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"

View file

@ -391,7 +391,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@ -680,6 +680,21 @@
"description": "main | isolated | current | session:<id>",
"type": "string"
},
"trigger": {
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@ -1018,6 +1033,28 @@
"description": "Session target",
"type": "string"
},
"trigger": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
{
"type": "null"
}
]
},
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"

View file

@ -391,7 +391,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@ -680,6 +680,21 @@
"description": "main | isolated | current | session:<id>",
"type": "string"
},
"trigger": {
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@ -1018,6 +1033,28 @@
"description": "Session target",
"type": "string"
},
"trigger": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"once": {
"type": "boolean"
},
"script": {
"maxLength": 65536,
"minLength": 1,
"type": "string"
}
},
"required": ["script"],
"type": "object"
},
{
"type": "null"
}
]
},
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"

View file

@ -225,8 +225,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 51940,
"roughTokens": 12985
"chars": 53471,
"roughTokens": 13368
},
"openClawDeveloperInstructions": {
"chars": 3245,
@ -237,8 +237,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6943
},
"totalWithDynamicToolsJson": {
"chars": 79712,
"roughTokens": 19928
"chars": 81243,
"roughTokens": 20311
},
"userInputText": {
"chars": 1442,

View file

@ -225,8 +225,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 51629,
"roughTokens": 12908
"chars": 53160,
"roughTokens": 13290
},
"openClawDeveloperInstructions": {
"chars": 2136,
@ -237,8 +237,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6563
},
"totalWithDynamicToolsJson": {
"chars": 77883,
"roughTokens": 19471
"chars": 79414,
"roughTokens": 19854
},
"userInputText": {
"chars": 1033,

View file

@ -226,8 +226,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 52919,
"roughTokens": 13230
"chars": 54450,
"roughTokens": 13613
},
"openClawDeveloperInstructions": {
"chars": 2155,
@ -238,8 +238,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6799
},
"totalWithDynamicToolsJson": {
"chars": 80116,
"roughTokens": 20029
"chars": 81647,
"roughTokens": 20412
},
"userInputText": {
"chars": 1271,