Recurring schedules, watchers, and queued task execution architecture.
An execute-mode run owns its automatic result notification, including results from background commands and subagents. The scheduler waits for the firing's own active turns, queued continuations, commands, child tasks, and command wakes to settle before marking the run successful and emitting schedule.result. Unrelated work in a reused conversation does not extend the wait. Ownership is recorded before asynchronous turn setup, covers dequeued messages while their processing claim and persistence are pending, and follows wakes through queueing, hydration, and execution. Dequeued dispatches retain a cancellation signal for teardown until the active turn takes over. User interrupts preserve accepted user prompts during that handoff; started turns remain cancellable. Background work retains its original timing through queueing, dispatch, active processing, and command wakes. The reply-notification gate and persisted-history check apply that timing to suppress stale kickoff alerts without hiding replies to newer user requests. Quiet runs skip this fallback, and explicit successful delivery suppresses duplicates.
The wait shares the run's timeouts.scheduleTurnTimeoutSec budget. If delegated work exceeds that budget, the scheduler records an error, cancels only commands, child tasks, queued continuations, and the active turn owned by that run. User work and other runs in the same conversation remain intact. The attempt follows the existing retry and exhaustion-alert policy; partial output never emits a success notification. Scheduled command wakes carry cronRunId, check the persisted run status before starting, and observe cancellation while running, so a late callback cannot restart a failed attempt.
The scheduler supports two recurrence syntaxes for recurring tasks:
0 9 * * 1-5 for weekday mornings). Evaluated via the croner library.RRULE lines, RDATE/EXDATE exclusions) are parsed via rrulestr with forceset: true.| Line | Purpose | Example |
|---|---|---|
DTSTART | Start date/time anchor (required) | DTSTART:20250101T090000Z |
RRULE: | Recurrence rule (one or more; multiple lines form a union) | RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR |
RDATE | Add one-off dates not covered by the RRULE pattern | RDATE:20250704T090000Z |
EXDATE | Exclude specific dates from the recurrence set | EXDATE:20251225T090000Z |
EXRULE | Exclude an entire series defined by a recurrence pattern | EXRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 |
Bounded recurrence is supported via COUNT (e.g., RRULE:FREQ=DAILY;COUNT=30) and UNTIL (e.g., RRULE:FREQ=WEEKLY;UNTIL=20250331T235959Z) parameters on RRULE lines.
Exclusion precedence: EXDATE and EXRULE exclusions always take precedence over RRULE and RDATE inclusions. A date that matches both an inclusion and an exclusion is excluded.
The detectScheduleSyntax() function auto-detects which syntax an expression uses by checking for RRULE markers (RRULE:, DTSTART, FREQ=). When creating or updating a schedule, the caller can explicitly specify syntax: 'cron' | 'rrule', or the system infers it from the expression string via normalizeScheduleSyntax().
The database column is named cron_expression and the Drizzle table is cronJobs for historical reasons. Code aliases scheduleJobs and scheduleRuns are preferred in new code. The canonical API field is expression with an explicit syntax discriminator.
| File | Responsibility |
|---|---|
assistant/src/schedule/recurrence-types.ts | ScheduleSyntax type, detectScheduleSyntax(), normalizeScheduleSyntax() |
assistant/src/schedule/recurrence-engine.ts | Validation (isValidScheduleExpression), next-run computation, RRULE set detection |
assistant/src/schedule/schedule-store.ts | CRUD operations, claim-based polling |
assistant/src/schedule/scheduler.ts | 15-second tick loop, fires due schedules and reminders |
assistant/src/memory/schema.ts | cronJobs / scheduleJobs table, scheduleSyntax column |
Reminders support optional routing metadata that controls how the notification pipeline fans out delivery across channels when a reminder fires. This allows a single reminder to reach the user on multiple channels (desktop, Telegram) without requiring duplicate reminders.
Two columns on the reminders table carry routing metadata:
| Column | Type | Default | Description |
|---|---|---|---|
routing_intent | TEXT | 'single_channel' | Controls channel coverage: single_channel, multi_channel, or all_channels |
routing_hints_json | TEXT (JSON) | '{}' | Free-form hints for the decision engine (e.g. preferred channels) |
When the scheduler fires a reminder, routing metadata flows through the full notification pipeline:
sequenceDiagram
participant Scheduler as Scheduler<br/>(15s tick)
participant Store as ReminderStore<br/>(SQLite)
participant Lifecycle as Daemon Lifecycle<br/>(notifyReminder)
participant Signal as emitNotificationSignal
participant Engine as Decision Engine<br/>(LLM)
participant Enforce as enforceRoutingIntent
participant Broadcaster as Broadcaster
participant Adapters as Channel Adapters<br/>(Cadet, Telegram)
Scheduler->>Store: claimDueReminders(now)
Store-->>Scheduler: ReminderRow[] (with routingIntent, routingHints)
Scheduler->>Lifecycle: notifyReminder({ id, label, message, routingIntent, routingHints })
Lifecycle->>Signal: emitNotificationSignal({ routingIntent, routingHints, ... })
Signal->>Engine: evaluateSignal(signal, connectedChannels)
Engine-->>Signal: NotificationDecision (LLM channel selection)
Signal->>Enforce: enforceRoutingIntent(decision, routingIntent, connectedChannels)
Note over Enforce: Override channel selection<br/>based on routing intent
Enforce-->>Signal: Enforced decision (re-persisted if changed)
Signal->>Broadcaster: dispatchDecision(signal, decision)
Broadcaster->>Adapters: Fan-out to each selected channel
The enforceRoutingIntent() step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:
| Intent | Enforcement Rule |
|---|---|
single_channel | Cap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. |
multi_channel | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
all_channels | Replace the LLM's selection with all connected channels. |
A decision the LLM suppressed (shouldNotify: false) is left alone under every intent. A signal with no routing intent keeps the LLM's selection.
When enforcement changes the decision, the updated selectedChannels and annotated reasoningSummary are re-persisted to notification_decisions so the audit trail reflects what was actually dispatched.
One reminder creates one notification signal. The routing intent on that single signal controls how many channels receive the notification. The notification pipeline handles per-channel copy rendering, conversation pairing, and delivery through existing adapters. No duplicate reminders are needed for multi-channel delivery.
Channel availability is resolved when the signal is emitted (not when the reminder is created):
If a channel becomes unavailable between reminder creation and fire time, it is silently excluded from delivery. The routing intent enforcement operates only on channels that are connected at fire time.
| File | Responsibility |
|---|---|
assistant/src/tools/reminder/reminder-store.ts | CRUD with routingIntent and routingHints fields |
assistant/src/memory/schema.ts | reminders table schema with routing_intent and routing_hints_json columns |
assistant/src/schedule/scheduler.ts | Claims due reminders and passes routing metadata to the notifier |
assistant/src/daemon/lifecycle.ts | Wires the reminder notifier to emitNotificationSignal() with routing metadata |
assistant/src/notifications/emit-signal.ts | Orchestrates the full pipeline including routing intent enforcement |
assistant/src/notifications/decision-engine.ts | enforceRoutingIntent() post-decision guard |
assistant/src/notifications/signal.ts | RoutingIntent type and NotificationSignal fields |
Watchers poll external APIs on an interval, detect new events via watermark-based change tracking, and process them through a background LLM session.
graph TD
subgraph "Scheduler (15s tick)"
TICK["runScheduleOnce()"]
CRON["Recurrence Schedules<br/>(cron + RRULE)"]
REMIND["Reminders"]
WATCH["runWatchersOnce()"]
end
subgraph "Watcher Engine"
CLAIM["claimDueWatchers()"]
POLL["provider.fetchNew()"]
DEDUP["insertWatcherEvent()"]
PROCESS["processMessage()"]
end
subgraph "Provider Registry"
GMAIL["Gmail Provider"]
SLACK_W["Slack Provider"]
GCAL["Google Calendar Provider"]
GH_W["GitHub Provider"]
LINEAR_W["Linear Provider"]
FUTURE["Future Providers..."]
end
subgraph "Disposition"
SILENT["silent → log"]
NOTIFY["notify → macOS notification"]
ESCALATE["escalate → user chat"]
end
TICK --> CRON
TICK --> REMIND
TICK --> WATCH
WATCH --> CLAIM
CLAIM --> POLL
POLL --> GMAIL
POLL --> SLACK_W
POLL --> GCAL
POLL --> GH_W
POLL --> LINEAR_W
POLL --> FUTURE
POLL --> DEDUP
DEDUP --> PROCESS
PROCESS --> SILENT
PROCESS --> NOTIFY
PROCESS --> ESCALATE
Key design decisions:
| Decision | Rationale |
|---|---|
| Watermark-based polling | Efficient change detection without webhooks; each provider defines its own cursor format |
| Background conversations | LLM retains context across polls (e.g. "already replied to this thread"); invisible to user's chat |
| Circuit breaker (5 errors → disable) | Prevents runaway polling when credentials expire or APIs break |
| Provider interface | Extensible: implement WatcherProvider for any external API (Gmail, Stripe, Gong, Salesforce, etc.) |
| Optimistic claim locking | Prevents double-polling in concurrent scheduler ticks |