Skip to content

Commit a6768d9

Browse files
authored
feat(cron): event triggers — polled condition-watcher scripts via code mode (#101195)
* feat(cron): add headless code-mode driver and trigger-script evaluator Part A of cron event triggers: runCodeModeScriptHeadless runs a script to completion (exec/settle/resume, no snapshots, no session), plus the cron-facing evaluator with per-job tool catalog cache, semaphore, 16KB state cap, and closed failure taxonomy. * fix(cron): correct trigger-script bootstrap flags and cache narrowing * feat(cron): add event triggers (polled condition-watcher scripts) Part B: trigger field on cron jobs gated by cron.triggers.enabled; timer evaluates the script each due tick, quiet ticks leave no run history, fired runs append the script message to the payload; once semantics, min-interval floor, SQLite columns, RPC/CLI/agent-tool surfaces, and docs. * fix(cron): propagate triggerEval through startup catch-up outcomes * fix(cron): honor cron staggering on quiet trigger ticks; fix trigger test types * fix(cron): reject with Error reason in trigger-script abort test * fix(cron): regenerate protocol/docs/snapshot artifacts and break madge cycle for trigger types CI fixes for #101195: trigger evaluator result types move to the cron types leaf (madge cycle), cron tool schema inventory gains trigger, Swift protocol bindings + docs map + Linux prompt snapshots regenerated. * fix(cron): drop underscore-dangle names in trigger code sync assert * fix(cron): adopt registerHeadlessToolSearchCatalog after tool-search symbol localization Upstream #101831 made registerToolSearchCatalog module-private; fold the headless ref-only catalog registration into one public seam.
1 parent 525f58e commit a6768d9

53 files changed

Lines changed: 2826 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7057,6 +7057,7 @@ public struct CronJob: Codable, Sendable {
70577057
public let createdatms: Int
70587058
public let updatedatms: Int
70597059
public let schedule: AnyCodable
7060+
public let trigger: [String: AnyCodable]?
70607061
public let sessiontarget: AnyCodable
70617062
public let wakemode: AnyCodable
70627063
public let payload: AnyCodable
@@ -7088,6 +7089,7 @@ public struct CronJob: Codable, Sendable {
70887089
createdatms: Int,
70897090
updatedatms: Int,
70907091
schedule: AnyCodable,
7092+
trigger: [String: AnyCodable]?,
70917093
sessiontarget: AnyCodable,
70927094
wakemode: AnyCodable,
70937095
payload: AnyCodable,
@@ -7118,6 +7120,7 @@ public struct CronJob: Codable, Sendable {
71187120
self.createdatms = createdatms
71197121
self.updatedatms = updatedatms
71207122
self.schedule = schedule
7123+
self.trigger = trigger
71217124
self.sessiontarget = sessiontarget
71227125
self.wakemode = wakemode
71237126
self.payload = payload
@@ -7150,6 +7153,7 @@ public struct CronJob: Codable, Sendable {
71507153
case createdatms = "createdAtMs"
71517154
case updatedatms = "updatedAtMs"
71527155
case schedule
7156+
case trigger
71537157
case sessiontarget = "sessionTarget"
71547158
case wakemode = "wakeMode"
71557159
case payload
@@ -7236,6 +7240,7 @@ public struct CronAddParams: Codable, Sendable {
72367240
public let enabled: Bool?
72377241
public let deleteafterrun: Bool?
72387242
public let schedule: AnyCodable
7243+
public let trigger: [String: AnyCodable]?
72397244
public let sessiontarget: AnyCodable
72407245
public let wakemode: AnyCodable
72417246
public let payload: AnyCodable
@@ -7253,6 +7258,7 @@ public struct CronAddParams: Codable, Sendable {
72537258
enabled: Bool?,
72547259
deleteafterrun: Bool?,
72557260
schedule: AnyCodable,
7261+
trigger: [String: AnyCodable]?,
72567262
sessiontarget: AnyCodable,
72577263
wakemode: AnyCodable,
72587264
payload: AnyCodable,
@@ -7269,6 +7275,7 @@ public struct CronAddParams: Codable, Sendable {
72697275
self.enabled = enabled
72707276
self.deleteafterrun = deleteafterrun
72717277
self.schedule = schedule
7278+
self.trigger = trigger
72727279
self.sessiontarget = sessiontarget
72737280
self.wakemode = wakemode
72747281
self.payload = payload
@@ -7287,6 +7294,7 @@ public struct CronAddParams: Codable, Sendable {
72877294
case enabled
72887295
case deleteafterrun = "deleteAfterRun"
72897296
case schedule
7297+
case trigger
72907298
case sessiontarget = "sessionTarget"
72917299
case wakemode = "wakeMode"
72927300
case payload
@@ -7394,6 +7402,7 @@ public struct CronRunLogEntry: Codable, Sendable {
73947402
public let runatms: Int?
73957403
public let durationms: Int?
73967404
public let nextrunatms: Int?
7405+
public let triggerfired: Bool?
73977406
public let model: String?
73987407
public let provider: String?
73997408
public let usage: [String: AnyCodable]?
@@ -7418,6 +7427,7 @@ public struct CronRunLogEntry: Codable, Sendable {
74187427
runatms: Int?,
74197428
durationms: Int?,
74207429
nextrunatms: Int?,
7430+
triggerfired: Bool?,
74217431
model: String?,
74227432
provider: String?,
74237433
usage: [String: AnyCodable]?,
@@ -7441,6 +7451,7 @@ public struct CronRunLogEntry: Codable, Sendable {
74417451
self.runatms = runatms
74427452
self.durationms = durationms
74437453
self.nextrunatms = nextrunatms
7454+
self.triggerfired = triggerfired
74447455
self.model = model
74457456
self.provider = provider
74467457
self.usage = usage
@@ -7466,6 +7477,7 @@ public struct CronRunLogEntry: Codable, Sendable {
74667477
case runatms = "runAtMs"
74677478
case durationms = "durationMs"
74687479
case nextrunatms = "nextRunAtMs"
7480+
case triggerfired = "triggerFired"
74697481
case model
74707482
case provider
74717483
case usage

docs/automation/cron-jobs.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,41 @@ Cron expressions are parsed by [croner](https://github.com/Hexagon/croner). When
8888

8989
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.
9090

91+
## Event triggers (condition watchers)
92+
93+
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`:
94+
95+
```json5
96+
{
97+
schedule: { kind: "every", everyMs: 30000 },
98+
trigger: {
99+
// Fires only when the observed status differs from the last evaluation.
100+
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 } });",
101+
once: false,
102+
},
103+
payload: { kind: "agentTurn", message: "Investigate the CI status change." },
104+
}
105+
```
106+
107+
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.
108+
109+
`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.
110+
111+
<Warning>
112+
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.
113+
</Warning>
114+
115+
Create a watcher from a local script file (`-` reads the script from stdin):
116+
117+
```bash
118+
openclaw cron add \
119+
--name "PR CI watcher" \
120+
--every 30s \
121+
--trigger-script ./watch-pr-ci.js \
122+
--message "Respond to the CI status change" \
123+
--session isolated
124+
```
125+
91126
## Payloads
92127

93128
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 `
497532
enabled: true,
498533
store: "~/.openclaw/cron/jobs.json",
499534
maxConcurrentRuns: 8,
535+
triggers: {
536+
enabled: false,
537+
minIntervalMs: 30000,
538+
},
500539
retry: {
501540
maxAttempts: 3,
502541
backoffMs: [30000, 60000, 300000],

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
6666
- H2: How cron works
6767
- H2: Schedule types
6868
- H3: Day-of-month and day-of-week use OR logic
69+
- H2: Event triggers (condition watchers)
6970
- H2: Payloads
7071
- H3: Agent-turn options
7172
- H3: Command payloads

packages/gateway-protocol/src/cron-validators.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,38 @@ describe("cron protocol validators", () => {
3636
expect(validateCronAddParams(minimalAddParams)).toBe(true);
3737
});
3838

39+
it("accepts trigger add, patch, and clear shapes", () => {
40+
expect(
41+
validateCronAddParams({
42+
...minimalAddParams,
43+
trigger: { script: "json({ fire: true })", once: true },
44+
}),
45+
).toBe(true);
46+
expect(
47+
validateCronUpdateParams({
48+
id: "job-1",
49+
patch: { trigger: { script: "json({ fire: false })" } },
50+
}),
51+
).toBe(true);
52+
expect(validateCronUpdateParams({ id: "job-1", patch: { trigger: null } })).toBe(true);
53+
});
54+
55+
it("rejects invalid trigger scripts and additional properties", () => {
56+
expect(validateCronAddParams({ ...minimalAddParams, trigger: { script: "" } })).toBe(false);
57+
expect(
58+
validateCronAddParams({
59+
...minimalAddParams,
60+
trigger: { script: "json({ fire: true })", unexpected: true },
61+
}),
62+
).toBe(false);
63+
expect(
64+
validateCronUpdateParams({
65+
id: "job-1",
66+
patch: { trigger: { script: "json({ fire: true })", unexpected: true } },
67+
}),
68+
).toBe(false);
69+
});
70+
3971
it("rejects public caller scope on cron admin params", () => {
4072
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
4173
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,15 @@ export const CronScheduleSchema = Type.Union([
247247
),
248248
]);
249249

250+
/** Headless condition script evaluated before a recurring cron payload runs. */
251+
export const CronTriggerSchema = Type.Object(
252+
{
253+
script: Type.String({ minLength: 1, maxLength: 65_536 }),
254+
once: Type.Optional(Type.Boolean()),
255+
},
256+
{ additionalProperties: false },
257+
);
258+
250259
/** Full cron payload for new jobs. */
251260
export const CronPayloadSchema = Type.Union([
252261
Type.Object(
@@ -431,6 +440,10 @@ export const CronJobStateSchema = Type.Object(
431440
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
432441
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
433442
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
443+
lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
444+
triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
445+
lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
446+
triggerState: Type.Optional(Type.Unknown()),
434447
},
435448
{ additionalProperties: false },
436449
);
@@ -454,6 +467,10 @@ const CronJobStatePatchSchema = Type.Object(
454467
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
455468
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
456469
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
470+
lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
471+
triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
472+
lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
473+
triggerState: Type.Optional(Type.Unknown()),
457474
},
458475
{ additionalProperties: false },
459476
);
@@ -474,6 +491,7 @@ export const CronJobSchema = Type.Object(
474491
createdAtMs: Type.Integer({ minimum: 0 }),
475492
updatedAtMs: Type.Integer({ minimum: 0 }),
476493
schedule: CronScheduleSchema,
494+
trigger: Type.Optional(CronTriggerSchema),
477495
sessionTarget: CronSessionTargetSchema,
478496
wakeMode: CronWakeModeSchema,
479497
payload: CronPayloadSchema,
@@ -527,6 +545,7 @@ export const CronAddParamsSchema = Type.Object(
527545
owner: Type.Optional(CronOwnerSchema),
528546
...CronCommonOptionalFields,
529547
schedule: CronScheduleSchema,
548+
trigger: Type.Optional(CronTriggerSchema),
530549
sessionTarget: CronSessionTargetSchema,
531550
wakeMode: CronWakeModeSchema,
532551
payload: CronPayloadSchema,
@@ -556,6 +575,7 @@ export const CronJobPatchSchema = Type.Object(
556575
displayName: Type.Optional(Type.Union([CronDisplayNameSchema, Type.Null()])),
557576
...CronCommonOptionalFields,
558577
schedule: Type.Optional(CronScheduleSchema),
578+
trigger: Type.Optional(Type.Union([CronTriggerSchema, Type.Null()])),
559579
sessionTarget: Type.Optional(CronSessionTargetSchema),
560580
wakeMode: Type.Optional(CronWakeModeSchema),
561581
payload: Type.Optional(CronPayloadPatchSchema),
@@ -621,6 +641,7 @@ export const CronRunLogEntrySchema = Type.Object(
621641
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
622642
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
623643
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
644+
triggerFired: Type.Optional(Type.Boolean()),
624645
model: Type.Optional(Type.String()),
625646
provider: Type.Optional(Type.String()),
626647
usage: Type.Optional(

0 commit comments

Comments
 (0)