0080 - 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— optionalHH:MMwindow 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?