# 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

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

### 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

| 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                          |

***

## 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:

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

### Trigger-Time Data Flow

When the scheduler fires a reminder, routing metadata flows through the full notification pipeline:

```mermaid
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:

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

### 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

| 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                            |

***

## 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.

```mermaid
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                                                |
