scheduled_runs ledger and the rebuildable ticker that drives it, the lifecycle of a single fire, the one-firing-owner invariant that keeps a task from having two schedulers, the error-halts policy that parks a failed recurring Routine instead of retry-burning it, and the unified Scheduler surface that merges Routines and the OpenClaw Gateway’s own cron into one read.
What it is, and what it isn’t
A Routine is Clawboo’s cron for team work. It owns when a team task runs. It is the single external wake for every runtime class, native, the wrapped one-shot runtimes (Claude Code, Codex, Hermes), and OpenClaw, so a team built from mixed runtimes has one scheduling surface regardless of what each runtime can do on its own. A Routine is not a runtime’s own scheduler. Most runtimes carry some notion of standalone cron or heartbeat; the OpenClaw Gateway, for example, schedules an agent’s own life via its built-in cron. Clawboo treats that as a different domain: it can read and operate those entries as an operator surface, but it never fires a team task into a runtime’s own cron, and it never reaches into them to dispatch coordinated team work. The two are surfaced side by side and labeled, never merged. A Routine is not a side channel. A fire is just another task dispatch: it lands on the board, gets atomically claimed, runs through the executor with budgets, approvals, verification, observability, and (for file-mutating work) a worktree all applying exactly as they would for a hand-created or delegated task. There is no privileged “scheduled” execution path.The two cron domains
The normalized schedule row every source projects carries adomain field that keeps the merged view honest. There are exactly two values:
The separation is enforced structurally, not by convention. Registering a
team-task schedule through a runtime-own-life source is refused with a typed TeamTaskDomainViolationError, both at the multiplexer’s gate and again inside the source itself, so the refusal is defense-in-depth. The reasoning: a runtime’s own-life cron is the agent’s private business, and team-task cadence belongs to the Routines ledger where the board, budgets, and verification can see it.
The model
A Routine’s durable state lives in thescheduled_runs ledger. The ticker reads it, decides what is due in SQL, atomically claims each due row, dispatches the fire through the executor, and writes the outcome back, re-arming the row for its next occurrence, or disarming it.
The durable ledger
Thescheduled_runs table is the source of truth. Each row is one Routine: the target agent_id and team_id, the cron_spec string, a task_template JSON blob describing the board task to materialize on each fire, a status, the last_run_at / next_run_at timestamps, a scheduled_by firing-owner label, a last_error, and the dormant tenant_id multi-tenant seam.
The ledger is deliberately cron-math-free. It stores next_run_at as a precomputed epoch-millisecond timestamp and never parses a cron expression itself; callers compute the next occurrence and hand it in. This keeps the database package free of any scheduling-library dependency; the single point that imports the cron library lives in the scheduler package’s occurrence.ts, and everyone else deals in precomputed timestamps.
A cron_spec is one of two shapes: a croner-parseable cron expression (5- or 6-field, optionally with seconds), or a one-shot once@<ISO-8601>. A spent one-shot is self-disabling; after it fires, its next_run_at becomes null, and the due-pass only ever queues rows with a non-null next_run_at, so the row simply goes quiet.
The task_template is a validated JSON object: a board task title, an optional description, a worktree isolation kind (defaulting to code, which provisions a worktree), a priority, an optional repoPath / model, an optional maxNodeCents cost cap threaded into the run, and an optional teamTaskId that binds the Routine to an existing board task instead of minting a fresh one per fire.
The ledger state machine
A row moves through six statuses, enforced inside the write transaction against the freshly read row (the same discipline the board uses):paused is the one status that never auto-fires: the due-pass guards on status = 'idle', so a paused row is invisible to it. A spent one-shot re-enters idle with a null next_run_at and is equally invisible. An errored recurring Routine sits in error, disarmed, until a human resumes it, which is the heart of the error-halts policy below.
The rebuildable ticker
The ticker is the actuator. It holds zero durable state. Its topology is a single timer, armed atmin(max(minNextRunAt − now, 0), 60s) and .unref()’d so it never keeps the process alive. The 60-second clamp does double duty: it is a periodic rescan that picks up rows written by another process and recovers from laptop sleep, and it makes spurious wakes harmless because dueness is always re-decided in SQL.
Each tick runs three phases:
- Due-pass. A single
UPDATE … SET status='queued' WHERE status='idle' AND next_run_at ≤ now RETURNINGflips every due row toqueued. Paused, errored, and disarmed rows are excluded by thestatus='idle'guard. - Claim phase (sequential). For each queued row, an atomic
UPDATE … SET status='claimed' WHERE id=? AND status='queued' RETURNING. A zero-row result means another ticker (or another process) already claimed it; that is data, not an error, so the ticker drops the row and never retries. This is the same “never retry a 409” rule the board’s claim follows. A won claim emits aroutine_firedobservability event and transitions the row torunning. - Dispatch phase (concurrent). The claimed fires are dispatched in parallel under
Promise.allSettled, so a slow OpenClaw fire (bounded only by its watchdog) never head-of-line-blocks the others, and one failing fire never aborts the rest. Each dispatch records its outcome and re-arms (or disarms) the ledger row.
min(next_run_at). Any REST write to a Routine pokes the ticker to re-arm immediately rather than waiting for the next 60-second rescan.
Boot-resume: the ledger reconstructs the actuator
Because the ticker holds no durable state, restarting the process loses nothing; but it can leave rows mid-transition (aclaimed or running row from a fire that the previous process started). Boot-resume heals them in one transaction before arming:
- A
claimedorphan (the fire never started) resets toqueued; it was due; fire it now. The atomic claim and the board task’s own claim deduplicate any double-fire. - A recurring
runningorphan re-arms toidleat its next occurrence. The board side of a half-done dispatch is healed separately by the board’s own orphan reconciliation. - A one-shot
runningorphan goes toerror, never re-armed. Its outcome is unknown, and re-firing risks materializing the one-shot twice, so a human inspects it instead. idlerows are untouched; a past-duenext_run_atfires on the next due-pass.
The fire path
When a claimed fire is dispatched, the wake-bridge does two things. First it materializes the board task: either the existing task the Routine was bound to (which must still be claimable), or a fresh per-fire task stampedscheduled_by: 'clawboo', the firing owner of record. Then it branches on the runtime’s integration class, read from the adapter’s capability seam, never from a hardcoded runtime-id switch:
The one-shot path is the ordinary executor pipeline: claim the board task, provision a worktree if the kind requires it, run the adapter, verify, and complete. A lost claim is treated as success, someone else already owns the task, so the work is happening.
The connected path exists because an OpenClaw agent runs over its live Gateway connection; the one-shot runner refuses such a runtime by design. So a separate operator dispatcher claims the board task, opens a stable per-Routine session over the server-held operator client, and drains the adapter’s event stream to a terminal
done; bounded by a watchdog (10 minutes by default, overridable with CLAWBOO_ROUTINE_OPENCLAW_TIMEOUT_MS). The watchdog is the documented degradation: a pure dispatch-and-record with no closer would leak in_progress board tasks, so on timeout the dispatcher aborts the run and releases the task. Clawboo does not create a Gateway cron entry here: that would be the runtime’s own-life domain.
Each fire emits a sequence of observability events under the run’s trace: routine_fired at claim, routine_dispatched when the bridge picks a path, then routine_completed or routine_error once the outcome is recorded.
The one-firing-owner invariant
A board task must have exactly one scheduler. Two schedulers firing the same task is the recipe for double-dispatch, stale claims, and drift. Thetasks.scheduled_by column carries the firing owner of record; manual for a hand-created task, clawboo for one the Routines engine fires, openclaw (or a future runtime) for a runtime-fired one.
The invariant is enforced at three walls:
- Registration-time de-dup (the primary guard). Binding a Routine to an existing team task inspects that task’s
scheduled_by. If it is already owned by a different non-manualowner, registration is refused with anownership_conflict, surfaced as a typedDuplicateFiringOwnerError(HTTP409, never retried). Amanualtask is stamped with the new owner in the sameBEGIN IMMEDIATEtransaction, so two concurrent registrations against one task serialize. Crucially, the guard is domain-scoped: it only readstasks.scheduled_by(the team-task domain), so a runtime’s own-life cron never trips it. - The atomic claim is the backstop, even if two firings somehow targeted one task, only one can claim it.
- The Gateway source’s refusal is the third wall; a
team-taskcreate aimed at the runtime-own-life source is refused outright.
todo → done), so a recurring schedule against it would fire once and then park in error forever. Binding a recurring spec is refused at registration with a BoundRecurringScheduleError (HTTP 400).
The error-halts policy
When a recurring fire fails, the ledger row moves toerror with the failure recorded in last_error, and next_run_at is set to null; disarmed. It will not fire again until a human resumes it.
This is deliberate. Autonomous scheduled work must never silently retry-burn: a Routine that fails every fire and keeps retrying would spend a budget, churn the board, and bury the real problem. Parking the Routine surfaces the failure (the Scheduler tab shows the error and a Resume affordance) and stops the bleeding. A successful fire, by contrast, re-arms cleanly at its next occurrence; a one-shot self-disables.
A failed dispatch that is really a lost claim is not an error at all, it means another worker already owns the task, the work is happening, and the fire is recorded as satisfied.
The unified Scheduler surface
The Scheduler tab reads both domains through one merged view. AScheduleMultiplexer fans exactly two ScheduleSource adapters:
ClawbooRoutineScheduleSource;domain: 'team-task',manageability: 'managed'. A thin projection over thescheduled_runsledger; Clawboo fully owns these rows.OpenClawGatewayCronScheduleSource:domain: 'runtime-own-life',manageability: 'external-write'. A read+write adapter over the operator’s cron RPC on the live Gateway connection; Clawboo operates these but does not own them.
read() never rejects. A source whose backend is down (a disconnected Gateway) returns a degraded status with whatever records it can; a stale cache, or none, so one dead source can never take the merged view down. The REST GET /api/schedules therefore always returns 200, with per-source degradation reported as data. Writes route by owner: an observe-only source refuses with 403, a team-task create into a runtime-own-life source returns 422, an unknown id returns 404, an illegal transition or ownership conflict returns 409, and a write against a disconnected Gateway returns 503. The manageability tier is a structural gate; the UI is a pure function of it and can never offer an action the owning system forbids.
The Scheduler panel reads and writes exclusively through this unified
/api/schedules surface. Gateway cron jobs are reached server-side via the OpenClawGatewayCronScheduleSource; the browser never speaks the Gateway’s cron.* RPC directly.Design rationale and trade-offs
The ledger-plus-rebuildable-ticker split is the whole design. Putting durable state in SQLite and keeping the actuator stateless means a crash or restart is a non-event;bootResume() reconstructs every active Routine from the rows alone, and deciding dueness in SQL makes the timer’s exact wake time irrelevant. The alternative (a stateful in-memory scheduler) would lose its schedule on every restart and need a separate persistence layer anyway.
Keeping the database package free of the cron library, precomputing next_run_at at the caller and storing epoch milliseconds, means swapping the tick library touches exactly one file, and the ledger never grows a scheduling dependency. The cost is that the caller, not the row, owns the cron math; the ticker re-derives the next occurrence after each fire.
The error-halts policy trades autonomy for safety: a recurring Routine that fails stops itself rather than retrying, which means a transient failure needs a human to un-park it. For autonomous scheduled work spending real budget, that is the correct default; silent retry-burn is the worse failure mode.
Boundaries and non-goals
- Not a runtime’s own scheduler. Clawboo never fires a team task into a runtime’s own-life cron, and never auto-creates own-life crons. The Gateway-cron source is an operator surface over schedules the Gateway owns, not their owner.
- No native scheduler for the one-shot runtimes. Claude Code, Codex, Hermes, and native have no live scheduler Clawboo drives; scheduling them is a Clawboo Routine.
hermes gatewayis deliberately never launched. - Human participants are a seam, not a feature. A Routine targeting a
participantKind: 'human'agent reaches a typedNotImplementedError; the intended future shape is a scheduled board ping, not a spawned process. It is reachable and typed, but not built in v0.3.1. - Single implicit tenant today. Every ledger row carries a
tenant_idcolumn, but it is a dormant seam; no per-tenant filtering is active. Multi-tenant scoping is a future seam, not a shipped feature.
These docs describe Clawboo v0.3.1, the current release.
See also
- The board: the durable task substrate a fire materializes into
- Delegation and orchestration: the other path that turns intent into board tasks
- Governance: the budgets and caps a scheduled fire runs under
- Observability: the
routine_*events a fire emits - Gateway and events: the OpenClaw operator connection the connected dispatch rides
- Recurring team work guide: a how-to for scheduling recurring team tasks
- Schedules API: the REST surface over the unified scheduler
- Glossary: canonical term definitions