Developer Guide

Scheduling Architecture

Scheduling Architecture

Recurring schedules, watchers, and queued task execution architecture.

Execute-mode completion and time limits

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.

Recurrence Schedules — Cron and RRULE Dual-Syntax Engine

The scheduler supports two recurrence syntaxes for recurring tasks:

  • Cron — Standard 5-field cron expressions (e.g., 0 9 * * 1-5 for weekday mornings). Evaluated via the croner library.
  • RRULE — iCalendar recurrence rules (RFC 5545). RRULE sets (multiple RRULE lines, RDATE/EXDATE exclusions) are parsed via rrulestr with forceset: true.

Supported RRULE Lines

LinePurposeExample
DTSTARTStart date/time anchor (required)DTSTART:20250101T090000Z
RRULE:Recurrence rule (one or more; multiple lines form a union)RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
RDATEAdd one-off dates not covered by the RRULE patternRDATE:20250704T090000Z
EXDATEExclude specific dates from the recurrence setEXDATE:20251225T090000Z
EXRULEExclude an entire series defined by a recurrence patternEXRULE: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.

Syntax Detection

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().

Naming

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.

Key Source Files

FileResponsibility
assistant/src/schedule/recurrence-types.tsScheduleSyntax type, detectScheduleSyntax(), normalizeScheduleSyntax()
assistant/src/schedule/recurrence-engine.tsValidation (isValidScheduleExpression), next-run computation, RRULE set detection
assistant/src/schedule/schedule-store.tsCRUD operations, claim-based polling
assistant/src/schedule/scheduler.ts15-second tick loop, fires due schedules and reminders
assistant/src/memory/schema.tscronJobs / scheduleJobs table, scheduleSyntax column

Reminder Routing — Trigger-Time Multi-Channel Delivery

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.

Routing Metadata Model

Two columns on the reminders table carry routing metadata:

ColumnTypeDefaultDescription
routing_intentTEXT'single_channel'Controls channel coverage: single_channel, multi_channel, or all_channels
routing_hints_jsonTEXT (JSON)'{}'Free-form hints for the decision engine (e.g. preferred channels)

Trigger-Time Data Flow

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

Enforcement Behavior

The enforceRoutingIntent() step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:

IntentEnforcement Rule
single_channelCap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected.
multi_channelIf the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels.
all_channelsReplace 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.

Single-Reminder Fanout

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.

Connected Channels at Fire Time

Channel availability is resolved when the signal is emitted (not when the reminder is created):

  • Cadet — always connected (local HTTP)
  • Telegram — connected when an active guardian binding exists

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.

Key Source Files

FileResponsibility
assistant/src/tools/reminder/reminder-store.tsCRUD with routingIntent and routingHints fields
assistant/src/memory/schema.tsreminders table schema with routing_intent and routing_hints_json columns
assistant/src/schedule/scheduler.tsClaims due reminders and passes routing metadata to the notifier
assistant/src/daemon/lifecycle.tsWires the reminder notifier to emitNotificationSignal() with routing metadata
assistant/src/notifications/emit-signal.tsOrchestrates the full pipeline including routing intent enforcement
assistant/src/notifications/decision-engine.tsenforceRoutingIntent() post-decision guard
assistant/src/notifications/signal.tsRoutingIntent type and NotificationSignal fields

Watcher System — Event-Driven Polling

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:

DecisionRationale
Watermark-based pollingEfficient change detection without webhooks; each provider defines its own cursor format
Background conversationsLLM 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 interfaceExtensible: implement WatcherProvider for any external API (Gmail, Stripe, Gong, Salesforce, etc.)
Optimistic claim lockingPrevents double-polling in concurrent scheduler ticks