Figure — schedule lifecycle: create → arm → fire → track → escalate
schedules.json, pending_events.json) that make both structures crash-safe. Teal boxes are the model/agent side of the boundary; plain vellum boxes are internal Scheduler/Tracker mechanics.What it is
The package comment says it plainly: scheduler is "a small, persistent reminder engine." It has two halves that live in the same package but do different jobs. Scheduler (scheduler.go) is the clock — it owns a robfig/cron runner and a map of time.Timers, and calls a DeliverFunc when something is due. Tracker (events.go) is the conscience — it records that a fire actually happened as a PendingEvent, injects it into the session, and won't let it go until ack_event is called, nudging and then escalating to Jean directly if the agent ignores it. Neither type imports anything about Claude, Telegram, or Discord; the daemon wires Scheduler's DeliverFunc to Tracker.Fire, and Tracker's InjectFunc/FallbackFunc/ReceiptFunc to the actual session and frontend.
Cron vs one-shot
A Schedule struct carries exactly one of Cron (a 5-field cron spec, parsed with cron.ParseStandard) or FireAt (an absolute instant, computed from in_seconds at Create() time). Create() rejects a call unless exactly one is set. Recurring() just checks Cron != "". Recurring schedules get a real cron entry via cron.AddFunc; one-shots get a time.AfterFunc. A one-shot whose FireAt is already in the past when the process starts (missed while relayd was down) is armed with delay 0 — it fires almost immediately on load rather than being silently dropped.
Self-scheduling
The model schedules itself: schedule_message is an MCP tool registered in cmd/relay-shim/main.go (registerScheduleTools), which calls straight into Scheduler.Create. There is no separate admin path — the same tool serves both "remind the user at 9am" (delivered via reply when it fires) and "wake myself up in 20 minutes to resume this task" (the initialPrompt text tells the agent to just continue). list_schedules/cancel_schedule wrap List()/Cancel().
Event lifecycle
- Fire —
Scheduler.fire(id)callsdeliver()(wired toTracker.Fire) before deleting a one-shot's own record, so a crash between the two can't lose the event — worst case the schedule fires again and the tracker's coalescing (samescheduleID, still open) absorbs the duplicate. - Record —
Tracker.Firecreates aPendingEvent{Status: StatusPending, FireCount: 1}and persists it topending_events.json(atomic tmp+rename) before doing anything else. - Deliver/inject — exactly once, via
InjectFunc, withinitialPrompt()text instructing the agent to act and then callack_event. - Nudge — if still pending after
NudgeAfter(default 5m),Reconcile()re-injectsnudgePrompt()once, stampingLastNudgeAt. - Escalate — if still pending after
EscalateAfter(default 12m),FallbackFuncpings the admin (Jean) directly, stampedFallbackSentAtonly on send success. - Ack — explicit via
ack_event(Tracker.Ack, requires a non-empty note) or inferred viaNoteReplyif a reply lands withinReplyAckWindow(2m) of the fire/nudge.
Delivery vs. escalation
Injection happens exactly once at Fire time — the comment in the code is explicit that the Claude endpoint's Send is "lossless-buffered," so an undelivered inject is not lost and re-sending it every reconcile tick would just spam duplicates. Reconcile() only ever adds new stages (nudge, then fallback), each gated by its own persisted timestamp so it fires at most once and survives a restart mid-escalation. A fully-escalated event (fallback already sent) that fires again via its recurring schedule is treated as a fresh delivery — FiredAt/DeliveredAt/LastNudgeAt/FallbackSentAt are all reset — so one stuck event can't permanently suppress a recurring reminder.
Persistence and external edits
Both files (schedules.json, pending_events.json) are written atomically (temp file + os.Rename) and reloaded on startup (load()), which is what makes the whole thing crash-safe: Tracker has no per-event timers at all — Reconcile derives everything due from wall-clock comparisons against persisted timestamps on a 30s ticker, so a restart at any point needs no re-arming logic. Scheduler additionally runs an fsnotify watcher on the directory holding schedules.json (not the file itself, since save's rename swaps the inode); a hand-edit while relayd is running is diffed in reconcileFromFile(), re-armed, tagged Source: SourceFile, and — if an ExternalFunc is wired — surfaced to the agent as a heads-up. Deletions in the file are deliberately ignored (never disarms a running schedule from an external edit) to avoid a hand-edit silently killing something live.