Skip to content

cron: add lifecycle hooks for job execution#45239

Closed
Arry8 wants to merge 3 commits intoopenclaw:mainfrom
Arry8:feature/cron-lifecycle-hooks
Closed

cron: add lifecycle hooks for job execution#45239
Arry8 wants to merge 3 commits intoopenclaw:mainfrom
Arry8:feature/cron-lifecycle-hooks

Conversation

@Arry8
Copy link
Copy Markdown

@Arry8 Arry8 commented Mar 13, 2026

Summary

  • Adds a hook system that runs user-defined scripts at cron job lifecycle points (beforeRun, afterComplete, onFailure, afterRun)
  • Hooks are configured globally via openclaw.json cron.hooks section, or per-job via hooks field in the job store
  • Supports priority ordering, job/agent ID filtering, skipGlobal overrides, and abort capability on beforeRun hooks
  • Hook failures are isolated — they log warnings but never crash the job

Design

Celery-inspired named lifecycle hooks with WordPress-style config-driven registration.

Hook Point When Can Abort?
beforeRun Before anything starts Yes
afterComplete After successful execution No
onFailure After failed execution No
afterRun Always runs (success or failure) No

Global 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

module.exports = async function(ctx) {
  // ctx.hookPoint, ctx.workflow, ctx.job, ctx.status,
  // ctx.error, ctx.durationMs, ctx.meta, ctx.log
  // beforeRun only: return { abort: true, reason: '...' } to skip the job
};

Files changed

  • src/config/types.cron.ts — New types: CronHookEntry, CronLifecycleHookPoint, CronHooksConfig, CronJobHooksConfig; added hooks? to CronConfig
  • src/cron/types.ts — Added hooks?: CronJobHooksConfig to CronJob
  • src/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 isolation
  • src/cron/service/timer.ts — Integrated hook calls into runDueJob() at all 4 lifecycle points

Test plan

  • pnpm build passes
  • pnpm check passes (lint + format)
  • pnpm test src/cron/hooks.test.ts — 11/11 tests pass
  • Manual: configure a hook script in openclaw.json, trigger a cron job, verify hook fires

Arry8 and others added 2 commits March 13, 2026 10:55
* 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 13, 2026

Greptile Summary

This PR adds a lifecycle hook system for cron job execution, supporting four hook points (beforeRun, afterComplete, onFailure, afterRun) with global config and per-job overrides, priority ordering, filtering, and abort capability. The overall architecture is clean and well-tested, but there are two logic bugs in the filter implementation and a minor timer-handle leak.

  • filter.workflow is never evaluatedmatchesFilter() leaves a comment deferring to the caller, but no caller ever checks entry.filter.workflow. Any hook with a workflow filter silently fires for every job, making the field a no-op that will mislead users.
  • agentId filter bypassed for agent-less jobs — the condition && job.agentId && in matchesFilter causes the filter to be skipped entirely when job.agentId is undefined, so a hook intended only for a specific agent will also fire for jobs with no agent.
  • rejectAfterTimeout leaks setTimeout handles — unlike executeJobCoreWithTimeout which clears its timeout in a finally block, each successful hook invocation leaves a 10-second timer handle alive. This can affect test hygiene and clean process shutdown over time.

Confidence Score: 3/5

  • Safe to merge with fixes — the bugs are in the filter logic and will silently produce incorrect hook dispatch, not crashes.
  • Two concrete logic bugs (workflow filter ignored, agentId filter bypassed for agent-less jobs) mean hooks can fire in more cases than configured. Neither causes a crash or data loss, but they undermine the correctness of the filtering feature before it's even shipped.
  • src/cron/hooks.ts — both filter bugs are in matchesFilter() and the timeout leak is in rejectAfterTimeout().
Prompt To Fix All 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.

---

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

Comment thread src/cron/hooks.ts
Comment on lines +125 to +138
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cron/hooks.ts
Comment on lines +133 to +135
if (f.agentId && f.agentId.length > 0 && job.agentId && !f.agentId.includes(job.agentId)) {
return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment thread src/cron/hooks.ts
Comment on lines +146 to +150
function rejectAfterTimeout(ms: number): Promise<never> {
return new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`cron hook timed out after ${ms}ms`)), ms);
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation scripts Repository scripts size: XL and removed size: M labels Mar 13, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cron/hooks.ts
// Global entries from openclaw.json cron.hooks section.
const globalEntries: ResolvedEntry[] = [];
if (!skipGlobal) {
const raw = cronConfig?.hooks?.[hookPoint];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/cron/hooks.ts

async function loadHookModule(scriptPath: string): Promise<unknown> {
// Dynamic import works for .cjs (via jiti/bun) and .ts files.
const mod = await import(scriptPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@Arry8 Arry8 closed this Mar 13, 2026
@Arry8
Copy link
Copy Markdown
Author

Arry8 commented Mar 13, 2026

will incorporate suggestions and create new pr

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation scripts Repository scripts size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant