cron: add lifecycle hooks for job execution#45239
cron: add lifecycle hooks for job execution#45239Arry8 wants to merge 3 commits intoopenclaw:mainfrom
Conversation
* fix(node-host): harden pnpm approval binding * fix(node-host): harden perl approval binding * docs(plugins): clarify workspace shadowing * fix(ui): keep shared auth on insecure control-ui connects (openclaw#45088) Merged via squash. Prepared head SHA: 99eb3fd Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark * docs: add Claude Code token efficiency playbook * docs: compress playbook for agent consumption * docs: restructure playbook for long-context patterns * pre-commit: add incremental repo-map updater Auto-updates docs/repo-map.json exports and dependencies when src/ or extensions/ .ts files are committed. Preserves existing purpose fields, removes deleted files, adds new files with empty purpose. Regex-based extraction, no dependencies. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Radek Sienkiewicz <mail@velvetshark.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Add a hook system that runs user-defined scripts at cron job lifecycle points (beforeRun, afterComplete, onFailure, afterRun). Hooks are configured via openclaw.json cron.hooks (global) and per-job hooks in the job store. Supports priority ordering, job/agent filtering, skipGlobal overrides, and abort capability on beforeRun hooks.
Greptile SummaryThis PR adds a lifecycle hook system for cron job execution, supporting four hook points (
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 125-138
Comment:
**`filter.workflow` is typed and documented but never enforced**
`matchesFilter()` skips the `workflow` filter entirely and leaves a comment saying "workflow filter is checked by the caller." However, the callers in `timer.ts` never check `filter.workflow` either. The `workflow` field is always hard-coded to `"cron"` in `makeHookCtx` and is placed on the context object, but no code ever evaluates `entry.filter.workflow` against it.
Any hook configured with `filter: { workflow: ["some-value"] }` will silently match every job regardless of that filter. A user reading the `CronHookEntry` type definition would have no indication the field is a no-op.
The fix is to add the check inside `matchesFilter` (passing the workflow string from the context or job), or to remove `workflow` from the `filter` type until it is implemented:
```ts
if (f.workflow && f.workflow.length > 0 && !f.workflow.includes(workflow)) {
return false;
}
```
where `workflow` is threaded into `matchesFilter` (e.g. `"cron"` for all timer-driven hooks).
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 133-135
Comment:
**`agentId` filter bypassed when `job.agentId` is undefined**
The guard `&& job.agentId &&` causes the entire `agentId` filter to be skipped when the job has no `agentId`. As a result, a hook configured with `filter: { agentId: ["my-agent"] }` (acting as a whitelist) will fire for **every** agent-less job, defeating the intent of the filter.
Since `agentId` is optional on `CronJobBase`, this is a realistic case. The fix should treat an unset `agentId` as non-matching when the filter is non-empty:
```suggestion
if (f.agentId && f.agentId.length > 0 && (!job.agentId || !f.agentId.includes(job.agentId))) {
return false;
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 146-150
Comment:
**`setTimeout` handle leaked on successful hook completion**
`rejectAfterTimeout` creates a `setTimeout` but never clears it. When a hook finishes before the deadline, `Promise.race` resolves with the hook result and the timeout promise is abandoned — but the underlying `setTimeout` keeps ticking for up to 10 seconds before firing a no-op `reject`. In long-running processes (and in test environments that check for open handles) this will accumulate stale timers.
Compare with `executeJobCoreWithTimeout` in `timer.ts` which correctly uses `finally { clearTimeout(timeoutId) }` to clean up its timeout.
A self-contained fix:
```ts
function rejectAfterTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let id: ReturnType<typeof setTimeout>;
const promise = new Promise<never>((_, reject) => {
id = setTimeout(() => reject(new Error(`cron hook timed out after ${ms}ms`)), ms);
});
return { promise, clear: () => clearTimeout(id) };
}
```
and in `runCronHooks`:
```ts
const { promise: timeoutPromise, clear: clearHookTimeout } = rejectAfterTimeout(HOOK_TIMEOUT_MS);
try {
const result = await Promise.race([hookFn(ctx), timeoutPromise]);
// ...
} finally {
clearHookTimeout();
}
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 6612761 |
| function matchesFilter(entry: CronHookEntry, job: CronJob): boolean { | ||
| const f = entry.filter; | ||
| if (!f) { | ||
| return true; | ||
| } | ||
| if (f.jobId && f.jobId.length > 0 && !f.jobId.includes(job.id)) { | ||
| return false; | ||
| } | ||
| if (f.agentId && f.agentId.length > 0 && job.agentId && !f.agentId.includes(job.agentId)) { | ||
| return false; | ||
| } | ||
| // workflow filter is checked by the caller (not per-job, per-invocation). | ||
| return true; | ||
| } |
There was a problem hiding this comment.
filter.workflow is typed and documented but never enforced
matchesFilter() skips the workflow filter entirely and leaves a comment saying "workflow filter is checked by the caller." However, the callers in timer.ts never check filter.workflow either. The workflow field is always hard-coded to "cron" in makeHookCtx and is placed on the context object, but no code ever evaluates entry.filter.workflow against it.
Any hook configured with filter: { workflow: ["some-value"] } will silently match every job regardless of that filter. A user reading the CronHookEntry type definition would have no indication the field is a no-op.
The fix is to add the check inside matchesFilter (passing the workflow string from the context or job), or to remove workflow from the filter type until it is implemented:
if (f.workflow && f.workflow.length > 0 && !f.workflow.includes(workflow)) {
return false;
}where workflow is threaded into matchesFilter (e.g. "cron" for all timer-driven hooks).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 125-138
Comment:
**`filter.workflow` is typed and documented but never enforced**
`matchesFilter()` skips the `workflow` filter entirely and leaves a comment saying "workflow filter is checked by the caller." However, the callers in `timer.ts` never check `filter.workflow` either. The `workflow` field is always hard-coded to `"cron"` in `makeHookCtx` and is placed on the context object, but no code ever evaluates `entry.filter.workflow` against it.
Any hook configured with `filter: { workflow: ["some-value"] }` will silently match every job regardless of that filter. A user reading the `CronHookEntry` type definition would have no indication the field is a no-op.
The fix is to add the check inside `matchesFilter` (passing the workflow string from the context or job), or to remove `workflow` from the `filter` type until it is implemented:
```ts
if (f.workflow && f.workflow.length > 0 && !f.workflow.includes(workflow)) {
return false;
}
```
where `workflow` is threaded into `matchesFilter` (e.g. `"cron"` for all timer-driven hooks).
How can I resolve this? If you propose a fix, please make it concise.| if (f.agentId && f.agentId.length > 0 && job.agentId && !f.agentId.includes(job.agentId)) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
agentId filter bypassed when job.agentId is undefined
The guard && job.agentId && causes the entire agentId filter to be skipped when the job has no agentId. As a result, a hook configured with filter: { agentId: ["my-agent"] } (acting as a whitelist) will fire for every agent-less job, defeating the intent of the filter.
Since agentId is optional on CronJobBase, this is a realistic case. The fix should treat an unset agentId as non-matching when the filter is non-empty:
| if (f.agentId && f.agentId.length > 0 && job.agentId && !f.agentId.includes(job.agentId)) { | |
| return false; | |
| } | |
| if (f.agentId && f.agentId.length > 0 && (!job.agentId || !f.agentId.includes(job.agentId))) { | |
| return false; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 133-135
Comment:
**`agentId` filter bypassed when `job.agentId` is undefined**
The guard `&& job.agentId &&` causes the entire `agentId` filter to be skipped when the job has no `agentId`. As a result, a hook configured with `filter: { agentId: ["my-agent"] }` (acting as a whitelist) will fire for **every** agent-less job, defeating the intent of the filter.
Since `agentId` is optional on `CronJobBase`, this is a realistic case. The fix should treat an unset `agentId` as non-matching when the filter is non-empty:
```suggestion
if (f.agentId && f.agentId.length > 0 && (!job.agentId || !f.agentId.includes(job.agentId))) {
return false;
}
```
How can I resolve this? If you propose a fix, please make it concise.| function rejectAfterTimeout(ms: number): Promise<never> { | ||
| return new Promise<never>((_, reject) => { | ||
| setTimeout(() => reject(new Error(`cron hook timed out after ${ms}ms`)), ms); | ||
| }); | ||
| } |
There was a problem hiding this comment.
setTimeout handle leaked on successful hook completion
rejectAfterTimeout creates a setTimeout but never clears it. When a hook finishes before the deadline, Promise.race resolves with the hook result and the timeout promise is abandoned — but the underlying setTimeout keeps ticking for up to 10 seconds before firing a no-op reject. In long-running processes (and in test environments that check for open handles) this will accumulate stale timers.
Compare with executeJobCoreWithTimeout in timer.ts which correctly uses finally { clearTimeout(timeoutId) } to clean up its timeout.
A self-contained fix:
function rejectAfterTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let id: ReturnType<typeof setTimeout>;
const promise = new Promise<never>((_, reject) => {
id = setTimeout(() => reject(new Error(`cron hook timed out after ${ms}ms`)), ms);
});
return { promise, clear: () => clearTimeout(id) };
}and in runCronHooks:
const { promise: timeoutPromise, clear: clearHookTimeout } = rejectAfterTimeout(HOOK_TIMEOUT_MS);
try {
const result = await Promise.race([hookFn(ctx), timeoutPromise]);
// ...
} finally {
clearHookTimeout();
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 146-150
Comment:
**`setTimeout` handle leaked on successful hook completion**
`rejectAfterTimeout` creates a `setTimeout` but never clears it. When a hook finishes before the deadline, `Promise.race` resolves with the hook result and the timeout promise is abandoned — but the underlying `setTimeout` keeps ticking for up to 10 seconds before firing a no-op `reject`. In long-running processes (and in test environments that check for open handles) this will accumulate stale timers.
Compare with `executeJobCoreWithTimeout` in `timer.ts` which correctly uses `finally { clearTimeout(timeoutId) }` to clean up its timeout.
A self-contained fix:
```ts
function rejectAfterTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let id: ReturnType<typeof setTimeout>;
const promise = new Promise<never>((_, reject) => {
id = setTimeout(() => reject(new Error(`cron hook timed out after ${ms}ms`)), ms);
});
return { promise, clear: () => clearTimeout(id) };
}
```
and in `runCronHooks`:
```ts
const { promise: timeoutPromise, clear: clearHookTimeout } = rejectAfterTimeout(HOOK_TIMEOUT_MS);
try {
const result = await Promise.race([hookFn(ctx), timeoutPromise]);
// ...
} finally {
clearHookTimeout();
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6612761617
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Global entries from openclaw.json cron.hooks section. | ||
| const globalEntries: ResolvedEntry[] = []; | ||
| if (!skipGlobal) { | ||
| const raw = cronConfig?.hooks?.[hookPoint]; |
There was a problem hiding this comment.
Accept cron.hooks in config validation
Global lifecycle hooks cannot be configured today because cron.hooks never survives config validation: src/config/zod-schema.ts defines cron as a strict object and does not include a hooks key (.strict() at the cron object level). That means any openclaw.json using cron.hooks is rejected before this loader runs, so the new global hook path introduced here is effectively unreachable in normal startup.
Useful? React with 👍 / 👎.
|
|
||
| async function loadHookModule(scriptPath: string): Promise<unknown> { | ||
| // Dynamic import works for .cjs (via jiti/bun) and .ts files. | ||
| const mod = await import(scriptPath); |
There was a problem hiding this comment.
Resolve hook script paths from workspace before import
This imports the configured script string as-is, but the new contract says hook paths may be workspace-relative (src/config/types.cron.ts comment on CronHookEntry.script). Raw import(scriptPath) treats values like workspace/scripts/hooks/audit-log.cjs as package specifiers rather than filesystem paths, so those documented values fail with module resolution errors and hooks never execute unless users provide absolute paths or module-relative ./... paths.
Useful? React with 👍 / 👎.
|
will incorporate suggestions and create new pr |
Summary
beforeRun,afterComplete,onFailure,afterRun)openclaw.jsoncron.hookssection, or per-job viahooksfield in the job storeskipGlobaloverrides, and abort capability onbeforeRunhooksDesign
Celery-inspired named lifecycle hooks with WordPress-style config-driven registration.
beforeRunafterCompleteonFailureafterRunGlobal config (
openclaw.json){ "cron": { "hooks": { "beforeRun": [ { "script": "workspace/scripts/hooks/audit-log.cjs", "priority": 5 } ], "onFailure": [ { "script": "workspace/scripts/hooks/alert.cjs" } ] } } }Per-job config (job store)
{ "id": "my-job", "hooks": { "afterComplete": ["workspace/scripts/hooks/post-fetch.cjs"], "skipGlobal": ["beforeRun"] } }Hook script contract
Files changed
src/config/types.cron.ts— New types:CronHookEntry,CronLifecycleHookPoint,CronHooksConfig,CronJobHooksConfig; addedhooks?toCronConfigsrc/cron/types.ts— Addedhooks?: CronJobHooksConfigtoCronJobsrc/cron/hooks.ts— Hook runner module (~140 lines):loadHookEntries(),runCronHooks()src/cron/hooks.test.ts— 11 unit tests covering loading, merging, priority, filtering, abort, error isolationsrc/cron/service/timer.ts— Integrated hook calls intorunDueJob()at all 4 lifecycle pointsTest plan
pnpm buildpassespnpm checkpasses (lint + format)pnpm test src/cron/hooks.test.ts— 11/11 tests passopenclaw.json, trigger a cron job, verify hook fires