mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
525f58e9c3
commit
a6768d9de5
53 changed files with 2827 additions and 86 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
288
src/agents/code-mode-headless.test.ts
Normal file
288
src/agents/code-mode-headless.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ describe("createCronToolSchema", () => {
|
|||
"schedule",
|
||||
"sessionKey",
|
||||
"sessionTarget",
|
||||
"trigger",
|
||||
"wakeMode",
|
||||
].toSorted(),
|
||||
);
|
||||
|
|
@ -78,6 +79,7 @@ describe("createCronToolSchema", () => {
|
|||
"schedule",
|
||||
"sessionKey",
|
||||
"sessionTarget",
|
||||
"trigger",
|
||||
"wakeMode",
|
||||
].toSorted(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? "-") : "-"}`);
|
||||
|
|
|
|||
79
src/cli/cron-cli/trigger-options.test.ts
Normal file
79
src/cli/cron-cli/trigger-options.test.ts
Normal 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 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
40
src/cli/cron-cli/trigger-options.ts
Normal file
40
src/cli/cron-cli/trigger-options.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
20
src/config/zod-schema.cron-triggers.test.ts
Normal file
20
src/config/zod-schema.cron-triggers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
46
src/cron/persisted-shape.trigger.test.ts
Normal file
46
src/cron/persisted-shape.trigger.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -30,4 +30,5 @@ export type CronRunLogEntry = {
|
|||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
nextRunAtMs?: number;
|
||||
triggerFired?: boolean;
|
||||
} & CronRunTelemetry;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
338
src/cron/service.trigger-eval.test.ts
Normal file
338
src/cron/service.trigger-eval.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
61
src/cron/store/trigger-codec.test.ts
Normal file
61
src/cron/store/trigger-codec.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
25
src/cron/store/trigger-codec.ts
Normal file
25
src/cron/store/trigger-codec.ts
Normal 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) } : {}),
|
||||
};
|
||||
}
|
||||
280
src/cron/trigger-script.test.ts
Normal file
280
src/cron/trigger-script.test.ts
Normal 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
438
src/cron/trigger-script.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"') ||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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-",
|
||||
|
|
|
|||
2
src/state/openclaw-state-db.generated.d.ts
vendored
2
src/state/openclaw-state-db.generated.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue