From a6768d9de567d56b0b8466f2eb73ffd8e79c9d8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 19:12:38 +0100 Subject: [PATCH] =?UTF-8?q?feat(cron):=20event=20triggers=20=E2=80=94=20po?= =?UTF-8?q?lled=20condition-watcher=20scripts=20via=20code=20mode=20(#1011?= =?UTF-8?q?95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../OpenClawProtocol/GatewayModels.swift | 12 + docs/automation/cron-jobs.md | 39 ++ docs/docs_map.md | 1 + .../src/cron-validators.test.ts | 32 ++ packages/gateway-protocol/src/schema/cron.ts | 21 + src/agents/code-mode-headless.test.ts | 288 ++++++++++++ src/agents/code-mode.ts | 374 ++++++++++++++- src/agents/tool-search.ts | 24 + src/agents/tools/cron-tool.schema.test.ts | 2 + src/agents/tools/cron-tool.ts | 16 + src/cli/cron-cli/register.cron-add.ts | 14 + src/cli/cron-cli/register.cron-edit.ts | 23 + src/cli/cron-cli/shared.test.ts | 20 + src/cli/cron-cli/shared.ts | 18 +- src/cli/cron-cli/trigger-options.test.ts | 79 ++++ src/cli/cron-cli/trigger-options.ts | 40 ++ src/config/cron-limits.ts | 10 + src/config/types.cron.ts | 4 + src/config/zod-schema.cron-triggers.test.ts | 20 + src/config/zod-schema.ts | 7 + src/cron/isolated-agent/run-config.ts | 18 + src/cron/isolated-agent/run.ts | 22 +- src/cron/normalize.test.ts | 15 + src/cron/normalize.ts | 19 + src/cron/persisted-shape.trigger.test.ts | 46 ++ src/cron/persisted-shape.ts | 16 + src/cron/run-log-types.ts | 1 + src/cron/run-log/entry-codec.ts | 1 + src/cron/service.trigger-eval.test.ts | 338 ++++++++++++++ src/cron/service/jobs.ts | 59 ++- src/cron/service/ops.ts | 100 ++-- src/cron/service/state.ts | 8 + src/cron/service/timer.ts | 183 +++++++- src/cron/store/row-codec.ts | 4 + src/cron/store/trigger-codec.test.ts | 61 +++ src/cron/store/trigger-codec.ts | 25 + src/cron/trigger-script.test.ts | 280 +++++++++++ src/cron/trigger-script.ts | 438 ++++++++++++++++++ src/cron/types.ts | 36 ++ src/gateway/server-cron.ts | 19 + .../cron-error-classification.ts | 3 + src/gateway/server-methods/cron.ts | 2 + src/gateway/server.cron.test.ts | 26 ++ src/state/openclaw-state-db.generated.d.ts | 2 + src/state/openclaw-state-db.ts | 2 + src/state/openclaw-state-schema.generated.ts | 2 + src/state/openclaw-state-schema.sql | 2 + .../codex-dynamic-tools.discord-group.json | 39 +- .../codex-dynamic-tools.heartbeat-turn.json | 39 +- .../codex-dynamic-tools.telegram-direct.json | 39 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- 53 files changed, 2827 insertions(+), 86 deletions(-) create mode 100644 src/agents/code-mode-headless.test.ts create mode 100644 src/cli/cron-cli/trigger-options.test.ts create mode 100644 src/cli/cron-cli/trigger-options.ts create mode 100644 src/config/zod-schema.cron-triggers.test.ts create mode 100644 src/cron/persisted-shape.trigger.test.ts create mode 100644 src/cron/service.trigger-eval.test.ts create mode 100644 src/cron/store/trigger-codec.test.ts create mode 100644 src/cron/store/trigger-codec.ts create mode 100644 src/cron/trigger-script.test.ts create mode 100644 src/cron/trigger-script.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index fb2bc2f563e..182c1818503 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index cff3036dce1..daa3fcbd4cf 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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. + + +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. + + +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], diff --git a/docs/docs_map.md b/docs/docs_map.md index 5f03c2442e5..3eea26e063a 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 075096b55f3..00b4f39ecac 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -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); diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index d0e56b8989a..f9fcc4ca43e 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -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( diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts new file mode 100644 index 00000000000..e3ecaf246dc --- /dev/null +++ b/src/agents/code-mode-headless.test.ts @@ -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((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, + }); + }); +}); diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index ba166cae3a4..9261f3f90f2 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -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 { 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 | undefined; + let onAbort: (() => void) | undefined; try { return await new Promise((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(params: { + promise: Promise; + deadline: number; + signal?: AbortSignal; +}): Promise { + const remainingMs = remainingHeadlessMs(params.deadline); + if (params.signal?.aborted) { + throw headlessAbortError(params.signal); + } + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const timeout = new Promise((_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; + config: CodeModeConfig; + deadline: number; + signal: AbortSignal; +}): Promise { + 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 { + 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 | null => diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 24dd5527e2a..ea94bd027a2 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -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 { const nameCounts = new Map(); for (const tool of tools) { diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 3ba2cbfbb85..11dd4c16e7d 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -58,6 +58,7 @@ describe("createCronToolSchema", () => { "schedule", "sessionKey", "sessionTarget", + "trigger", "wakeMode", ].toSorted(), ); @@ -78,6 +79,7 @@ describe("createCronToolSchema", () => { "schedule", "sessionKey", "sessionTarget", + "trigger", "wakeMode", ].toSorted(), ); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 80d94ac4c2c..5af1f9f8583 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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:", @@ -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:", @@ -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): diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 5dac973dfa0..4b41c6b32fc 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -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 ", "Cron stagger window (e.g. 30s, 5m)") .option("--exact", "Disable cron staggering (set stagger to 0)", false) + .option("--trigger-script ", "Condition script file, or - for stdin") + .option("--trigger-once", "Disable after the first successful triggered run", false) .option("--system-event ", "System event payload (main session)") .option("--message ", "Agent message payload") .option("--command ", "Command payload run as sh -lc 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, diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index ff68723fb48..46196437c09 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -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 ", "Cron stagger window (e.g. 30s, 5m)") .option("--exact", "Disable cron staggering (set stagger to 0)") + .option("--trigger-script ", "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 ", "Set systemEvent payload") .option("--message ", "Set agentTurn payload message") .option("--command ", "Set command payload run as sh -lc 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, diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index 4bc7a84a9d9..7e426a1695d 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -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", diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 120978f38ba..200afe570b3 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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 ?? "-") : "-"}`); diff --git a/src/cli/cron-cli/trigger-options.test.ts b/src/cli/cron-cli/trigger-options.test.ts new file mode 100644 index 00000000000..676c151b226 --- /dev/null +++ b/src/cli/cron-cli/trigger-options.test.ts @@ -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("../gateway-rpc.js"); + return { + ...actual, + callGatewayFromCli: (...args: Parameters) => + 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 } }, + ); + }); +}); diff --git a/src/cli/cron-cli/trigger-options.ts b/src/cli/cron-cli/trigger-options.ts new file mode 100644 index 00000000000..98a29f62ec9 --- /dev/null +++ b/src/cli/cron-cli/trigger-options.ts @@ -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 { + 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; + stdin?: NodeJS.ReadableStream; + }, +): Promise { + 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; +} diff --git a/src/config/cron-limits.ts b/src/config/cron-limits.ts index 2cb2c78d5a0..e7442caa7a7 100644 --- a/src/config/cron-limits.ts +++ b/src/config/cron-limits.ts @@ -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): 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; +} diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 10bf68594e8..0923d7a20f8 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -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; /** diff --git a/src/config/zod-schema.cron-triggers.test.ts b/src/config/zod-schema.cron-triggers.test.ts new file mode 100644 index 00000000000..3fd5df98b65 --- /dev/null +++ b/src/config/zod-schema.cron-triggers.test.ts @@ -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); + }); +}); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 71c552da962..6e2cbb13979 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/cron/isolated-agent/run-config.ts b/src/cron/isolated-agent/run-config.ts index 66d2c66547b..1be9f1a66fc 100644 --- a/src/cron/isolated-agent/run-config.ts +++ b/src/cron/isolated-agent/run-config.ts @@ -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>; +/** 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, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 48d30337c7c..e2a191b2163 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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; diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 199c491a6c8..20e826c59a3 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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; + + 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", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index bd658b431ed..f60a2213e2a 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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); } diff --git a/src/cron/persisted-shape.trigger.test.ts b/src/cron/persisted-shape.trigger.test.ts new file mode 100644 index 00000000000..970a878ac92 --- /dev/null +++ b/src/cron/persisted-shape.trigger.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { getInvalidPersistedCronJobReason } from "./persisted-shape.js"; + +function candidate(overrides: Record = {}) { + 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"); + }); +}); diff --git a/src/cron/persisted-shape.ts b/src/cron/persisted-shape.ts index 37ed6dc4bc7..65704e0e3e7 100644 --- a/src/cron/persisted-shape.ts +++ b/src/cron/persisted-shape.ts @@ -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).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"; diff --git a/src/cron/run-log-types.ts b/src/cron/run-log-types.ts index 05697fd44ea..46e6ef65752 100644 --- a/src/cron/run-log-types.ts +++ b/src/cron/run-log-types.ts @@ -30,4 +30,5 @@ export type CronRunLogEntry = { runAtMs?: number; durationMs?: number; nextRunAtMs?: number; + triggerFired?: boolean; } & CronRunTelemetry; diff --git a/src/cron/run-log/entry-codec.ts b/src/cron/run-log/entry-codec.ts index f5cf8fcb69b..6446e9b0b1f 100644 --- a/src/cron/run-log/entry-codec.ts +++ b/src/cron/run-log/entry-codec.ts @@ -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 diff --git a/src/cron/service.trigger-eval.test.ts b/src/cron/service.trigger-eval.test.ts new file mode 100644 index 00000000000..7d4994ad0ba --- /dev/null +++ b/src/cron/service.trigger-eval.test.ts @@ -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; +type IsolatedRunner = CronServiceDeps["runIsolatedAgentJob"]; + +function watcher(overrides: Partial = {}): 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(); + } + }); +}); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index e43165897a7..24bdf3d0363 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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, + 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); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index b8c41359d93..0e8b8a97046 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -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); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index b735e4cadfa..d6b26569c05 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -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; /** Default agent id for jobs without an agent id. */ defaultAgentId?: string; /** Resolve session store path for a given agent id. */ diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3fb214819d4..855112993d4 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -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, diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 14b74e049d6..d67203a63db 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -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 } : {}), diff --git a/src/cron/store/trigger-codec.test.ts b/src/cron/store/trigger-codec.test.ts new file mode 100644 index 00000000000..d3c9635bdc2 --- /dev/null +++ b/src/cron/store/trigger-codec.test.ts @@ -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 }); + } + }); +}); diff --git a/src/cron/store/trigger-codec.ts b/src/cron/store/trigger-codec.ts new file mode 100644 index 00000000000..06b80cab66a --- /dev/null +++ b/src/cron/store/trigger-codec.ts @@ -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 { + 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) } : {}), + }; +} diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts new file mode 100644 index 00000000000..e67c2ac40da --- /dev/null +++ b/src/cron/trigger-script.test.ts @@ -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[0]; +type HeadlessParams = Parameters>[0]; +type PrepareParams = Parameters>[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[0]["runHeadless"]> + >[0], + ) => Promise, +) { + 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> = []; + const { evaluate, prepareRuntime } = createEvaluator( + vi.fn(async (params) => { + const wrapped = params.ctx.catalogRef?.current?.entries[0]?.tool; + contexts.push((wrapped ?? {}) as Record); + 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) => void) | undefined; + const pending = new Promise>((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((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 => { + preparationSignal = params.signal; + return await new Promise((_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(); + }); +}); diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts new file mode 100644 index 00000000000..c4db1ee0de3 --- /dev/null +++ b/src/cron/trigger-script.ts @@ -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; + hookContext: Omit; +}; + +type PrepareTriggerRuntime = (params: { + runtimeConfig: OpenClawConfig; + jobId: string; + agentId?: string; + toolsAllow?: string[]; + signal?: AbortSignal; +}) => Promise; + +type CronTriggerEvaluatorDeps = { + config: OpenClawConfig; + runHeadless?: typeof runCodeModeScriptHeadless; + prepareRuntime?: PrepareTriggerRuntime; +}; + +type TriggerRuntimeCacheEntry = { + promise: Promise; + 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 { + 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) { + 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, +): 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(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + throw signal.reason instanceof Error ? signal.reason : new TriggerEvaluationTimeoutError(); + } + let onAbort: (() => void) | undefined; + try { + const aborted = new Promise((_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(); + + 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 => { + 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 { + 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; + } + }; +} diff --git a/src/cron/types.ts b/src/cron/types.ts index 8b15b3c99c5..18fb80e39c7 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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; diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 824ea808ec4..c7b7d5a1509 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -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, diff --git a/src/gateway/server-methods/cron-error-classification.ts b/src/gateway/server-methods/cron-error-classification.ts index cb7952af797..7208d1376de 100644 --- a/src/gateway/server-methods/cron-error-classification.ts +++ b/src/gateway/server-methods/cron-error-classification.ts @@ -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"') || diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 99b0b21b414..068d7f0a2e6 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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 = diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 78334b1b2e8..bcf4d988bb4 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -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-", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 5e2b36aebd3..8f76da0ad86 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -346,6 +346,8 @@ export interface CronJobs { stagger_ms: number | null; state_json: Generated; store_key: string; + trigger_once: number | null; + trigger_script: string | null; updated_at: number; wake_mode: string; } diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 1a2d65bc5bb..666bc03b655 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -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"); diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 52e15cccc8c..8bbc9acdcce 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -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, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 311dd62115a..31c6aaca975 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 78fbc8c11a4..484070a9dba 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:", "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" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index 66fe9aecac5..e74fdee1a05 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:", "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" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 3fabe33e452..fd531c5a6f9 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:\",\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:\": 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\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\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\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\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:", "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" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 7c9ffee1812..d936e33352a 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 8bd2c417396..9bb7fddcbca 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 642a0542d9c..f004bd6819b 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -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,