Skip to content

18 · Background Tasks: Who Closes the Loop?

Model background work as scheduling, isolation, delivery, and failure state so restarts and missed notifications are diagnosable.

Chapter brief

Question to answer

When a scheduled agent misses, duplicates, or fails to deliver, who detects it, retries it, and closes delivery?

By the end, you can

  • Separate scheduling, execution, state, result delivery, and notification
  • Define missed, running, succeeded, failed, and delivered states
  • Handle restart catch-up, duplicates, lease loss, and dead letters
Read this now if
Engineers building cron, async workers, long jobs, or proactive agents
Prerequisites
Understand sessions, idempotency, and message delivery
Deliverable
A background-task state machine, idempotency scheme, and delivery SLA
Evidence boundary
Successful execution does not prove delivery; scheduler, worker, and channel can fail independently

After a background task fails, who closes the loop?

Section titled “After a background task fails, who closes the loop?”

Scenario: a weekly report is generated and persisted, but Slack delivery fails. After restart, the scheduler sees “not delivered,” reruns the whole research job, and eventually sends two different reports. Execution success, durable result, and delivery success collapsed into one state.

Passing conditions: scheduled, claimed, running, succeeded, delivered, and failed are separate; execution and delivery have distinct idempotency keys; expired leases permit takeover without double-running; failures enter reasoned retry or dead letter; restart catch-up rules are auditable.

A background task is more than a cron expression in a loop. It needs a lifecycle that survives restart: when to fire, where to run, whether the result arrived, and what the next retry means.

ConstraintState that must be recordedFailure action
A restart must not lose a reminderScheduled time, last fire, grace windowFire inside the window; mark it missed after expiry
A task must not pollute the user sessionSession target and skills snapshotUse an isolated session with an explicit close path
Execution success is not delivery successRun status and delivery statusAlert separately; do not repeat an already successful side effect
Bad configuration must not create a retry stormSchedule errors separate from execution errorsDisable configuration faults; back off transient failures

This is a state contract, not a product scorecard.

When a task needs an online runtime: Codex

Section titled “When a task needs an online runtime: Codex”

Codex moves long-running work to cloud tasks. The local client shows summaries and lets the user choose an apply. The trade is explicit: code runs in a managed environment and depends on network access.

Claude Code exposes a small task shape: expression, prompt, recurrence, and durability. A scheduler lock prevents duplicate fires across IDE windows, while default expiry keeps short-lived reminders from running forever.

When execution and delivery differ: OpenClaw

Section titled “When execution and delivery differ: OpenClaw”

OpenClaw records lastRunStatus separately from lastDeliveryStatus, and counts schedule errors separately from execution errors. Isolated sessions, deterministic staggering, and a failure destination all support that split.

When a persisted prompt needs a gate: Hermes

Section titled “When a persisted prompt needs a gate: Hermes”

Hermes uses croniter and a JSON store. ONESHOT_GRACE_SECONDS=120 gives a one-shot task a short restart window, and cron prompts pass threat-pattern and invisible-Unicode checks. The source does not prove an interception rate.

Persist state before an irreversible action:

created -> due -> running -> executed -> delivered
\-> failed -> retrying | disabled

Write task id, attempt, timestamp, and error class on each transition. If the process exits after a tool side effect but before its log, recovery must inspect the side effect instead of blindly replaying it.

Before shipping, ask:

  • Do at, every, and cron have separate time semantics?
  • Are permissions, logs, and close paths separate for main and isolated sessions?
  • What does the user see when a run succeeds but a webhook fails?
  • Are cron test vectors clearly separated from production incidents?
Source notebook: implementation details
Four background-task models: codex cloud-tasks vs claude code CronCreateTool + scheduler vs openclaw service + isolated-agent + delivery vs hermes croniter + jobs.json
Same 'let the agent run in the background', from a remote task table to a full cron subsystem.

The four systems on scheduling, isolation, delivery, and failure handling:

How four systems schedule cron and background work

Section titled “How four systems schedule cron and background work”

Codex · move the whole “runs in the background” problem to the cloud

Section titled “Codex · move the whole “runs in the background” problem to the cloud”

Codex’s source places long-running work in cloud tasks; the local client reads summaries and lets the user choose which result to apply. That moves persistence and execution into a managed environment, along with network, credential, quota, and data-boundary decisions. Whether it fits depends on whether the task may run remotely.

A managed runtime can provide persistent processes and scheduler infrastructure, but availability still depends on deployment, service dependencies, and quotas. The source structure is not an SLA and does not establish that the service is always online.

The source also shows an environment binding and a multi-branch apply model. How many branches run, and what they cost, depends on runner capacity, model choice, task duration, and quota. The model does not prove that cloud execution is always cheaper or that local parallelism is impossible.

The client supports it with a matching experience: an “apply” is not a binary success/failure but a three-way distinction (fully applied, partially applied (some paths conflicted), or completely rejected), and a partial apply surfaces the skipped and conflicting paths so the user can decide what to do next.

The cost of this design is obvious. If the network drops or the cloud service goes down, the entire background-task capability disappears.

The trade is concrete: a network or service failure can interrupt the task, and sensitive code may not be allowed to leave the machine. Those constraints can favor a local scheduler instead.

Claude Code · make cron a first-class tool inside the IDE

Section titled “Claude Code · make cron a first-class tool inside the IDE”

Claude Code keeps cron in the local IDE process, so it does not require a remote task service while the scheduler process and machine stay available. Sleep, an exited IDE, and local permissions can still prevent a fire; the source does not provide a performance comparison with a cloud scheduler.

claude-code/src/utils/cronTasks.ts:30-70 A cron task is described by just a handful of essential fields: identity, expression, the prompt to fire, timestamps, whether it is recurring, whether it persists to disk, and whether it is permanent.
export type CronTask = {
id: string
/** 5-field cron string (local time), validated on write, re-validated on read. */
cron: string
/** Prompt to enqueue when the task fires. */
prompt: string
/** Epoch ms when the task was created. Anchor for missed-task detection. */
createdAt: number
/**
* Epoch ms of the most recent fire. Written back by the scheduler after
* each recurring fire so next-fire computation survives process restarts.
* Never set for one-shots (they're deleted on fire).
*/
lastFiredAt?: number
/** When true, the task reschedules after firing instead of being deleted. */
recurring?: boolean
/**
* When true, the task is exempt from recurringMaxAgeMs auto-expiry.
* System escape hatch for assistant mode's built-in tasks
* (catch-up / morning-checkin / dream).
*/
permanent?: boolean
/**
* Runtime-only flag. false means session-scoped (never written to disk).
*/
durable?: boolean
/**
* Runtime-only. When set, the task was created by an in-process teammate.
*/
agentId?: string
}

Around that data structure the system carves out a very clear picture of what a cron task actually is.

Each task carries an identifier, the 5-field cron expression that controls timing, the prompt that should fire at each match, the creation timestamp plus the most-recent-fire timestamp (so that “what is the next fire time” can be computed correctly even after a process restart), whether the task is recurring or fires only once, whether it should be persisted to disk (the default is “live only in this session, vanish when the session ends”), and a special “permanent” marker that only the IDE’s own built-in tasks (the morning briefing, the nightly tidy-up) are allowed to wear, so that those tasks can opt out of the normal “expire after 30 days of inactivity” rule.

The user/agent facing tool that creates a task exposes a deliberately minimal surface: give it a cron expression, give it a prompt, and optionally specify whether it is recurring and whether it should survive across sessions.

claude-code/src/tools/ScheduleCronTool/CronCreateTool.ts:27-55 A small input surface: expression, prompt, recurring flag, durability flag, plus a hard cap of 50 tasks per workspace.
const MAX_JOBS = 50
const inputSchema = lazySchema(() =>
z.strictObject({
cron: z
.string()
.describe(
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" ' +
'(e.g. "*/5 * * * *" = every 5 minutes, ' +
'"30 14 28 2 *" = Feb 28 at 2:30pm local once).',
),
prompt: z.string().describe('The prompt to enqueue at each fire time.'),
recurring: semanticBoolean(z.boolean().optional()).describe(
`true (default) = fire on every cron match until deleted or auto-expired after ${DEFAULT_MAX_AGE_DAYS} days. ` +
`false = fire once at the next match, then auto-delete. ` +
`Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.`,
),
durable: semanticBoolean(z.boolean().optional()).describe(
'true = persist to .claude/scheduled_tasks.json and survive restarts. ' +
'false (default) = in-memory only, dies when this Claude session ends. ' +
'Use true only when the user asks the task to survive across sessions.',
),
}),
)

What makes this design stable in real IDE use is the scheduler behind it, and the scheduler does several surprisingly thoughtful things to handle real-world edge cases.

The first is wait for the file to settle before reading it: when a user’s config file has just been rewritten by another process, a plain read can capture a half-written state, so the scheduler requires the file to have been untouched for 300 milliseconds before it counts as a stable read.

The second is mutual exclusion across IDE windows: users routinely have several windows open at once, each with its own scheduler process, and without coordination the same task would fire once per window. The fix is a local file lock.

Only the window holding the lock actually fires tasks; the others become observers. The current source uses a one-second scheduler tick and a 300-millisecond quiet-write window. Those are implementation constants, not site-measured optima; they balance trigger delay against partially written configuration reads.

The third is default expiry after a month for recurring tasks, to prevent the failure mode of a */5 * * * * task quietly burning fire costs for six months because nobody remembers it; if some built-in feature really must “always run” (the daily IDE briefing), it carries an explicit “permanent” marker and skips expiry.

The fourth is default no-persist: most cron tasks users type are actually “remind me later today”-style ephemeral wishes that have no business polluting persistent storage; to make a task survive across sessions a user has to explicitly say so.

OpenClaw · the textbook implementation of a local cron subsystem

Section titled “OpenClaw · the textbook implementation of a local cron subsystem”

If the previous two systems are answering “local or cloud”, OpenClaw is answering the next question down: what state and operations does a local cron subsystem need?

Its cron submodule is large and exposes separate scheduling, isolation, delivery, and failure paths. File count and listed modules are source evidence, not proof that every production edge is covered.

The source represents three explicit scheduling shapes. A cron-expression-style recurring task is one shape; the other two are “fire once at a specific moment” (run something at 9am next Wednesday and never again) and “fire every fixed number of milliseconds” (check this status every 30 seconds).

All three shapes live inside a single tagged union, each with its own dedicated next-fire computation logic, so you do not need to torture every kind of schedule into a single cron expression that loses the original intent.

OpenClaw openclaw/src/cron/types.ts:4-67 A cron task explicitly distinguishes three scheduling shapes: a fixed moment, a fixed interval, a cron expression, each carrying its own shape-specific fields.
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| {
kind: "cron";
expr: string;
tz?: string;
/** Optional deterministic stagger window in milliseconds (0 keeps exact schedule). */
staggerMs?: number;
};
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageChannel = ChannelId | "last";
export type CronDeliveryMode = "none" | "announce" | "webhook";
export type CronDelivery = {
mode: CronDeliveryMode;
channel?: CronMessageChannel;
to?: string;
accountId?: string;
bestEffort?: boolean;
/** Separate destination for failure notifications. */
failureDestination?: CronFailureDestination;
};
export type CronFailureAlert = {
after?: number;
channel?: CronMessageChannel;
to?: string;
cooldownMs?: number;
mode?: "announce" | "webhook";
accountId?: string;
};

Around the scheduling shapes there are several modifier fields: timezone (especially important for cross-region teams where “every day at 9am” means different moments in different places); a stagger window (a deterministic offset discussed below); execution context (choose between running inside the user’s main session or spinning up a separate isolated session); wake mode (wait until the next heartbeat to handle this, or fire the moment the time matches).

Next is the persistent state attached to each task. OpenClaw models this state quite precisely: the next time the task should run, the last time it ran, whether the last run succeeded or failed, how many consecutive failures have accumulated, whether the last result was delivered, and what the delivery’s final status was.

OpenClaw openclaw/src/cron/types.ts:109-147 A task's runtime state is captured in fine detail: execution outcomes and delivery outcomes are tracked separately, and consecutive execution errors are counted independently from schedule-configuration errors.
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastRunStatus?: CronRunStatus;
lastStatus?: "ok" | "error" | "skipped"; // back-compat
lastError?: string;
lastDurationMs?: number;
/** Consecutive execution errors (reset on success). Used for backoff. */
consecutiveErrors?: number;
lastFailureAlertAtMs?: number;
/** Auto-disables job after threshold. */
scheduleErrorCount?: number;
/** Explicit delivery outcome, separate from execution outcome. */
lastDeliveryStatus?: CronDeliveryStatus;
lastDeliveryError?: string;
lastDelivered?: boolean;
};
export type CronJob = CronJobBase<
CronSchedule,
CronSessionTarget,
CronWakeMode,
CronPayload,
CronDelivery,
CronFailureAlert | false
> & { state: CronJobState };

The reason the state is split so finely is that it needs to answer several different questions. “Did the task execute successfully?” and “Did the result actually reach the user?” are two genuinely different things in production.

A task may run perfectly and produce the right result, but the webhook configured on top of it happens to be down at that moment and the result never lands; conversely, a task that crashed cannot even be considered for delivery.

If you collapse these two into one success/failure flag, you end up in nightmare scenarios like “the task crashed but the user got no alert” or “the task is healthy yet the user gets repeated failure pings”.

OpenClaw tracks them on two independent state lines and lets their alert channels be independent too.

Similarly, consecutive execution failures and schedule-configuration errors are kept as separate counters.

If a task’s cron expression itself is malformed, treat it as a configuration error and stop the useless triggers; for transient runtime errors (flaky network, an external API occasionally returning 500), one possible policy is exponential backoff plus retries, with an alert threshold chosen for the workload.

The tolerance levels of these two are naturally different, and mixing them lets “configuration is broken, stop now” interfere with “runtime is flaky, keep trying patiently”.

The stagger window mentioned above is another one of those “looks small but actually saves you” engineering details. Imagine a thousand users have each configured a task for “9am every weekday”.

If the scheduler fires at exactly 09:00:00 for everyone, that single moment will send a thousand simultaneous requests to the model API, which will either blow past API quotas or get throttled into mass failures.

The stagger window instead adds a deterministic offset of at most a few dozen seconds to each task’s nominal fire time, so the thousand tasks naturally spread across 09:00-09:05 and the external API sees a much smoother load profile.

The offset is deterministic rather than random precisely because it needs to stay stable across process restarts; a random offset that resets on every restart would break predictability.

Next, why it is a bad idea to run cron tasks directly inside the user’s main session.

If a cron firing simply enqueues its prompt into the main session’s message queue, the user comes back and finds a long string of mystery messages in their conversation history, the prompt prefix cache gets shredded by the injected content, and the task’s own tool calls can race against whatever the user is doing right now.

OpenClaw’s answer is to spin up a separate session for every cron task: no inherited conversation history, its own working directory, and an explicit close path. Whether the boundary is fully independent depends on the runner configuration.

Paired with the isolated session is another very important design: freezing the skills snapshot at task creation.

The skills that a task depends on (its tool surface) are whatever they happen to be the moment the task is created; later, the user may modify those skills (remove one, add one, tweak the behavior of another), but every time the cron task fires it still uses the frozen snapshot captured at creation.

This freezes the captured skills configuration for that task; it reduces one source of behavior drift, but external state and runner changes still need their own controls.

Finally, OpenClaw’s testing posture is distinctive. There are dozens of test files for this cron subsystem, and a significant fraction of them are named directly after past production bug numbers (“issue-22895-how-soon-is-the-next-fire” and so on).

This treats tests as living artefacts of production pain: every time a real edge case is found in the wild, an issue-numbered regression test is left behind to keep that case from regressing in the future.

Hermes · do the whole job with a small toolkit

Section titled “Hermes · do the whole job with a small toolkit”

Hermes uses a cron-expression library to parse schedules, stores all tasks in a single json file, and writes each run’s result to a small per-task directory on disk. The library choice is an implementation detail, not a claim about a universal standard.

The overall structure is compact, but every choice has a clear reason behind it.

The on-disk paths and permissions are explicitly tightened: the directory holding configs has mode 700 (only the owner can enter) and the task file has mode 600 (only the owner can read or write).

This Unix-style hygiene is itself a defense: it prevents other users on the same machine from reading cron configs and, more importantly, from injecting new cron tasks into your account.

One design detail to remember is the two-minute grace window for one-shot tasks.

Imagine a user says “remind me at 10am today” but the agent process happens to restart at 09:59:55 and takes five seconds to come up; a strict “the moment has passed, do nothing” rule would mean the reminder is lost forever.

Hermes’s solution is that when the agent comes back up it checks “am I still within two minutes of the originally scheduled time?”, and if so it fires once immediately.

The two-minute value is a deliberate trade-off: too short and you cannot tolerate a normal restart, too long and you violate the user’s intent (firing an “open meeting at 10am” reminder at 11am is meaningless).

Recurring tasks do not need this kind of grace because there will simply be a next fire.

But the heaviest thing Hermes does for cron is not in scheduling; it is in security scanning.

It is very clear about one thing: a cron prompt may execute while the user is away and inherits whatever tools and permissions the configured runner grants.

Treat that actual authority boundary as a high-risk input surface and vet it with the same rigour as system-level input.

Hermes hermes-agent/tools/cronjob_tools.py:41-68 Any prompt about to be written into a cron config is first run through a threat-pattern library specifically tuned for the cron scenario; any invisible Unicode character is blocked outright.
_CRON_THREAT_PATTERNS = [
(r'ignore\s+(?:\w+\s+)*(?:previous|all|above|prior)\s+(?:\w+\s+)*instructions',
"prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'system\s+prompt\s+override', "sys_prompt_override"),
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
(r'wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_wget"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
(r'authorized_keys', "ssh_backdoor"),
(r'/etc/sudoers|visudo', "sudoers_mod"),
(r'rm\s+-rf\s+/', "destructive_root_rm"),
]
_CRON_INVISIBLE_CHARS = {
'\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
}
def _scan_cron_prompt(prompt: str) -> str:
for char in _CRON_INVISIBLE_CHARS:
if char in prompt:
return f"Blocked: prompt contains invisible unicode U+{ord(char):04X}."
for pattern, pid in _CRON_THREAT_PATTERNS:
if re.search(pattern, prompt, re.IGNORECASE):
return f"Blocked: prompt matches threat pattern '{pid}'."
return ""

The reasoning behind this scan is the same as the memory scan from Chapter 19: any input that is about to be executed at high privilege must be vetted at high-privilege standards once.

The attack surface this scan covers includes classic prompt-injection templates (“ignore previous instructions”, “now your system prompt becomes…”), shell snippets that exfiltrate secrets from environment variables, commands that read well-known credential files, keywords that plant SSH backdoors or edit sudoers, and the most destructive of all, rm -rf / style commands.

Alongside the regex patterns, it also enumerates a list of invisible Unicode characters (zero-width spaces, zero-width joiners, bidirectional overrides).

These characters are invisible to the eye but participate in the model’s input tokens: attackers use them to slip past keyword-based scanners or to flip the visual order of characters away from the byte order.

Any match aborts the write outright.

Beyond prompt scanning, Hermes also takes care of several small things that make cron tolerable to operate over the long term.

It records the trigger origin of each task on the task itself (which chat platform it came from, which channel, which conversation thread), so that when that task fires in the future the system knows where to send the result back to (rather than silently writing to disk where nobody will see it).

And it maintains backward compatibility for the on-disk task shape. Early versions stored “the skill in use” as a single field while newer versions made it an array, and on read both shapes are normalized into one canonical form so that a format evolution does not orphan every task ever created.

These fields target migration and delivery-maintenance problems. Their value still needs runtime evidence; the source alone does not establish an operating history or outcome.

Four cron systems plotted on local capability and cloud capability axes
The implementations emphasize local scheduling, IDE lifecycle, bounded file storage, or managed tasks. The axes show emphasis, not completeness.

How the four split:

  • OpenClaw top-left: local scheduling + delivery + failure-alert. Scheduling can stay local; individual jobs may still require a network.
  • Claude Code top-middle: the source’s 1s tick + chokidar + cross-process scheduler lock, scoped to an IDE lifecycle.
  • Hermes middle: croniter + jobs.json + the current ten-pattern scan. Coverage stops at the rule set.
  • Codex bottom-right: the reviewed client routes this work through cloud-tasks and renders bestOf apply results.

Side by side, the four cron subsystems:

Four cron subsystems lined up side by side
cloud-tasks (Codex) · cron tool + scheduler (Claude Code) · cron service + isolated-agent (OpenClaw) · croniter + jobs.json (Hermes).

Mistake 1: letting recurring tasks default to running forever

Section titled “Mistake 1: letting recurring tasks default to running forever”

Treating “recurring” as “once created, runs forever” is an extremely common piece of lazy design. A */5 * * * * schedule looks harmless, but six months later nobody remembers it exists and each fire keeps quietly costing money.

Claude Code’s current source uses a 30-day default expiry. That is an implementation setting, not a universal lifetime. Choose a TTL from trigger cost, task risk, and the renewal workflow; require explicit renewal or a permanent marker only when the task must outlive it.

Explicit renewal makes ownership visible. Separately, repeated failures can trigger backoff or disablement when the error policy and recovery path are defined.

With these two policies together, the cron subsystem stops being a write-once, garbage-only-grows accumulation pit.

Mistake 2: treating “the task ran successfully” as “the user received the result”

Section titled “Mistake 2: treating “the task ran successfully” as “the user received the result””

A successful task execution is not the same as a successful delivery; these two events come apart all the time in production.

The task may have run perfectly but the webhook configured for it happens to be down at that moment, or the task crashed and there is nothing to deliver in the first place.

If your data model exposes only a single success/failure bit, you will hit user-experience disasters like “the task crashed but the user got nothing” or “the task is perfectly fine but the user is being woken up by alerts”.

When delivery can fail independently, track execution and delivery outcomes separately and give each an explicit alert path.

This both pinpoints whether the problem is in execution or delivery and gives the user a meaningful diagnostic.

Mistake 3: putting cron firings straight into the user’s active session

Section titled “Mistake 3: putting cron firings straight into the user’s active session”

If a cron fire simply enqueues its prompt into the user’s main session, you immediately create a cascade of headaches: the user comes back to find a pile of mystery messages in their conversation history, the prompt prefix cache is invalidated by those injections, and the task’s tool calls can race against whatever the user is doing in real time.

The healthier approach is to give each cron fire a separate session with explicit close, log, and notification paths, then surface “the task ran, here is the result” back into the main conversation. Whether it is fully isolated depends on the runner configuration.

OpenClaw goes one step further and freezes the task’s skill configuration at creation time. That reduces drift from later skill edits; external state, runner changes, and permissions still need separate controls.

Mistake 4: trusting user-provided cron prompts as if they were ordinary text

Section titled “Mistake 4: trusting user-provided cron prompts as if they were ordinary text”

A cron task may run while the user is absent and inherits whatever tools and permissions its configuration grants. Treat its prompt according to that actual authority, not as ordinary chat text.

Any prompt being written into a cron config should be scanned for classic injection templates, commands that read known credential files, shell snippets that exfiltrate keys from environment variables, keywords that plant SSH backdoors or edit sudoers, and destructive root commands like rm -rf /.

Beyond regex matches, the scan also needs to enumerate invisible Unicode characters explicitly: these are not visible on screen but do reach the model’s tokens, and they are one of the most common ways to bypass keyword-based scanners.

For prompts that can invoke privileged tools, invisible-character checks cover one class of keyword bypass. They still need runtime policy, approval, and audit; a rule-set match rate is not a security coverage rate.

Closing the loop requires separate run and delivery state

Section titled “Closing the loop requires separate run and delivery state”

复刻方案

  1. 1. Pick a scheduling model
    Cron expressions only: use croniter or cron-parser. Need at / every / cron together: borrow OpenClaw's CronSchedule discriminated union.
  2. 2. Add recurring vs one-shot
    One-shot fires then deletes (Claude Code's fire-then-delete). Recurring computes next-fire and re-schedules. Hermes currently configures a 120-second grace window; tune yours against startup delay and late-fire cost.
  3. 3. Add a durable option
    Default to session-only, not persisted. Explicit durable=true writes to .claude/scheduled_tasks.json or ~/.hermes/cron/jobs.json. Avoids polluting long-term storage with ephemeral reminders.
  4. 4. Add a scheduler lock
    When multiple processes can own one cron file, add exclusion or a single-owner mechanism. Claude Code uses a scheduler lock; a single-process deployment may not need the same primitive.
  5. 5. Add isolated-agent option
    When a job can contend with user tools, permissions, or context, consider OpenClaw isolated-agent + skills-snapshot. A lightweight reminder may stay in the main session.
  6. 6. Separate execution vs delivery
    OpenClaw's lastRunStatus + lastDeliveryStatus. A failed webhook must not mask a failed job; a failed job must not pretend delivery succeeded.
  7. 7. Add failure backoff + alert
    consecutiveErrors for exponential backoff. scheduleErrorCount auto-disables broken jobs. failureAlert with its own cooldownMs prevents alert storms.
  8. 8. Add prompt scanning
    Cron prompts may invoke privileged tools while the user is away. Hermes's current ten _CRON_THREAT_PATTERNS are a starting set of known patterns, not complete coverage.
  9. 9. Add staggerMs
    All `0 9 * * *` tasks triggering simultaneously will hammer the model API. OpenClaw's deterministic stagger spreads each job across 0..staggerMs.

Do you need cron? Answer these 7 questions:

  1. Should it run while users are offline? Yes: need local cron or cloud. No: user-driven re-run is enough.
  2. Multiple sessions open at once? Yes: need a scheduler lock. No: single owner simplifies a lot.
  3. Persist across sessions? Yes: write to .claude/scheduled_tasks.json or ~/.hermes/cron/jobs.json. No: session-scoped only.
  4. Who reads cron output? Yourself: write to file and surface at next session. Multiple people: webhook or announce to a channel.
  5. Failure handling? Auto-retry: backoff. Alert: failureAlert + cooldownMs. Neither: simple log.
  6. Isolation requirements? High: isolated-agent + skills-snapshot. Low: run in main session.
  7. Is the prompt source trusted? From user or model: force scan. From system config: trust.

Do not choose a product by counting yes answers. Offline execution, multi-process ownership, cross-session persistence, isolation, and independent delivery each introduce different state. Implement only the required branches, then test restart, duplicate fire, and delivery failure.

What to carry forward and the next experiment

Section titled “What to carry forward and the next experiment”

Background work closes two loops: did the work execute, and did the result arrive? Schedule, lease, idempotency, state truth, and delivery channel must stay separate or restart and retry create duplicate side effects.

Next experiment: inject missed trigger, duplicate trigger, worker crash before and after commit, delivery failure, clock jump, and lease expiry. Pass when each logical job executes once or deduplicates safely, delivers once, routes failures to retry or dead letter, and reports recovery latency and duplicate rate.

Open ten review questions
Q1 · Concept: How do cron job, background task, and long-running task differ?

Three related but distinct concepts:

Cron job: a task triggered by schedule. */5 * * * * fires every 5 min. Schedule is first-class. Background task: an async fire-and-forget task, doesn’t block the main flow. May or may not have a schedule. Long-running task: single execution takes a long time (> minutes). May run foreground or background.

Overlap and distinction:

  • A cron job describes a schedule; its runner may execute in the foreground or background, and the executor determines whether it blocks
  • Background tasks aren’t necessarily cron (can be user-triggered then suspended)
  • Long-running tasks aren’t necessarily background (they can be long foreground tasks the user waits on)

Examples:

  • */5 * * * * check_pr_status: cron + background + short
  • bg: run_test_suite(): background + long + non-cron
  • wait: generate_video(): long foreground + non-cron + non-background

Why distinguish?

Different properties need different infrastructure:

  • Cron: scheduler + persistent schedule + timezone
  • Background: queue + worker + isolation
  • Long-running: timeout + heartbeat + mid-cancel

Follow-up: “Does an agent system need all three?” Depends:

  • User-driven REPL only: none needed
  • Background cron for PR watching: need cron + background
  • Long tasks (codebase review): need long-running + optionally background

Follow-up: “How does OpenClaw distinguish?” OpenClaw’s CronSchedule handles cron; subagent-followup handles background + long-running. Claude Code’s cron tool is mainly cron; inline / fork handles background/long.

Source: openclaw/src/scheduling/cron-service.ts + claude-code/src/tools/CronTool.ts.

Q2 · Concept: How to do cross-process scheduler lock? Why is it necessary?

Problem: User opens 3 Claude Code windows (same cwd), each window has its cronScheduler. If all trigger the same cron job, it runs 3 times.

Solution - scheduler lock:

async function acquireSchedulerLock(cwd: string): Promise<LockHandle | null> {
const lockPath = path.join(cwd, '.claude', 'scheduler.lock');
try {
const fd = await fs.open(lockPath, 'wx'); // exclusive create
await fd.write(JSON.stringify({ pid: process.pid, ts: Date.now() }));
return { fd, path: lockPath };
} catch (err) {
if (err.code === 'EEXIST') {
const content = await fs.readFile(lockPath, 'utf-8');
const { pid, ts } = JSON.parse(content);
if (Date.now() - ts > 30000) {
await fs.unlink(lockPath);
return acquireSchedulerLock(cwd); // retry
}
return null;
}
throw err;
}
}

Key points:

  1. Atomic file creation: O_CREAT | O_EXCL (Node’s ‘wx’ flag) ensures only one wins when two processes race
  2. Write pid + ts: other processes see who holds and when
  3. Expiration: holder crashes don’t release lock, need timeout takeover
  4. Heartbeat update: holder updates ts periodically (every 10s) to avoid takeover

Why not OS file lock (fcntl)?

  • Cross-platform issues (msvcrt and fcntl APIs differ)
  • fcntl locks unreliable on NFS / network filesystems
  • File existence + ts check is simpler

Why not SQLite?

  • Pulls in SQLite dependency
  • Doesn’t match existing architecture
  • File-level lock is enough

Claude Code’s actual implementation:

.claude/scheduler.lock + chokidar watch + atomic write + ts check. On multi-window startup, the first to acquire the lock schedules; others become followers (still can read state, but don’t fire).

Follow-up: “What if lock owner hangs?” ts heartbeat checks liveness; timeout auto-yield. Other followers detect stale lock and take over.

Follow-up: “Multi-host cron lock?” File lock doesn’t span hosts. Use Redis SETNX or etcd. OpenClaw single-host cron doesn’t need this.

Source: claude-code/src/utils/schedulerLock.ts.

Q3 · Architecture: Why does Codex push all cron to cloud-tasks instead of doing local?

Codex design philosophy: “Local is the dev tool; long-running tasks are cloud-shaped.”

Reasoning:

  1. Local resources are unreliable: laptops shut down / disconnect / sleep; cron is unstable in this environment
  2. Long tasks need compute: large codebase review / batch refactor consume memory + CPU; local lags
  3. Cloud already has scheduling infra: K8s CronJob / AWS EventBridge etc., don’t reinvent
  4. Multi-person collaboration: cloud-run tasks are team-visible; local is personal scope

cloud-tasks model:

pub struct TaskSummary {
pub id: TaskId,
pub status: TaskStatus, // Queued / Running / Done / Failed
pub created_at: DateTime,
pub environment: EnvironmentRow, // pinned env
}

Client is a thin TUI; main logic in the cloud. codex-cloud-tasks is an independent binary from codex.

bestOf multi-branch:

BestOfModalState represents N branches (different prompts / models) and lets the UI choose an apply result. Actual parallel execution depends on runner capacity and quota; the same pattern can run locally when the machine can support it.

Trade-offs vs Claude Code / OpenClaw:

  • Claude Code: local scheduling depends on an IDE or scheduler process staying available
  • OpenClaw: a self-hosted deployment can keep work local or connect another runtime
  • Codex: cloud tasks own the task state and execution environment

Each product’s “typical deployment environment” differs, so cron strategy differs.

Costs:

  • Can’t run cron offline
  • Depends on cloud-tasks backend availability
  • User must accept this cloud service layer

Follow-up: “Codex users without cloud?” Use GitHub Actions / Cron-as-a-Service. Codex doesn’t reinvent.

Follow-up: “What can local cron learn from Codex?” BestOfModalState parallel-branch thinking; local version can use a thread pool.

Source: codex/codex-rs/cloud-tasks/src/app.rs.

Q4 · Concept: Design logic of OpenClaw’s CronDelivery 4 modes?

4 delivery modes:

  • none: don’t deliver. Job completes, logs only, doesn’t disturb the user
  • announce: notify main agent session. At next user message, surface “your cron completed; result: xxx”
  • webhook: HTTP POST to external endpoint for system integration
  • silent (hidden): similar to none but writes to audit log

Why 4 instead of 1?

Different cron jobs serve different purposes:

PurposeModeExample
Data batchnoneDaily export sales report to S3
ReminderannounceDaily 9am “today’s standup agenda”
System integrationwebhookWatch PR status, trigger CI
AuditsilentSecurity scan, result only in audit log

Subtlety of announce mode:

When cron triggers, the user may be away or doing something else. “announce” doesn’t interrupt the current session; it queues the message and surfaces at the next user message.

OpenClaw’s accountId + bestEffort:

  • accountId: in multi-user scenarios, specify which user to notify
  • bestEffort: don’t retry notification failure (the cron task itself succeeded)

Webhook mode engineering points:

interface WebhookDelivery {
url: string;
method: 'POST' | 'PUT';
headers?: Record<string, string>;
retries: number;
timeout_ms: number;
}

Needs retry policy + timeout, otherwise slow webhook endpoints block the scheduler.

Compared to Claude Code’s onFire(prompt):

Claude Code doesn’t split into 4 modes; uses onFire(prompt) injecting into session queue. Simpler but no silent / webhook choice.

Follow-up: “Why not send email / Slack directly?” Those are special cases of webhook. OpenClaw abstracts to webhook + external adapter, which is flexible.

Follow-up: “How does announce avoid being annoying?” Give user a muted-period (no notifications at night) + fold multiple announces into one surface message.

Source: openclaw/src/scheduling/cron-delivery.ts.

Q5 · Concept: Hermes’s ONESHOT_GRACE_SECONDS=120: what is it? Why 120s?

ONESHOT_GRACE is the “tolerance time” for one-shot cron.

Problem: User enters cron at 10:00 today, but agent restarts at 9:59:55, missing the 10:00 trigger. What to do?

Two strategies:

Strict: missed = lost. 10:00 not triggered, never triggers. Grace: after restart, check “is now within schedule_time + grace?”, if yes, run immediately to catch up.

Hermes picks grace:

ONESHOT_GRACE_SECONDS = 120
def should_fire_now(job):
if job.kind == 'oneshot':
delta = (now - job.scheduled_at).total_seconds()
if delta >= 0 and delta <= ONESHOT_GRACE_SECONDS:
return True
if delta > ONESHOT_GRACE_SECONDS:
return False # missed, mark failed

Why 120s?

  • Too short (30s): agent restart + load may exceed, frequent oneshot losses
  • Too long (1h): catching up after 1h may not be what the user wants (“remind me 10am to meet” running at 11am isn’t useful)
  • 120s = 2 min: covers agent restart time, not too loose

Engineering implications of the grace window:

Similar “grace period” concepts in many scheduling systems:

  • AWS EventBridge: default 1 min
  • K8s CronJob: startingDeadlineSeconds default unlimited (not recommended)
  • Quartz: misfireThreshold default 60s

120s is empirical, no absolute optimum.

Recurring doesn’t need grace:

*/5 * * * * missing one fire is fine; next 5min triggers. Oneshot has no next.

Follow-up: “Can users configure grace?” Hermes is hardcoded. OpenClaw via staggerMs + skipIfStale is configurable.

Follow-up: “How to record misses?” Audit log writes "missed: scheduled at X, fired_at NULL, reason=stale"; ops can see.

Source: hermes-agent/scheduler.py:ONESHOT_GRACE_SECONDS.

Q6 · Real-world: Roadmap for adding cron to your agent, 0 to 1?

The following eight steps are an implementation decomposition, not a schedule estimate. Effort depends on the existing scheduler, storage, permission system, and test harness.

Step 1 · MVP

import schedule
@cli.command()
def cron_create(expr: str, prompt: str):
schedule.every().day.at(expr).do(lambda: fire_prompt(prompt))
def cron_runner():
while True:
schedule.run_pending()
time.sleep(1)

Borrow python-schedule library to get running first.

Step 2 · Persistence

@dataclass
class CronJob:
id: str
expr: str
prompt: str
created_at: datetime
def save_jobs(jobs: list[CronJob]):
with open('~/.youragent/cron/jobs.json', 'w') as f:
json.dump([asdict(j) for j in jobs], f)

Borrow Hermes jobs.json + ~/.youragent/cron/ layout.

Step 3 · Scheduler + isolation

import croniter
class Scheduler:
def __init__(self):
self.jobs = load_jobs()
async def run(self):
while True:
now = datetime.now()
for job in self.jobs:
next_fire = croniter(job.expr, now).get_next(datetime)
if (next_fire - now).total_seconds() < 1:
await self.fire(job)
await asyncio.sleep(1)

Borrow OpenClaw 1s tick + croniter standard cron syntax.

Step 4 · Failure handling + alerts

async def fire_with_retry(job: CronJob):
for attempt in range(3):
try:
await run_job(job)
job.consecutive_errors = 0
return
except Exception as e:
job.consecutive_errors += 1
if job.consecutive_errors >= 5:
await send_failure_alert(job, e)
job.disabled = True

Borrow OpenClaw consecutiveErrors + failureAlert.

Step 5 · scheduler lock

def acquire_lock(cwd: Path) -> bool:
lock_path = cwd / '.youragent' / 'scheduler.lock'
try:
with open(lock_path, 'x') as f:
f.write(json.dumps({"pid": os.getpid(), "ts": time.time()}))
return True
except FileExistsError:
return is_lock_stale(lock_path)

Borrow Claude Code scheduler lock file mutex + ts heartbeat.

Step 6 · Isolated execution

async def run_job_isolated(job: CronJob):
session = create_session(
session_id=f"cron-{job.id}-{uuid4()}",
skills_snapshot=current_skills(),
parent_session=None,
)
await session.run_prompt(job.prompt)
await session.close()

Borrow OpenClaw isolated-agent + skills-snapshot.

Step 7 · Threat scanning

CRON_THREAT_PATTERNS = [
r'ignore\s+previous\s+instructions',
r'system\s+prompt\s+override',
# ... 10 critical
]
def scan_cron_prompt(prompt: str) -> Optional[str]:
for pattern in CRON_THREAT_PATTERNS:
if re.search(pattern, prompt, re.IGNORECASE):
return f"Blocked: matched {pattern}"
return None

Borrow Hermes _scan_cron_prompt 10 critical + 10 invisible unicode.

Step 8 · Delivery modes

class CronDelivery(Enum):
NONE = 'none'
ANNOUNCE = 'announce'
WEBHOOK = 'webhook'
async def deliver(job: CronJob, result: str):
if job.delivery == CronDelivery.WEBHOOK:
async with httpx.AsyncClient() as client:
await client.post(job.webhook_url, json={"result": result})
elif job.delivery == CronDelivery.ANNOUNCE:
announcement_queue.append((job.id, result))

Borrow OpenClaw 4-mode delivery.

Key decisions:

  1. MVP with python-schedule: not croniter directly; get running fast
  2. Persist jobs.json: simpler than SQLite, enough for most cases
  3. Choose a lock by owner model: shared jobs need exclusion; a single owner does not need the same primitive
  4. Scan according to authority: static rules cover known patterns only; runtime policy still limits tools
  5. Isolation not for MVP: main session first; isolate when grown

Follow-up: “How to test cron?” Use freezegun to freeze time + run a few periods checking correct triggers.

Follow-up: “Cron vs systemd timer choice?” Self-managed cron suits agent-internal tasks (share agent context); systemd timer suits pure system scripts.

Source mosaic: Hermes + OpenClaw + Claude Code combined.

Q7 · Concept: Why does OpenClaw use isolated-agent for cron instead of main session?

isolated-agent = independent session + frozen skills snapshot + independent cwd.

Why not run in main session?

  1. Session state pollution: cron adds messages to main session; user returns to find history cluttered with “random” conversation
  2. Prompt cache invalidation: cron-triggered messages change main session prompt; user’s next message cache misses
  3. Concurrent conflicts: cron runs tool calls while user is using main session; may call same tool concurrently
  4. Error isolation: cron errors (infinite loop / OOM) shouldn’t crash main session

How isolated-agent is implemented:

async function runCronJob(job: CronJob) {
const isolatedSession = await createSession({
cwd: job.cwd,
skillsSnapshot: snapshotSkillsAtJobCreation(job),
parent: null, // don't inherit main session
autoClose: true,
});
try {
await isolatedSession.runPrompt(job.prompt);
} finally {
await isolatedSession.close();
}
}

skills-snapshot freezing:

Freeze skills state at cron creation. Even if user later modifies skills (delete / add / change), cron uses the snapshot version.

Why?

9:00 user creates cron "Every 5min, /run-daily-checks"
10:00 user deletes /run-daily-checks skill
10:05 cron triggers, skill doesn't exist

Without freezing, cron fails or behavior drifts. Freezing = deterministic behavior.

Compared with Claude Code’s onFire:

Claude Code cron injects prompt into current session; main session if running gets the inject. Simple but has all above issues.

Claude Code’s solution:

assistant mode + permanent: true lets cron run in “assistant mode session”, separated from user-visible session. Essentially similar to isolated-agent.

Practical engineering takeaways:

  • Isolate a task when it would occupy the foreground session, needs independent cancellation, or must recover on its own
  • For reproducible results, record or snapshot skill, model, and configuration versions at scheduling time; when the task should follow current configuration, resolve them at execution and record what was used
  • A scheduler that creates isolated sessions needs explicit close and reclamation paths, including abnormal exits
  • When operations or audit matter, make isolated-session logs queryable by task instead of exposing only a final status

Follow-up: “isolated-agent vs subagent differences?” Subagent is sync call (main agent waits result); isolated-agent is async (runs independently, main agent doesn’t wait). subagent-followup is OpenClaw’s mechanism for isolated-agent to notify main agent on completion.

Follow-up: “Cost of isolated-agent?” Each spawn creates a new agent context (system prompt + tool box). Actual token cost depends on context length, model, and tool calls; it is not a fixed percentage of the main session. Spawn lazily when the task needs isolation.

Source: openclaw/src/agents/isolated-agent.ts.

Q8 · Concept: Why is a cron prompt more dangerous than ordinary user input?

A cron prompt enters the system when the user is not there: the biggest security exposure.

Risk points:

  1. No user review: real-time prompts are user-visible; cron runs in background unsupervised
  2. Persistence: cron is one-time config, runs forever. Attack payload stays
  3. Privileged tokens: cron typically configures GH/AWS tokens for the agent; malicious cron grabbing them = total compromise
  4. Trigger frequency: every 5 min + user away = huge attack window
  5. Delivery path: webhook delivery pushes cron output to external, a potential exfiltration channel

Hermes’s 10 critical threats:

_CRON_THREAT_PATTERNS = [
# Prompt injection
(r'ignore\s+previous\s+instructions', 'prompt_injection'),
(r'system\s+prompt\s+override', 'sys_override'),
# Exfiltration via webhook
(r'curl\s+.*KEY|TOKEN|SECRET', 'exfil_secret'),
(r'webhook.*\.attacker\.', 'exfil_webhook'),
# Persistence
(r'authorized_keys', 'ssh_persist'),
(r'crontab\s+-e', 'cron_persist'),
# Lateral movement
(r'ssh\s+root@', 'lateral_ssh'),
# Cloud creds
(r'\.aws/credentials', 'aws_creds'),
# Bypass
(r'\\u200b|\\u200c', 'invisible_unicode'), # plus 10 invisible char scan
(r'base64.*decode', 'obfuscation'),
]

Why scan invisible unicode separately?

U+200B (zero-width space) etc. are invisible to humans but visible to models. Attackers embed them in cron prompts; users see clean text during review, models still execute.

How to defend?

  1. Scan on write: regex check at cron creation (Hermes pattern)
  2. Runtime prompt sanitize: normalize unicode pre-trigger
  3. Privilege minimization: cron token separate from user token, minimal scope
  4. Audit logs: every trigger logs prompt + result; post-hoc traceable
  5. Rate limit: max N triggers per hour, prevents brute force

OpenClaw’s additional defense:

failureAlert for too-frequent triggers auto-disables cron + alerts. 10 failures = auto-stop.

Follow-up: “What if user writes base64-encoded cron?” base64.*decode is a threat pattern; auto-ask. Signature: users tricked into pasting base64 prompts is common social engineering.

Follow-up: “How to test cron security?” Fix the threat model, platform, scanner version, and input set first; record blocks, false positives, false negatives, and side effects. Without those materials, do not publish a universal production threshold.

Source: hermes-agent/scheduler.py:_CRON_THREAT_PATTERNS + _INVISIBLE_CHARS.

Q9 · Engineering: How to auto-decide disable vs retry on cron failure?

OpenClaw records consecutiveErrors, scheduleErrorCount, and backoff separately. The useful comparison is error classification, not completeness.

Two independent counters:

  • consecutiveErrors: consecutive failure count. Success = reset to 0
  • scheduleErrorCount: scheduling errors (not execution errors). E.g., invalid cron expr

Auto-disable thresholds:

const MAX_CONSECUTIVE_ERRORS = 5;
const MAX_SCHEDULE_ERRORS = 3;
if (job.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
job.disabled = true;
emit('cron.auto_disabled', { reason: 'too many failures' });
}
if (job.scheduleErrorCount >= MAX_SCHEDULE_ERRORS) {
job.disabled = true;
emit('cron.auto_disabled', { reason: 'invalid schedule' });
}

Why two counters?

  • 5 execution failures = task has a bug, but scheduling is fine
  • 3 schedule failures = cron expr / tz config wrong, should disable immediately

Different thresholds: execution errors more tolerant (business may temporarily fail), schedule errors stricter (config error stops immediately).

Exponential backoff:

function nextRetryDelay(consecutiveErrors: number): number {
return Math.min(
1000 * Math.pow(2, consecutiveErrors), // 1s, 2s, 4s, 8s, 16s
300_000 // cap 5min
);
}

More failures = longer retry delay, prevents retry storms.

failure-alert independent channel:

interface FailureAlert {
after: number; // alert after N failures
cooldownMs: number; // alert cooldown
destination: Delivery; // alert delivery path
}

after: 3 = alert after 3 failures; cooldownMs: 3600000 = no re-alert within 1h; destination uses independent webhook (not cron main delivery).

Why cooldown?

Otherwise a persistently failing cron triggers alerts every time; user receives 100 identical alerts and can’t read them.

Why independent destination?

The main delivery path may itself be failing. An independent alert path reduces correlated failure, but it can fail too, so track its delivery state.

Compared to Claude Code’s strategy:

Claude Code currently uses recurringMaxAgeMs to expire unrenewed tasks after 30 days. It caps lifetime; it does not judge business value.

Follow-up: “How to distinguish ‘business broken’ vs ‘network jitter’?” Error type classification: NetworkError / TimeoutError = jitter (not counted in consecutiveErrors), BusinessError / SyntaxError = hard break (counted).

Follow-up: “How does user manually re-enable?” /cron enable <id> resets consecutiveErrors.

Source: openclaw/src/scheduling/failure-handler.ts.

Q10 · Open-ended: Combine the four to design a general cron system.

The seven layers below combine components from four sources. They are not a universal minimum; omit or add layers according to process ownership, external delivery, and tool authority.

Layer 1 · CronJob data model (when state must persist)

@dataclass
class CronJob:
id: str # UUID
expr: str # croniter syntax
prompt: str # trigger prompt
timezone: str = 'UTC'
kind: Literal['recurring', 'oneshot'] = 'recurring'
consecutive_errors: int = 0
schedule_error_count: int = 0
disabled: bool = False
isolation: Literal['main', 'isolated'] = 'isolated'
delivery: Delivery
failure_alert: Optional[FailureAlert] = None
skills_snapshot: Optional[dict] = None
created_at: datetime
last_fired_at: Optional[datetime] = None
permanent: bool = False

Borrow OpenClaw CronSchedule + Claude Code CronTask + Hermes job.

Layer 2 · Scheduler (when triggers run locally)

class Scheduler:
async def run(self):
while True:
now = datetime.now(timezone.utc)
for job in self.active_jobs():
if self._should_fire(job, now):
asyncio.create_task(self._fire(job))
await asyncio.sleep(1)
def _should_fire(self, job, now):
if job.kind == 'oneshot':
return self._oneshot_should_fire(job, now)
next_fire = croniter(job.expr, job.last_fired_at or job.created_at).get_next(datetime)
return next_fire <= now

Borrow Hermes croniter + OpenClaw 1s tick.

Layer 3 · Cross-process mutex (when schedulers share jobs)

class SchedulerLock:
def __init__(self, cwd: Path):
self.lock_path = cwd / '.youragent' / 'scheduler.lock'
async def acquire(self) -> bool:
try:
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.lock_path, 'x') as f:
f.write(json.dumps({"pid": os.getpid(), "ts": time.time()}))
return True
except FileExistsError:
return self._is_stale_lock()
async def heartbeat(self):
while True:
self._update_ts()
await asyncio.sleep(10)

Borrow Claude Code scheduler lock.

Layer 4 · Isolated execution (when jobs contend for session state or authority)

async def fire_isolated(job: CronJob):
session = await create_session(
session_id=f"cron-{job.id}-{uuid4()}",
skills_snapshot=job.skills_snapshot or current_skills(),
parent=None,
auto_close=True,
)
try:
result = await session.run_prompt(job.prompt, timeout=job.timeout_ms / 1000)
await deliver(job, result)
except Exception as e:
await handle_failure(job, e)
finally:
await session.close()

Borrow OpenClaw isolated-agent + skills-snapshot.

Layer 5 · Failure handling (when retries are allowed)

async def handle_failure(job: CronJob, error: Exception):
job.consecutive_errors += 1
if job.consecutive_errors >= 5:
job.disabled = True
emit('cron.auto_disabled', {'job_id': job.id})
if job.failure_alert and job.consecutive_errors >= job.failure_alert.after:
if can_alert_now(job, job.failure_alert.cooldown_ms):
await send_alert(job.failure_alert.destination, error)

Borrow OpenClaw failure-handler.

Layer 6 · Delivery (when results leave the runner)

class Delivery(Enum):
NONE = 'none'
ANNOUNCE = 'announce'
WEBHOOK = 'webhook'
FILE = 'file'
async def deliver(job: CronJob, result: str):
handler = DELIVERY_HANDLERS[job.delivery.kind]
await handler(job, result)

Borrow OpenClaw 4 modes.

Layer 7 · Threat scanning (when prompt origin is not fully trusted)

CRON_THREAT_PATTERNS = [
# 10 entries: prompt injection + exfil + persistence
]
INVISIBLE_UNICODE = {
'\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
}
def scan_cron_prompt(prompt: str) -> Optional[str]:
for char in INVISIBLE_UNICODE:
if char in prompt:
return f"Blocked: invisible unicode U+{ord(char):04X}"
for pattern, pid in CRON_THREAT_PATTERNS:
if re.search(pattern, prompt, re.IGNORECASE):
return f"Blocked: pattern {pid}"
return None

Borrow Hermes _scan_cron_prompt + _INVISIBLE_CHARS.

Choices this example makes explicit:

  1. Choose storage by query needs: jobs.json has a smaller surface; evaluate SQLite when concurrency or migrations require it
  2. Choose locks by owner model: shared jobs need exclusion; a single owner does not need the same mechanism
  3. Choose isolation by contention risk: a separate session reduces interference but adds context and operational cost
  4. Scan according to authority: static rules cover known patterns only; runtime policy still limits tools
  5. Derive thresholds from configuration: the example’s five failures are not a universal stop rule
  6. Delivery split by mode: different scenarios, different paths
  7. Cloud option optional: depends on deployment environment

Implementation cost: this chapter has no project log for the combined design, so it cannot estimate weeks. Estimate scheduling, persistence, locking, isolation, delivery, scanning, and fault-injection tests separately.

Follow-up: “Multi-host lock?” Redis SETNX or etcd lease. File locks don’t span hosts.

Follow-up: “How to test the cron system?” Freezegun + run cycles checking correct triggers / backoffs.

Source mosaic: components from four implementations combined by task boundary; this site has not benchmarked the combined design end to end.