agent-relay / internal/schedulercomponent breakdown — reminder engine + event tracker

The scheduler: cron, one-shots, and the event that won't let itself be ignored

Two cooperating types — Scheduler (internal/scheduler/scheduler.go) arms timers and fires text at a chat id; Tracker (internal/scheduler/events.go) records that a fire actually happened and escalates until it's acknowledged. Neither knows about Claude, Telegram, or Discord — they hold text, a chat id, and timestamps, and call back into the daemon.

Figure — schedule lifecycle: create → arm → fire → track → escalate

primary call/data flow persistence / disk
Model / MCP tool schedule_message Scheduler.Create cronSpec xor in_seconds arm() cron entry / time.Timer schedules.json save() tmp+rename, re-armed by load() on restart fsnotify watcher watchLoop → 300ms debounce → reconcileFromFile() hand-edit tagged Source=SourceFile cron tick / AfterFunc calls fire(id) Scheduler.fire calls DeliverFunc Tracker.Fire daemon's DeliverFunc impl deliver() one-shot: deleted from items only AFTER deliver() succeeds (crash-safe) PendingEvent status=pending, FireCount++ coalesced if same scheduleID open pending_events.json persist() before any cleanup InjectFunc into Claude session as "[scheduled trigger...] fired" initialPrompt(ev) Model / agent acts on trigger, replies, or resumes self-wakeup work Tracker.runLoop 30s ticker → Reconcile(now) wall-clock, no per-event timers scans events map age ≥ NudgeAfter (5m) re-inject nudgePrompt() once LastNudgeAt stamped (resets ReplyAckWindow ref) age ≥ EscalateAfter (12m) FallbackFunc → admin direct FallbackSentAt stamped on success ack_event tool Tracker.Ack(id, note) note required, non-empty NoteReply inferred ack if reply lands within ReplyAckWindow (2m) status = acknowledged ReceiptFunc → one-line receipt to Jean pruned from disk after Retention (1h) Reconcile() is idempotent per stage: NudgeAfter and EscalateAfter each fire at most once, gated by a persisted timestamp — a restart mid-escalation loses no state.
Solid cobalt arrows = live call/data flow inside relayd; dashed grey = writes to the two JSON files (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

  • FireScheduler.fire(id) calls deliver() (wired to Tracker.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 (same scheduleID, still open) absorbs the duplicate.
  • RecordTracker.Fire creates a PendingEvent{Status: StatusPending, FireCount: 1} and persists it to pending_events.json (atomic tmp+rename) before doing anything else.
  • Deliver/inject — exactly once, via InjectFunc, with initialPrompt() text instructing the agent to act and then call ack_event.
  • Nudge — if still pending after NudgeAfter (default 5m), Reconcile() re-injects nudgePrompt() once, stamping LastNudgeAt.
  • Escalate — if still pending after EscalateAfter (default 12m), FallbackFunc pings the admin (Jean) directly, stamped FallbackSentAt only on send success.
  • Ack — explicit via ack_event (Tracker.Ack, requires a non-empty note) or inferred via NoteReply if a reply lands within ReplyAckWindow (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.

agent-relay · internal/scheduler/scheduler.go + events.go · researched from source, 2026-08