A wakeup schedules the agent, not a worker
leases-and-budgets and session-ledger dissolve this page’s
ScheduleIdpin against the binding reaper, and the open compaction-interaction question with it: the schedules registry is the authority andschedulesre-lists after compaction, so no binding pin is needed — the same dissolution as the durable-job pins. In ledger terms a schedule is a resident of the schedules chapter,service’s timer twin. Everything else on this page stands.
A scheduled wakeup is a timer that posts a synthetic user turn into the
agent’s inbox, re-engaging the agent loop without a human present. It
schedules the agent — the reasoning loop — not a worker. This is the line that keeps it
distinct from its sibling long-running-work:
a spawn/born-durable worker runs a ral computation detached and yields a
value the agent later observes; a wakeup yields a prompt the model later
acts on. The timer runs no ral code — it manufactures a reason for the existing
turn driver to start a turn, and the model decides what that turn does.
The when is a cron expression, and almost nothing here is new code: the mechanism is assembled from machinery already compiled into the binary.
Context
exarch (exarch-architecture) is request–response.
Between turns it sits ReadyForUser (turn-ends-ready),
blocked on a single source: a human prompt. To become a long-running agent —
especially when run headless as a resident process — it needs a second
reason to start a turn. The pieces a wakeup composes from already exist:
- the delivery channel — the bus
PromptQueue(today a per-sessionVecDeque<String>) and its tool-boundary-steering / turn-boundary drain (exarch/src/bus.rs, frontend); this is the channel that generalises into the inbox below; - the synthetic-prompt path —
nudgealready manufactures a next prompt and feeds it throughappend_user(exarch/src/nudge.rs, session); - timers as data —
process::reaperis a single daemon over aBinaryHeap<Scheduled>woken by aCondvar, firing one action at each entry’swhen(core/src/process/reaper.rs); it is the death-clock and the foreground wall of concurrency-detached-vs-structured; - calendar arithmetic —
jiff(timezone- and DST-aware) is already a hard transitive dependency, pulled in by the bundleddatecoreutil (ral-core→uu_date→jiff-icu→jiff). It is compiled into the binary whether or not this feature exists; - host-only affordances —
register_builtins(EXARCH_BUILTINS)adds verbs present in exarch and absent from core, the principle of watch-repl-builtin.
Only one piece is genuinely new (below): the back-channel that today carries user steering must generalise into a session inbox, and the idle wait must become multi-source over it.
Why cron — and why it reuses rather than reinvents
Cron is calendar scheduling, and three facts make it the right surface:
- A headless agent is long-lived, so calendar anchoring is common, not an edge case. “Every weekday at 09:00”, “nightly at 03:00”, “on the half-hour” are the dominant shapes for a resident agent — exactly what cron expresses and a relative interval cannot.
- Models are exceptionally fluent in cron. A five-field expression is a
lingua franca with vast training behind it; the model emits
0 9 * * 1-5reliably. A bespokeevery/daily-atmini-language is the actual wheel reinvention — novel surface with no training data, which the model fumbles. - The dependency objection is already spent. Evaluating a cron expression
(“when does this next fire, in the host’s timezone, across a DST boundary?”)
needs a calendar library — and
jiff, the modern one, is already compiled in via the bundleddate. Declaring it in exarch’sCargo.tomlresolves to the same built crate at zero marginal cost. (host.rsshells todate(1)to avoid a directchronodep for one string; the irony is that thedateit sidesteps is what dragsjiffin regardless.)
Cron is wall-clock; the reaper is monotonic Instant. The scheduler bridges the
two: jiff computes the next absolute occurrence in the host-local timezone, the
daemon sleeps the monotonic delta to it, and recomputes on each fire. DST shifts,
NTP steps, and machine suspend are absorbed by recompute-on-fire plus the
overlap-skip below (a long sleep past several occurrences fires at most once on
wake, not a burst).
Decided
-
The payload is a prompt, not a computation. A wakeup appends a synthetic user message — instructions in natural language — and the model decides what to do with the turn. It does not run a ral thunk. (Running a thunk detached is
spawn’s job; running one past the death-clock is long-running-work’s.) A worker produces a value; a wakeup produces a turn. -
The trigger vocabulary is
cronplusafter. Two forms, each expressing what the other cannot:form recurrence semantics substrate cron "<expr>"repeats next calendar occurrence in host-local tz jiff→ monotonic deadlineafter <dur>one-shot a relative delay from now ( 30m,2h)reaper’s Instant, no calendarcronsubsumes recurring-calendar scheduling (*/30, ranges, lists, day-of-week names).aftercovers “in two hours”, which cron cannot express — it has no now-relative offset. There is no bespokeevery/daily-at. -
Reuse, not reinvention — the answer to “how much is new?”:
- Evaluate cron with
jiff(already compiled): next-occurrence, timezone, DST. Declare it directly in exarch. - Parse the five-field grammar in-tree — it is small and fully specified —
rather than pull a
chrono-based cron crate (cron,croner), which would add a second datetime tree parallel to thejiffalready present. (Revisit only if a mature jiff-based cron crate appears; the subtle parts — day-of-month/day-of-week OR-semantics, DST matching — are then worth not hand-rolling.) - Fire on the reaper. Generalise its entry’s one action from “cancel a
scope” to an action —
Cancel(scope) | Run(callback). The death-clock and wall stayCancel; a wakeup is aRunthat pushes the prompt and signals the wake. One timer daemon for the whole process; thearmed/disarm guard is reused unchanged. Recurrence stays out of the reaper: entries remain one-shot, and the host re-arms the next occurrence on each fire. - Deliver into the session inbox — the generalised
PromptQueue— reusing thenudgesynthetic-prompt path. Cron is the inbox’s first non-human producer; the delivery seam (below) is built once, for cron now and async workers later.
- Evaluate cron with
-
Ephemeral and per-session. A schedule lives on the
Session. It dies when the session ends and when/clearrebuilds the root (session). There is no persistence, no boot-time reload, no operator config file. Aforkdoes not inherit its parent’s schedules — they are host state, not shell state, like the headlessexpect_actionflag. Cron describes only the when; ephemerality is the lifetime, and the two do not conflict (a “09:00 daily” in a session that ends at 17:00 simply never fires again). A durable cron that survives restart is future work, the natural pairing with long-running-work’s durable-job registry, not this page. -
Delivery is at the turn boundary, not the tool boundary. Tool-boundary steering (tool-boundary-steering) is for user redirection mid-turn. A scheduled task is a fresh turn and must not barge into the context of a turn already in motion. A wakeup drains through
Repl::driveas the next prompt. The boundary is a per-message property of the inbox, not a global rule: user steering keeps the tool boundary, a wakeup takes the turn boundary. (An opt-in mid-turnsteertrigger is a possible later refinement, not the default.) -
At most one pending wakeup per schedule. If a schedule’s occurrence arrives while its previous wakeup is still unconsumed, the tick is skipped, not stacked — standard cron overlap semantics, and the same rule that bounds catch-up after a long suspend.
-
The registry is listable; cancellation is by id.
scheduleslists the live schedules (id, expression, next fire, last fired);unschedule <id>removes one. This is load-bearing for the same reason it is in long-running-work: auto-compaction loses the binding name that held theScheduleId, so the model rediscovers its schedules by listing and acts by id. A liveScheduleIdis pinned against agent-binding-reaping.
The delivery seam: one inbox, many producers
The genuinely new piece is small and deliberately general, because a wakeup is
the first instance of a pattern the agent will meet again. Decompose any
“something happens to the loop later” into (trigger, effect, target) and
the seam is obvious — the three things below differ on trigger, but cron and a
finishing worker share an effect and a target:
| mechanism | trigger (when) | effect (what) | target |
|---|---|---|---|
| the reaper’s wall / death-clock | time — a deadline | cancel a scope | a CancelScope, read at process::check |
| a cron wakeup | time — a recurrence | post a message · wake | a Session inbox |
| a finishing async worker (future) | event — the worker settles | post a message · wake | a Session inbox |
So the unification is along the delivery seam, not a single queue. Cron and async-worker completion want the identical thing — post a message to a session and wake its idle loop — so that becomes one mechanism:
- Generalise
PromptQueue(aVecDeque<String>) into a typed per-session inbox —VecDeque<InboxMsg>, each message carrying its source (UserSteering | ScheduledWakeup | AgentResult) and its drain boundary. One wake signal accompanies it. This is the inbound twin of the existing outboundKindevent stream (bus.rs): same bus, opposite direction. - The idle wait becomes a select over
{ user input, inbox }. The reaper’sRunaction is one producer (cron); a finishing worker is another; the TUI is the third.Repl::drivepulls the next message and starts a turn as if the user had typed it. Build the inbox once, for cron now and async delivery later — the latter turns the canonicalspawn → poll across turnsidiom of concurrency-detached-vs-structured into a push (“your job finished” arrives as a message).
Two seams that look adjacent must not collapse — each is a category error:
- An event-trigger is not a timer entry. A worker completion has no
when; it fires on its own result channel (HandleInner’srx). It posts to the inbox directly on settle — it is not armed as a zero-delay reaper entry, which would be a poll dressed as a deadline. The timer heap and the completion channel are different triggers that share only the inbox. - A cancellation is not a message. The control plane (cancel a scope → read at
check→ means unwind) and the data plane (a message → read by the driver → means here is work) stay on separate rails. Collapsing them re-admits the abuse of treating aDeadlinecancellation as a positive wake. Enforce it with a type: a cancellation must be unconstructable as anInboxMsg, in the spirit of structural-bug-prevention.
A shared channel does not mean a uniform drain policy: the boundary is the per-message tag above, generalising tool-boundary-steering rather than replacing it.
The two frontends realise the wait differently:
- The TUI already redraws each tick, so it polls the inbox cheaply; the wake signal need only interrupt its blocking input read.
- A resident agent wants a daemon frontend whose idle loop is this select —
headless.rstoday reads one seed and exits (frontend); the resident variant loops on the multi-source wait. Whether to build it now or lean on the TUI is open.
A wakeup renders as a marked user turn — e.g.
[scheduled 'nightly' · 0 3 * * *] run the full test suite … — so the model can
tell a wakeup from a human, and events.json records it as scheduled.
Surface (illustrative)
cron : String → Trigger, after : Duration → Trigger,
schedule : Trigger → String → ScheduleId. The payload is a string prompt; the
cron expression is evaluated in the host’s local timezone.
let nightly = schedule (cron "0 3 * * *") "run the full test suite; summarise failures"
let standup = schedule (cron "0 9 * * 1-5") "post the standup digest to the channel"
let once = schedule (after 2h) "re-run the flaky integration test once"
schedules # → the live schedules, by id
unschedule $nightly
Fixed arity holds (fixed-arity). Self-scheduling is
gated behind a schedule authority in the grant profile, off by
default — an agent that can wake itself indefinitely holds real authority, even
ephemerally.
Alternatives considered
- A bespoke trigger DSL (
every <dur>,daily-at "09:00"). Rejected: cron is the model-fluent lingua franca, and a custom mini-language is the actual reinvention — novel surface with no training data, fumbled by the model, and weaker than cron at the calendar cases a headless agent needs. - Pull a cron crate (
cron,croner) for parsing and evaluation. Rejected: both arechrono-based and would add a datetime tree parallel to thejiffalready compiled in. Reusejifffor evaluation and parse the small grammar in-tree instead. - Persist schedules / operator config across restart. Out of scope by the ephemeral + per-session decision; a durable cron is future work, paired with long-running-work’s registry.
- A sibling timer daemon (rather than generalising the reaper’s action).
Viable, and the lower-blast-radius option if touching a load-bearing core
primitive is unwanted — but it duplicates the heap/
Condvar/daemon the reaper already is. Generalising the action toCancel | Runis the greater reuse, and aRuncallback leaks no host representation into core: core fires opaque deadlines, the host supplies the closure. - A cron-specific delivery queue. Rejected: a wakeup’s effect — post a
message, wake the loop — is identical to a finishing async worker’s. Building a
wakeup-only channel would force a second, parallel mechanism the moment async
delivery lands. Generalise
PromptQueueinto one typed inbox now. - Default to tool-boundary steering. Rejected: a scheduled task is a fresh turn, not user redirection; injecting it mid-turn pollutes an unrelated turn’s context.
- Inject a computation, not a prompt (run a ral thunk on a timer). Rejected:
that is
spawn/ long-running-work — a detached worker producing a value. A wakeup re-engages the reasoning loop; its payload is a prompt the model acts on, not code the runtime runs.
Open questions
- The resident daemon frontend — build the multi-source idle loop now, or rely on the TUI’s tick until a headless-resident agent is actually wanted?
- Hand-rolled matcher vs a vetted crate — parse-and-match cron on
jiff, or adopt ajiff-based cron crate if a mature one exists, to avoid subtle day-of-month/day-of-week and DST matching bugs. - Catch-up policy — after a suspend past several occurrences, fire once (overlap-skip, the recommendation) or fire each missed occurrence.
- Runaway bounds — a per-session fire budget or minimum-interval floor beyond
the
schedulegrant gate. - Compaction interaction — a wakeup arriving mid-
compact, and pinning theScheduleIdagainst binding-reaping. - Grant for a scheduled turn — the ordinary per-turn profile, or a distinct (perhaps narrower) scheduled-turn profile?
- The inbox as its own
design/page — the(trigger, effect, target)decomposition and the inbox-as-inbound-bus span the reaper, cron, the bus, and the future async-worker delivery this ADR only anticipates. It may be worth filing as a cross-cutting concept page that predates that work.
See also
concurrency-detached-vs-structured
(the reaper / death-clock lineage and the deferred host-managed-job line),
long-running-work (the sibling mechanism —
a durable worker, where this is a recurring turn),
watch-repl-builtin (the core-mechanism /
host-affordance registration principle),
tool-boundary-steering and
frontend (the prompt queue the inbox generalises and the
per-message drain boundary it carries),
structural-bug-prevention (the
missing-type discipline that keeps a cancellation unconstructable as a message),
agent-binding-reaping (pin the
ScheduleId), unify-turn-evaluation
(the frame, the root/foreground cancel scopes the control plane rides, and the
idle loop’s home), exarch-architecture, and
docs/SPEC.md §13.