Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0080 - Cron

  • Feature Name: Cron Scheduler
  • Start Date: 2026-03-20
  • Discussion: #80, #183
  • Crates: apps/cron

Summary

Cron triggers skills into agents on a schedule. The scheduler runs as a standalone service outside the daemon and speaks the existing StreamMsg protocol — no cron-specific daemon knowledge, no cron-specific wire messages. The apps/cron crate is desktop-oriented; alternate consumers (e.g. multi-tenant cloud schedulers) model their own entry shape, storage, and time-zone semantics — the shared surface between them is just the daemon’s StreamMsg protocol.

Motivation

Agents need periodic behavior — checking feeds, running maintenance, sending reminders. Time-based triggering is one form of trigger; chat messages, webhooks, and file-watch events are others. All of them produce the same shape: something happens → an agent runs with a payload. Cron is the first concrete implementation of this trigger role and deliberately uses the same StreamMsg path that chat gateways use.

The session already carries the agent and sender. A cron entry needs the skill to fire, the agent to run, the sender to attribute it to, and the schedule expression — nothing else.

Design

Data model

[[cron]]
id = 1
schedule = "0 */2 * * * *"
skill = "check-feeds"
agent = "crab"
sender = "cron"
quiet_start = "23:00"
quiet_end = "07:00"
once = false
  • id — auto-incremented on create.
  • schedule — standard cron expression, validated on create and load.
  • skill — fired as /{skill} content into the target conversation.
  • agent — agent running the conversation.
  • sender — sender attribution (default "cron").
  • quiet_start / quiet_end — optional HH:MM window in local time. If the fire time falls inside, the tick is skipped silently. No queuing, no catch-up. Both must be set; otherwise quiet hours are ignored.
  • once — fire once then delete.

Architecture

Cron is a separate binary (crabtalk-cron). It runs as a system service managed by crabup / launchd / systemd. The daemon has no cron code — no field, no handlers, no protocol messages.

The binary uses the standard #[command::command] macro — start, stop, run, logs, same as every other service. No admin subcommands; schedule edits are direct file edits.

Persistence

The scheduler reads $CRABTALK_HOME/config/crons.toml. To add, remove, or change a schedule, edit this file in place. The running service polls the mtime every 2 seconds and reconciles timers on change — abort removed schedules, start new ones. Atomic write (tmp + rename) keeps readers consistent.

For once schedules the service deletes the entry after firing — the only mutation the service itself makes.

Firing

On a scheduled tick the service calls ConnectionInfo::stream from the SDK with:

#![allow(unused)]
fn main() {
StreamMsg {
    agent: "<from entry>",
    content: "/<skill>",
    sender: Some("<from entry>"),
    ..Default::default()
}
}

The reply stream is drained and discarded — output goes to conversation history through the daemon’s normal path. Failures surface as Err items on the receiver; the schedule continues on the next tick.

Alternatives

Keep cron inside the daemon. Rejected. Cron forced a P: Provider bound on the daemon struct for no reason other than that CronStore called runtime.send_to. Embedding also prevented alternate schedulers (e.g. multi-tenant cloud schedulers) from reusing the daemon without running cron.

Introduce an InvokeSkill protocol variant. Rejected. Cron already has everything it needs from SendMsg / StreamMsg — the content /{skill} pattern is what chat-driven slash commands use too. Adding a variant would fragment the trigger contract across consumers and force every downstream scheduler (e.g. external ones) to learn a new wire format.

Cron as a peer protocol endpoint with its own socket for admin. Rejected. The admin surface is thin and routing it through a dedicated socket multiplies client complexity (TUI would need to discover and connect to cron too). Instead, admin is direct file editing — the running service picks up changes via mtime polling.

Put CronEntry + validators in wcore for downstream consumers to reuse. Rejected. Multi-tenant cloud schedulers need different entry fields (tenant id, different trigger payload), different storage (database rows, not TOML), and different time-zone semantics (UTC, not local). The cron::Schedule::from_str “validator” is one line; reimplementing is_quiet for UTC is trivial. Sharing a struct would force alignment on details that shouldn’t align.

Introduce a Trigger trait / crates/trigger library. Premature. Cron is the only trigger today (chat gateways are structurally similar but each has its own domain-specific code — Telegram auth, WeChat sync, etc.). A common trait only becomes clear once a second non-chat trigger lands (webhook, file-watch).

Unresolved Questions

  • Should cron support a time-zone override per entry (instead of local time for everything)?
  • Should there be a max-concurrent-fires limit so a quiet window ending doesn’t burst?