diff --git a/CHANGELOG.md b/CHANGELOG.md index 3790e7c..7c6a871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased — scheduler: switch to unix cron (BREAKING TASK.md format) + +Replaces the three-form schedule grammar (`every ` / `daily_at HH:MM` / `at `) with 5-field unix cron + optional per-task `tz:` (default: `$TZ` env / host runtime tz). One grammar closes four real gaps in a single change: time-of-day windows, day-of-week filtering, local-timezone scheduling, and anchored cadence. Predicate: the live stretch trigger on 2026-05-15 ("every 30m between 12:00 and 18:00 weekdays Denver") required thirteen separate `daily_at` TASK.md files under the old grammar. + +- **Frontmatter.** `schedule:` is replaced by exactly one of `cron:` (5-field unix expression) or `at:` (ISO8601 absolute, unchanged semantics). Optional `tz:` is per-task; omitting it falls back to `$TZ` env, then the host's runtime tz. Cron evaluates against `tz`'s wall-clock; `cron-parser@5.5.0` handles DST (spring-forward skipped, fall-back single fire — verified by smoke + unit tests). +- **Anchored cadence (BEHAVIOR CHANGE).** Old `every 1h` drifted from `last_run_at`; new `cron: "0 * * * *"` anchors at `:00`. A mid-window restart at 14:13 fires next at 15:00, not 15:13. Most operators want anchored; if you relied on drift, switch to something like `cron: "13 * * * *"` and pin the minute. +- **No first-deploy catch-up under cron.** Cron is anchored, not stateful. A fresh task at 14:00 with `0 9 * * *` waits until tomorrow 09:00 — not today's. Old `daily_at` would have fired today's 09:00 anchor on first boot. If you need a one-time-now fire, add a sibling `at:` task. +- **New runtime dep: `cron-parser@5.5.0` (exact-pinned).** Replaces ~150 LOC of subtle tz/DST math we'd otherwise write. Brings `luxon` transitively. Mirror the SDK pin convention: bumps are deliberate verification passes. +- **Parser strictness at our layer.** Cron-parser is permissive (accepts 4-field, 6-field, empty string, `@daily`/`@hourly`); we pre-validate to enforce one-shape grammar: exactly 5 space-separated fields, no `@`-aliases, IANA tz validated by `Intl.DateTimeFormat` before handing to the parser (cron-parser's tz error is cryptic). +- **Min-interval guard.** Inspects the next 5 fire times at load and rejects if any gap < tier floor (5min Claude, 1min Ollama). Rejects pathological `* * * * *` on Claude with a clear error pointing at the floor. +- **Migration cheat-sheet.** Lives in `docs/USAGE.md` under "Schedule grammar". Hard cutover: no dual-shape acceptance period. Old `schedule:` is rejected as an unknown frontmatter key. +- **In-repo tasks migrated.** `examples/tasks/morning-digest` → `cron: "0 9 * * 1-5"` + `tz: America/Denver`. `examples/tasks/weekly-pr-review` → `cron: "0 9 * * 1"` + `tz: America/Denver`. `tasks/stretch/TASK.md` removed entirely — it was an operator-specific example and the canonical templates now live under `examples/tasks/`. +- **Live-verification fixes.** Two bugs surfaced during the dev-loop deploy: + - **Tick driver anchor.** `nextRunAt(task, lastRunAt=null, now)` for cron anchored on `now`, which advances every tick → `due` always future → task never fired through the natural tick path (only `/tasks run ` worked). Fixed by adding a `bootMs` param: cron now anchors on `lastRunAt ?? bootMs ?? now`, with the tick driver passing `bootMs = bootTime`. New regression test simulates multi-tick clock advancement and asserts two fires across two cron moments. + - **`/tasks` web-UI rendering.** The listing constructed HTML with `\n` line breaks; the web transport tried to render that as markdown and single `\n`s collapsed → everything on one line. Refactored `runTasksList` to the dual-render pattern (`/help`, `/status`, `/context` already use it): authors markdown, sends `mdToTelegramHtml(md)` for the bot + `md` as `markdownSource` for the web. Telegram output is virtually identical; web UI now gets proper `
    `/`
    ` rendering. +- **Tests.** 72 scheduler tests pass (was 60); coverage adds tz handling, weekday filter, DST spring-forward + fall-back, min-interval guard, both-cron-and-at rejected, invalid IANA tz rejected, full-weekday integration (`*/30 12-18 * * 1-5` Denver → 14 fires/Mon, 0 fires/Sat), and the multi-tick regression test for the tick-driver anchor. +- **Docs.** `docs/USAGE.md` (scheduled-tasks section + cheat sheet), `docs/ARCHITECTURE.md` (grammar paragraph + catch-up policy), `docs/GLOSSARY.md` (new `cron expression` entry), `docs/CONFIG.md` (`TZ` env var entry), `docs/OPERATIONS.md` (systemd `Environment=TZ=` snippet), `docs/ROADMAP.md` (OQ#12 closeout). + +No SDK pin bump. Reverses one anti-goal-adjacent design call (the "no cron in v1, kept the parser ~30 LOC" line in `docs/ARCHITECTURE.md`) — see the dep-justification block in `PLAN.md § Adding cron-parser as a runtime dep`. + ## Unreleased — Notion query truncation defenses Live `/clear ollama` verification under gemma4:e4b surfaced a tool-result overflow: a "list my in-progress tickets" query against the PNX projects database returned 7 rows but the 7th rendered with `(null)` for every property except title because `notion_query_database`'s JSON payload exceeded the 8 KB `TOOL_RESULT_MAX_LEN` cap and got cut mid-object. The model honestly narrated the gap; the cap had been chosen in the abstract before any integration emitted real volume, and the most useful Notion read overflowed on a single call. See `solrac-dev/PLAN-B.md` §1. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68090d2..6f1173a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -284,14 +284,14 @@ Reply for both: model output verbatim, HTML-escaped, truncated to ≈3,500 chars **Three composed pieces.** 1. **Loader** — same shape as skills' loader (frontmatter + body, fail-soft, name-collision rules). -2. **Schedule grammar** — `every `, `daily_at HH:MM`, `at `. Pure parser + pure `nextRunAt(spec, lastRunAt, now)`. No cron in v1 (kept the parser ~30 LOC and avoided `cron-parser` as a dep). -3. **Tick driver** — single shared `setInterval(60_000)` scans the registry, compares `nextRunAt(...)` to now, fires due tasks via the existing `queue.enqueue`. Boot fire runs the first tick immediately so jitter=0 catch-up tasks don't wait 60s. +2. **Schedule grammar** — 5-field unix cron via `cron:` or absolute one-off via `at:` (mutually exclusive). `tz:` is per-task with `$TZ`-env / host fallback. `cron-parser@5.5.0` (exact-pinned) handles tz + DST semantics (spring-forward skipped, fall-back single fire). Pure `validateCronExpr(expr, tz)` and pure `nextRunAt(task, lastRunAt, now)`. Predefined cron aliases (`@daily`, `@hourly`) are rejected at parse to keep the grammar one-shape; 4-field and 6-field expressions are pre-rejected before the parser sees them. +3. **Tick driver** — single shared `setInterval(60_000)` scans the registry, compares `nextRunAt(...)` to now, fires due tasks via the existing `queue.enqueue`. Boot fire runs the first tick immediately so jitter=0 catch-up tasks don't wait 60s. **Fresh tasks (never-run) do NOT boot-fire under cron** — cron is anchored, not stateful; a fresh deploy at 14:00 with `0 9 * * *` waits until tomorrow 09:00. Catch-up after restart still works: when `last_run_at` is set and the next cron fire after it is in the past, the task fires ONCE at boot. **Synthetic-update construction.** The driver builds a `Update` with negative `update_id` (avoids any chance of colliding with Telegram's positive offset space — `handled_updates.update_id` IS PRIMARY KEY, so a synthetic id colliding with a future poll offset would silently dedupe a real user message). Scheduler fires NEVER write to `handled_updates`. The synthesized message carries an `__solrac_scheduled` field with `{name, maxCostUsd}` that `main.ts::makeRunTurn` extracts and propagates into the runner's `AgentRunInput.scheduledTaskName` / `OllamaRunInput.scheduledTaskName`. The audit row gets `origin='scheduled'` + `task_name=`; cost cap, allowlist gate, and policy hooks all apply uniformly to user-typed and scheduled paths. **Engine-prefix mapping.** When a task's `engine` differs from `config.defaultEngine`, the scheduler prepends the matching prefix (`@` for primary, `!` for secondary) onto the message text. The existing `parseEnginePrefix` in `main.ts` then routes to the right runner, so the scheduler reuses one engine-routing path instead of building its own. `engine: ollama` is rejected at parse on Claude-default deploys (PR-B removed the `>` prefix; Ollama is reachable only as the deploy default). -**Catch-up policy.** `every` and `daily_at` default to `catch_up: true`; if the missed window is in the past at boot, the task fires once (NOT N times for N missed windows). `at` defaults to `catch_up: false`; an `at ` task is marked `one_off_consumed=1` without firing. `boot_catch_up_jitter_s` smears boot fires across a random window so 12 daily tasks don't all hit the model at once. +**Catch-up policy.** `cron` defaults to `catch_up: true`; if `last_run_at` is set and the next cron fire after it is in the past at boot, the task fires ONCE (NOT N times for N missed slots). Never-run tasks (no `last_run_at`) do not boot-fire — cron is anchored, not stateful. `at` defaults to `catch_up: false`; an `at ` task is marked `one_off_consumed=1` without firing. `boot_catch_up_jitter_s` smears boot fires across a random window so 12 daily tasks don't all hit the model at once. **Per-task `max_cost_usd`** (Claude tiers only, silently ignored on Ollama). Pre-flight check: if `SUM(cost_usd)` for THIS task in past 1 hour ≥ cap, the fire is skipped and a denial audit row is written with `error_message = "task_cost_cap: …"`. The cap is **inter-fire**: a single fire's cost is never aborted mid-turn (cost only arrives at end-of-turn from the SDK). diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2296b3b..25b967a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -30,8 +30,9 @@ Every Solrac knob is an environment variable, validated and frozen at boot by `s | `OLLAMA_MAX_TOOL_ITERATIONS` | no | `8` | positive int | Hard ceiling on tool-loop rounds per turn. Loop detector fires earlier on duplicate calls; this is the runaway-loop backstop. Iteration cap surfaces as `⚠️ stopped after N tool iterations`. | | `SOLRAC_SKILLS_ENABLED` | no | `false` | boolean | Master switch for operator-defined skills. When `true`, Solrac discovers `SKILL.md` files under `SOLRAC_SKILLS_DIR` at boot and exposes each as a `/` slash command. | | `SOLRAC_SKILLS_DIR` | no | `./skills` | path | Directory scanned for `/SKILL.md` files. Resolved relative to `SOLRAC_HOME`. Loaded ONCE at boot — edit files and restart. See [USAGE.md#skills-operator-defined-commands](./USAGE.md#skills-operator-defined-commands). | -| `SOLRAC_TASKS_ENABLED` | no | `false` | boolean | Master switch for scheduled tasks. When `true`, Solrac discovers `TASK.md` files under `SOLRAC_TASKS_DIR` at boot and fires each on its configured schedule (`every `, `daily_at HH:MM`, `at `). Fires synthesize updates through the existing turn queue, so cost caps + allowlist gate + policy hooks all apply automatically. | +| `SOLRAC_TASKS_ENABLED` | no | `false` | boolean | Master switch for scheduled tasks. When `true`, Solrac discovers `TASK.md` files under `SOLRAC_TASKS_DIR` at boot and fires each on its configured schedule (5-field unix `cron:` or absolute `at:`). Fires synthesize updates through the existing turn queue, so cost caps + allowlist gate + policy hooks all apply automatically. | | `SOLRAC_TASKS_DIR` | no | `./tasks` | path | Directory scanned for `/TASK.md` files. Resolved relative to `SOLRAC_HOME`. Loaded ONCE at boot — edit files and restart. See [USAGE.md#scheduled-tasks](./USAGE.md#scheduled-tasks). | +| `TZ` | no | host runtime tz | IANA tz | Default timezone for cron tasks that omit `tz:` in their frontmatter. Set `Environment=TZ=America/Denver` (or your preferred IANA name) in the systemd unit to pin the scheduler's clock predictably across deploys. Per-task `tz:` always wins over `$TZ`. | | `SOLRAC_INTEGRATIONS_ENABLED` | no | `false` | boolean | Master switch for operator + blessed integrations. When `true`, Solrac discovers `/index.ts` modules under `src/integrations-builtin/` (always) and `SOLRAC_INTEGRATIONS_DIR` (operator-owned) at boot, and registers each one's tools as `mcp__solrac__`. **Effective for both Claude tiers (`@`, `!`) and Ollama (when `OLLAMA_TOOLS_ENABLED=true`).** Required `true` when `OLLAMA_TOOLS_ENABLED=true`. See [USAGE.md#integrations](./USAGE.md#integrations). | | `SOLRAC_INTEGRATIONS_DIR` | no | `./integrations` | path | Directory scanned for operator-authored `/index.ts` integration modules. Resolved relative to launch cwd; can also be absolute (e.g. `~/.solrac/integrations`). Loaded ONCE at boot — edit files and restart. | | `NOTION_API_KEY` | when `notion` integration in use | — | string | Notion internal-integration secret (`secret_…`). Consumed by the blessed `notion` integration only — not validated in `config.ts`. Boot probes `GET /v1/users/me` (3s timeout); failure → integration self-gates to zero tools, solrac boots normally. **Scrubbed** from the SDK-spawned `claude` subprocess env by `agent.ts::sanitizedSubprocessEnv` (the integration handler runs in solrac's main process; the subprocess never needs the token). See [USAGE.md#notion-single-token-notion-workspace-opt-in-dep](./USAGE.md#notion--single-token-notion-workspace-opt-in-dep). | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index ac79690..4af0ccf 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -82,7 +82,9 @@ Terms that recur across Solrac's codebase and docs. Alphabetical. **skill tool** — A Solrac skill with `tool: true` frontmatter, exposed to the Ollama agent's tool catalog as `mcp__solrac__skills__` (wire format on Ollama: `skills__`). The model decides when to call it from natural language; the tool description is `skill.description`; input schema is `{ args: string }`. Phase 1 restriction: requires `tier: ollama` (free, no cross-engine cost surprises). Auto-allow permission tier; cost cap is the backstop. Built by `skill-tools.ts::buildSkillTools`. Per-turn context (chatId, fromId, updateId, parentAuditId) propagates via `node:async_hooks::AsyncLocalStorage` (`skillToolCtx`) — the SDK tool-handler signature `(args, extra)` leaves no slot for chat context, and concurrent turns require race-free isolation. Audit row tagged `origin='tool_call'` to distinguish from operator-typed slash invocations. -**scheduled task (operator-defined)** — A `TASK.md` file under `$SOLRAC_TASKS_DIR//` that fires a prompt on a schedule (`every `, `daily_at HH:MM`, `at `) into a configured chat. Loaded ONCE at boot by `scheduler.ts::loadTasksSync`; tick driver runs `setInterval(60_000)`. Synthesizes `Update` objects with negative `update_id`s that ride the existing turn queue, so cost caps + allowlist + policy hooks all apply uniformly. Audit row tagged `origin='scheduled'` with `task_name=`. Persisted state (`last_run_at`, `one_off_consumed`) lives in the `scheduled_tasks` table. Disabled by default (`SOLRAC_TASKS_ENABLED=false`). See [USAGE.md#scheduled-tasks](./USAGE.md#scheduled-tasks). +**scheduled task (operator-defined)** — A `TASK.md` file under `$SOLRAC_TASKS_DIR//` that fires a prompt on a schedule (5-field unix `cron:` or absolute `at:`) into a configured chat. Loaded ONCE at boot by `scheduler.ts::loadTasksSync`; tick driver runs `setInterval(60_000)`. Synthesizes `Update` objects with negative `update_id`s that ride the existing turn queue, so cost caps + allowlist + policy hooks all apply uniformly. Audit row tagged `origin='scheduled'` with `task_name=`. Persisted state (`last_run_at`, `one_off_consumed`) lives in the `scheduled_tasks` table. Disabled by default (`SOLRAC_TASKS_ENABLED=false`). See [USAGE.md#scheduled-tasks](./USAGE.md#scheduled-tasks). + +**cron expression** — A 5-field unix cron string used by the `cron:` frontmatter field on a scheduled task: `minute hour day-of-month month day-of-week`. Standard semantics — ranges (`12-18`), lists (`0,15`), step values (`*/30`), wildcards (`*`); day-of-week `1-5` means Mon–Fri. Predefined aliases (`@daily`, `@hourly`) and non-5-field variants are rejected at parse to keep the grammar one-shape. Validated and iterated by `cron-parser@5.5.0` (exact-pinned); tz + DST handling delegated to it. The expression evaluates against the task's `tz:` (default: `$TZ` env / host runtime tz). See [USAGE.md#schedule-grammar](./USAGE.md#schedule-grammar) and `man 5 crontab`. **audit `origin`** — Column on the `audit` table distinguishing the source of a row: `'user'` (operator typed), `'scheduled'` (scheduler fired), `'tool_call'` (Ollama agent invoked a tool-eligible skill), or `'system'` (rejection / queue-full row). All four share the table; `WHERE origin IN (...)` is the surface-aware filter. See [SCHEMA.md#audit](./SCHEMA.md#audit). diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 3bc04cf..fbbc30c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -77,6 +77,17 @@ ReadWritePaths=/opt/solrac/data - `TimeoutStopSec=90` — pairs with lifecycle's 60s drain budget; 30s of slack before SIGKILL. - `ReadWritePaths` — sqlite + WAL + PID file + workspaces all live under `data/`. Everything else is read-only or denied via `ProtectSystem=strict`. +### Pin the scheduler timezone + +If you use scheduled tasks with `cron:` expressions that omit `tz:`, the scheduler falls back to `$TZ` (env) and then to the host's runtime tz. Production hosts often have `TZ` unset (or `UTC`) — pin it explicitly in the unit so the scheduler's clock matches your intent across reboots and host migrations: + +```ini +[Service] +Environment=TZ=America/Denver +``` + +Per-task `tz:` in `TASK.md` always wins over `$TZ`, so this only affects tasks that opt to inherit. Setting it also fixes the timezone-naive output of `date` and other shell tools in skill/task bodies that read the wall clock. + ### Customizing paths The unit assumes: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 782563d..b0aba59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -332,7 +332,7 @@ Two distinct axes — kept separate because they have different cost-exposure sh **Status:** Shipped (Phase 1 + Phase 2). See `src/scheduler.ts` and [USAGE.md#scheduled-tasks](./USAGE.md#scheduled-tasks). -Operator-authored `TASK.md` files under `$SOLRAC_TASKS_DIR//` fire on a per-task schedule (`every `, `daily_at HH:MM`, `at `). Fires synthesize updates through the existing turn queue, so cost caps + allowlist + policy hooks all apply automatically. `/tasks` lists loaded tasks; `/tasks run ` manual-triggers. Cron expressions, timezones, and audit-only (no Telegram output) modes deferred to Phase 3. +Operator-authored `TASK.md` files under `$SOLRAC_TASKS_DIR//` fire on a per-task schedule (5-field unix `cron:` or absolute `at:`, with optional `tz:`). Fires synthesize updates through the existing turn queue, so cost caps + allowlist + policy hooks all apply automatically. `/tasks` lists loaded tasks; `/tasks run ` manual-triggers. Cron grammar + tz landed in v0.5.0 (replacing the prior `every`/`daily_at` shape); multi-trigger fanout and conditional-skip modes deferred. --- diff --git a/docs/USAGE.md b/docs/USAGE.md index 60ec238..9d09e1e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -449,26 +449,88 @@ Each task is a `TASK.md` file in `$SOLRAC_TASKS_DIR//` (default `./tasks`) --- name: morning_digest description: Weekday morning Notion ticket digest. -schedule: daily_at 13:00 +cron: "0 9 * * 1-5" +tz: America/Denver --- You are running as the morning digest. List any Notion tickets in "In progress" with no update in the last 48h. If there are none, reply "All clear." ``` -Drop this file at `./tasks/morning_digest/TASK.md`, set `SOLRAC_TASKS_ENABLED=true`, restart. The prompt fires every UTC day at 13:00 into the operator's DM. +Drop this file at `./tasks/morning_digest/TASK.md`, set `SOLRAC_TASKS_ENABLED=true`, restart. The prompt fires every weekday at 09:00 America/Denver into the operator's DM — assuming the operator has `/start`-ed the bot. If they haven't, the DM is dropped silently by Telegram; set `chat_id:` explicitly to avoid this. See [Where the reply lands](#where-the-reply-lands) below. ### Schedule grammar -One of (mutually exclusive): +Exactly one of `cron:` or `at:` must be present. -| Form | Meaning | Example | -|------|---------|---------| -| `every ` | Periodic interval from `last_run_at`. Units: `s`, `m`, `h`, `d`. | `every 1h`, `every 24h`, `every 30m` | -| `daily_at HH:MM` | Anchored daily fire (UTC). | `daily_at 09:00`, `daily_at 23:30` | -| `at ` | Single fire at an absolute timestamp. Must include timezone (`Z` or `±HH:MM`). | `at 2026-05-15T13:00:00Z` | +**`cron:`** — 5-field unix cron expression: `minute hour day-of-month month day-of-week`. Standard semantics: ranges (`12-18`), lists (`0,15,30,45`), steps (`*/30`), wildcards (`*`). Day-of-week `1-5` is Mon–Fri (`0`/`7` accept as Sun). Predefined aliases (`@daily`, `@hourly`) are **not** supported — use the 5-field equivalent. -**Minimum interval for `every`:** 5 minutes for Claude tiers (cost-runaway guard); 1 minute for Ollama. +**`tz:`** — optional IANA timezone (e.g., `America/Denver`, `Europe/Berlin`, `UTC`). Cron expressions evaluate against this wall-clock and DST shifts are handled by `cron-parser`: spring-forward skips the non-existent hour to the next valid moment; fall-back fires once, not twice. Defaults to `$TZ` env var, otherwise the host's runtime tz. + +| Expression | Meaning | +|---|---| +| `cron: "0 * * * *"` | Top of every hour | +| `cron: "*/30 * * * *"` | Every 30 minutes | +| `cron: "0 9 * * *"` | Daily at 09:00 (in `tz`) | +| `cron: "0 9 * * 1-5"` | Weekdays at 09:00 | +| `cron: "*/30 12-18 * * 1-5"` | Every 30 min during 12:00–18:30 weekdays (14 fires/day; cron's `12-18` is inclusive of hour 18) | +| `cron: "0 0 1 * *"` | First of every month at midnight | + +**`at:`** — single absolute fire. Must be ISO8601 with a `Z` suffix or explicit `±HH:MM` offset (timezone-naive strings are rejected). + +```yaml +at: 2026-06-01T09:00:00-06:00 +``` + +**Minimum interval (Claude tiers):** 5 minutes. The parser inspects the first 5 fire times of every cron expression at load time and rejects the task if any gap falls below the tier floor. So `* * * * *` is rejected on `engine: primary` / `secondary` but accepted on `engine: ollama` (Ollama's floor is 1 minute). + +**Anchored vs drifting.** Cron is anchored: `0 * * * *` always fires at `:00` regardless of when Solrac last started. A mid-window restart at 14:13 with this expression fires next at 15:00, not 15:13. This is a behavior change from the pre-cron `every 1h` grammar, which drifted from `last_run_at`. + +**Cron does not "catch up" first-deploy.** A fresh task at 14:00 with `0 9 * * *` waits until tomorrow 09:00 — not today's 09:00, not now. Cron is stateless: it fires at its anchors, period. If you want a one-time-now fire, add a sibling task with `at:`. Catch-up after restart (when `last_run_at` exists) still works: a missed window fires once at the next valid moment. + +### Where the reply lands + +`chat_id:` is the integer Telegram chat the scheduler synthesizes its update into. Omit it and Solrac falls back to the operator's allowlisted user id — a DM to you. + +**DM gotcha.** Telegram silently drops bot DMs to any account that hasn't `/start`-ed the bot at least once. If you're configuring Solrac on a new account and skip `chat_id:`, scheduled fires will appear to do nothing — the audit log shows the turn fired and replied, but Telegram swallows the outbound message. Either `/start` the bot from the operator account once, or set `chat_id:` explicitly to a chat you know the bot can reach. + +**Finding a chat_id:** + +For a **DM**, your `chat_id` equals your Telegram user id — the exact value already in your `.env` as `ALLOWLIST_BOOTSTRAP`. Just copy it. + +```sh +grep ALLOWLIST_BOOTSTRAP .env # your user id == your DM chat_id +``` + +For a **group** or channel, `chat_id` is a negative integer (e.g., `-100123456789`). Send at least one message into the target chat with the bot present, then query the audit table: + +```sh +sqlite3 data/solrac.sqlite \ + "SELECT DISTINCT chat_id, from_id FROM audit ORDER BY started_at DESC LIMIT 10" +``` + +```yaml +chat_id: 123456789 # DM to user 123456789 +chat_id: -100123456789 # group chat (note the leading minus) +``` + +The bot must already be in a target group; the allowlist gate matches on the *sender's* `from.id`, so any operator-id `from.id` can fire a scheduled prompt into any `chat_id` the bot has access to. Pick a chat you don't actively type in — a scheduled fire waits behind any user turn already running in the same chat (per-chat KeyedMutex), so colocating with your live conversation introduces unpredictable timing. + +### Migration from `schedule:` (pre-0.5.0) + +The `schedule:` field was replaced by `cron:` / `at:` in v0.5.0. Map old TASK.md files using this table: + +| Old `schedule:` | New | Notes | +|---|---|---| +| `every 1m` | `cron: "* * * * *"` | Ollama only (Claude floor 5m) | +| `every 5m` | `cron: "*/5 * * * *"` | | +| `every 30m` | `cron: "*/30 * * * *"` | | +| `every 1h` | `cron: "0 * * * *"` | **Behavior change**: anchored to `:00` instead of drifting from `last_run_at` | +| `every 2h` | `cron: "0 */2 * * *"` | | +| `every 1d` | `cron: "0 0 * * *"` | | +| `daily_at 09:00` (was UTC) | `cron: "0 9 * * *"` + `tz: UTC` | `tz` required if your host tz isn't UTC | +| `daily_at 09:00` (host tz) | `cron: "0 9 * * *"` | Uses host-tz default | +| `at 2026-06-01T09:00:00Z` | `at: 2026-06-01T09:00:00Z` | Field name change only | ### Frontmatter reference @@ -476,10 +538,12 @@ One of (mutually exclusive): |-----|----------|---------|-------| | `name` | yes | — | `[a-z0-9_]{1,32}` (Telegram bot-command shape). Lowercased automatically. | | `description` | yes | — | ≤256 chars. Shown in `/tasks`. | -| `schedule` | yes | — | See grammar above. | +| `cron` | one of | — | 5-field unix cron expression. Mutually exclusive with `at`. | +| `at` | one of | — | ISO8601 absolute timestamp with explicit tz suffix. Mutually exclusive with `cron`. | +| `tz` | no | `$TZ` env / host tz | IANA timezone name. Affects `cron` evaluation only. | | `chat_id` | no | first allowlist entry | Where the reply lands. Use a negative integer for group chats. | | `engine` | no | `config.defaultEngine` | `primary` (Sonnet, `@`), `secondary` (Opus, `!`), or `ollama` (free, default-engine deploys only). | -| `catch_up` | no | `true` for periodic, `false` for `at` | If Solrac was down through a missed window, fire once on next boot. Set to `false` to skip catch-up fires. | +| `catch_up` | no | `true` for `cron`, `false` for `at` | If Solrac was down through a missed window, fire once on next boot. Set to `false` to skip catch-up fires. | | `enabled` | no | `true` | Set `false` to pause without deleting. | | `max_cost_usd` | no | unset | Per-task hourly cap (Claude tiers only). Pre-flight skip when `SUM(cost_usd)` for this task in past 1 hour ≥ cap. Silently ignored on Ollama. | | `boot_catch_up_jitter_s` | no | `0` | Stagger boot catch-up fires by `random(0, N)` seconds so 12 daily tasks don't pile up simultaneously on restart. | diff --git a/examples/tasks/morning-digest/TASK.md b/examples/tasks/morning-digest/TASK.md index b5a3f46..2caaf3b 100644 --- a/examples/tasks/morning-digest/TASK.md +++ b/examples/tasks/morning-digest/TASK.md @@ -1,7 +1,8 @@ --- name: morning_digest description: Weekday morning Notion ticket digest (DMs operator). -schedule: daily_at 13:00 +cron: "0 9 * * 1-5" +tz: America/Denver catch_up: true enabled: true boot_catch_up_jitter_s: 30 diff --git a/examples/tasks/weekly-pr-review/TASK.md b/examples/tasks/weekly-pr-review/TASK.md index bcb1a9e..8130ea7 100644 --- a/examples/tasks/weekly-pr-review/TASK.md +++ b/examples/tasks/weekly-pr-review/TASK.md @@ -1,7 +1,8 @@ --- name: weekly_pr_review description: Monday PR review summary; escalates to Opus for nuanced reasoning. -schedule: every 7d +cron: "0 9 * * 1" +tz: America/Denver engine: secondary catch_up: true max_cost_usd: 0.50 diff --git a/package-lock.json b/package-lock.json index 6727923..a63a42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "solrac", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solrac", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.119", "@notionhq/client": "^5.20.0", + "cron-parser": "5.5.0", "marked": "^18.0.3", "zod": "^4.4.3" }, @@ -486,6 +487,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1140,6 +1153,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/marked": { "version": "18.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", diff --git a/package.json b/package.json index 012d3ba..cab737e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solrac", - "version": "0.4.0", + "version": "0.5.0", "description": "Self-hosted Claude-Code-style agent over Telegram (Bun runtime)", "license": "MIT", "author": "Carlos Justiniano ", @@ -54,6 +54,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.119", "@notionhq/client": "^5.20.0", + "cron-parser": "5.5.0", "marked": "^18.0.3", "zod": "^4.4.3" } diff --git a/src/commands.test.ts b/src/commands.test.ts index 5e28a0c..986d0d3 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1174,7 +1174,8 @@ describe("runCommand /tasks", () => { body: "Run the digest", chatId: null, engine: "ollama" as const, - spec: { kind: "every" as const, ms: 3_600_000 }, + spec: { kind: "cron" as const, expr: "0 * * * *" }, + tz: "UTC", catchUp: true, enabled: true, maxCostUsd: null, @@ -1190,11 +1191,9 @@ describe("runCommand /tasks", () => { await runCommand(h.deps, fakeMsg("/tasks"), { kind: "tasks_list" }, 1); const text = h.tg.sent[0]!.text; expect(text).toContain("morning_digest"); - expect(text).toContain("every 1h"); + expect(text).toContain("cron: 0 * * * * (UTC)"); expect(text).toContain("ollama"); - // Next-fire rendering: never-run task → fire on next tick → "(now)" or - // "( late)" depending on the millisecond clock the test ran at. - // Both forms include "next:" — that's the contract. + // Next-fire rendering: contract is that "next:" appears. expect(text).toContain("next:"); }); @@ -1207,6 +1206,7 @@ describe("runCommand /tasks", () => { chatId: null, engine: "ollama" as const, spec: { kind: "at" as const, atMs: Date.now() - 86_400_000 }, + tz: "UTC", catchUp: false, enabled: true, maxCostUsd: null, @@ -1239,7 +1239,8 @@ describe("runCommand /tasks", () => { body: "noop", chatId: null, engine: "ollama" as const, - spec: { kind: "every" as const, ms: 3_600_000 }, + spec: { kind: "cron" as const, expr: "0 * * * *" }, + tz: "UTC", catchUp: true, enabled: false, maxCostUsd: null, @@ -1260,37 +1261,33 @@ describe("runCommand /tasks", () => { test("/tasks renders 'in ' for future fire", async () => { const h = await makeHarness(); - const lastRun = Date.now() - 30 * 60 * 1000; // 30 min ago + // Use a one-off `at` task scheduled 30 min in the future — deterministic + // future-fire rendering regardless of where the wall clock falls relative + // to the cron anchor. + const futureMs = Date.now() + 30 * 60 * 1000; const fakeTask = { - name: "hourly", - description: "Hourly task", + name: "later", + description: "One-off in 30 min", body: "Run", chatId: null, engine: "ollama" as const, - spec: { kind: "every" as const, ms: 60 * 60 * 1000 }, // 1h - catchUp: true, + spec: { kind: "at" as const, atMs: futureMs }, + tz: "UTC", + catchUp: false, enabled: true, maxCostUsd: null, bootCatchUpJitterS: 0, - sourcePath: "/tasks/hourly/TASK.md", + sourcePath: "/tasks/later/TASK.md", sourceHash: "abc", }; h.deps.taskRegistry = { all: [fakeTask], - get: (n: string) => (n === "hourly" ? fakeTask : undefined), + get: (n: string) => (n === "later" ? fakeTask : undefined), size: () => 1, }; - h.db.upsertTaskMetadata({ name: "hourly", sourcePath: "/p", sourceHash: "h" }); - h.db.markTaskFired({ - name: "hourly", - lastRunAt: lastRun, - lastAuditId: 1, - lastStatus: "fired", - }); + h.db.upsertTaskMetadata({ name: "later", sourcePath: "/p", sourceHash: "h" }); await runCommand(h.deps, fakeMsg("/tasks"), { kind: "tasks_list" }, 1); const text = h.tg.sent[0]!.text; - // ~30m remain in the hour. Allow either "30m" or "29m" in case the clock - // rolled. expect(text).toMatch(/\(in \d+m\)/); }); diff --git a/src/commands.ts b/src/commands.ts index 53eb65d..8fbb80d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1852,15 +1852,31 @@ async function runTasksList( updateId: number, ): Promise { if (!deps.taskRegistry || deps.taskRegistry.size() === 0) { - const reply = - "📅 tasks: scheduler disabled or no TASK.md files loaded.\n" + - "Set SOLRAC_TASKS_ENABLED=true and drop TASK.md files into $SOLRAC_TASKS_DIR."; - await sendOrLog(deps.tg, msg.chat.id, reply, "cmd.tasks_reply_failed"); + const md = + "📅 **tasks**: scheduler disabled or no TASK.md files loaded.\n\n" + + "Set `SOLRAC_TASKS_ENABLED=true` and drop TASK.md files into `$SOLRAC_TASKS_DIR`."; + await sendOrLog(deps.tg, msg.chat.id, mdToTelegramHtml(md), "cmd.tasks_reply_failed", md); writeSystemAudit(deps, msg, updateId, "tasks_disabled", "ok"); return; } const now = Date.now(); - const lines = [`📅 scheduled tasks (${deps.taskRegistry.size()})`]; + // Author in markdown, derive Telegram HTML via `mdToTelegramHtml`. The web + // transport receives the markdown source via `markdownSource` so the browser + // gets `
      `-rendered list items with `
      `-broken continuations — without + // this dual-render path single `\n`s collapse and the whole listing renders + // on one line. + // + // Markdown shape: + // - Top-line list item: `- **** [(flags)]` + // - Continuation lines indented two spaces (CommonMark list-item content) + // - Trailing ` ` (two spaces) on each continuation forces `
      ` so the + // four sub-lines render as a stack, not a paragraph blob. + // - Cron expression wrapped in backticks — keeps literal `*` from being + // interpreted as emphasis, and renders as `` on both transports. + const mdLines: string[] = [ + `📅 **scheduled tasks** (${deps.taskRegistry.size()})`, + "", + ]; for (const t of deps.taskRegistry.all) { const state = deps.db.getTaskState(t.name); const last = state?.lastRunAt @@ -1871,15 +1887,15 @@ async function runTasksList( const consumed = state?.oneOffConsumed ? " (consumed)" : ""; const sched = formatScheduleSpec(t); const next = formatNextFire(t, state, now); - lines.push( - `· ${htmlEscapeText(t.name)}${enabled}${consumed}\n` + - ` schedule: ${htmlEscapeText(sched)} · engine: ${t.engine}\n` + - ` last: ${last} · ${htmlEscapeText(lastStatus)}\n` + - ` next: ${htmlEscapeText(next)}`, + mdLines.push( + `- **${t.name}**${enabled}${consumed} \n` + + ` schedule: \`${sched}\` · engine: ${t.engine} \n` + + ` last: ${last} · ${lastStatus} \n` + + ` next: ${next}`, ); } - const reply = lines.join("\n"); - await sendOrLog(deps.tg, msg.chat.id, reply, "cmd.tasks_reply_failed"); + const md = mdLines.join("\n"); + await sendOrLog(deps.tg, msg.chat.id, mdToTelegramHtml(md), "cmd.tasks_reply_failed", md); writeSystemAudit(deps, msg, updateId, "tasks_listed", "ok"); } @@ -1921,17 +1937,8 @@ async function runTasksRun( function formatScheduleSpec(t: ScheduledTask): string { const s = t.spec; - if (s.kind === "every") { - const ms = s.ms; - if (ms % 86_400_000 === 0) return `every ${ms / 86_400_000}d`; - if (ms % 3_600_000 === 0) return `every ${ms / 3_600_000}h`; - if (ms % 60_000 === 0) return `every ${ms / 60_000}m`; - return `every ${ms / 1000}s`; - } - if (s.kind === "daily_at") { - const hh = String(s.hourUtc).padStart(2, "0"); - const mm = String(s.minuteUtc).padStart(2, "0"); - return `daily_at ${hh}:${mm}`; + if (s.kind === "cron") { + return `cron: ${s.expr} (${t.tz})`; } return `at ${new Date(s.atMs).toISOString()}`; } @@ -1954,7 +1961,7 @@ function formatNextFire( ): string { if (state?.oneOffConsumed) return "consumed"; if (!task.enabled) return "—"; - const due = nextRunAt(task.spec, state?.lastRunAt ?? null, now); + const due = nextRunAt(task, state?.lastRunAt ?? null, now); if (due === null) return "—"; const delta = due - now; const abs = formatAbsoluteUtc(due); diff --git a/src/scheduler.test.ts b/src/scheduler.test.ts index 74e38df..e6f486c 100644 --- a/src/scheduler.test.ts +++ b/src/scheduler.test.ts @@ -1,10 +1,12 @@ /** - * @fileoverview Unit tests for the scheduler — parser, schedule grammar, - * nextRunAt, catch-up logic, and the boot tick driver. - * @proves Frontmatter parsing + validation, schedule grammar coverage, - * pure-function nextRunAt across all kinds, catch-up policy by - * kind × {never run, just ran, missed window}, queue-full audit - * row, max_cost_usd pre-flight gate. + * @fileoverview Unit tests for the scheduler — cron grammar, parseTaskFile, + * nextRunAt (cron + at), catch-up logic, and the boot tick + * driver. + * @proves Frontmatter parsing + validation, 5-field cron grammar coverage, + * tz handling including DST spring-forward/fall-back, weekday filter, + * min-interval guard via next-5-fires, pure-function nextRunAt across + * both kinds, catch-up policy, queue-full audit row, max_cost_usd + * pre-flight gate. * * The scheduler fires synthetic Telegram updates through the existing turn * queue; tests use a fake `enqueue` callback rather than wiring a real @@ -14,8 +16,7 @@ * * Cross-references: * - scheduler.ts — implementation - * - skills.test.ts — parser test pattern this mirrors - * - PLAN.md §10 — test surface the user signed off on + * - PLAN.md Phase 4 — test surface the user signed off on */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -29,11 +30,9 @@ import { getScheduledContext, loadTasksSync, nextRunAt, - parseScheduleSpec, parseTaskFile, startScheduler, - type ScheduleSpec, - type SchedulerHandle, + validateCronExpr, type Task, type TaskRegistry, } from "./scheduler.ts"; @@ -82,178 +81,173 @@ function writeTask(root: string, name: string, content: string): string { const MINIMAL = `--- name: digest description: A digest task. -schedule: every 1h +cron: "0 * * * *" +tz: UTC --- You are running as the digest. Reply with "ok". `; // --------------------------------------------------------------------------- -// parseScheduleSpec — grammar coverage +// validateCronExpr — grammar coverage // --------------------------------------------------------------------------- -describe("parseScheduleSpec — every", () => { - test("every 1h → 3600000ms", () => { - const spec = parseScheduleSpec("every 1h"); - expect(spec.kind).toBe("every"); - if (spec.kind === "every") expect(spec.ms).toBe(3_600_000); - }); - - test("every 30m → 1800000ms", () => { - const spec = parseScheduleSpec("every 30m"); - if (spec.kind === "every") expect(spec.ms).toBe(1_800_000); - }); - - test("every 24h → 86400000ms", () => { - const spec = parseScheduleSpec("every 24h"); - if (spec.kind === "every") expect(spec.ms).toBe(86_400_000); - }); - - test("every 1d → 86400000ms", () => { - const spec = parseScheduleSpec("every 1d"); - if (spec.kind === "every") expect(spec.ms).toBe(86_400_000); +describe("validateCronExpr — valid", () => { + test("hourly anchored", () => { + expect(validateCronExpr("0 * * * *", "UTC")).toBe("0 * * * *"); }); - test("every 30s → 30000ms", () => { - const spec = parseScheduleSpec("every 30s"); - if (spec.kind === "every") expect(spec.ms).toBe(30_000); + test("every-30m within window on weekdays", () => { + expect(validateCronExpr("*/30 12-18 * * 1-5", "America/Denver")).toBe( + "*/30 12-18 * * 1-5", + ); }); - test("every 0h rejected (not positive)", () => { - expect(() => parseScheduleSpec("every 0h")).toThrow(/positive integer/); + test("daily at 09:00", () => { + expect(validateCronExpr("0 9 * * *", "UTC")).toBe("0 9 * * *"); }); - test("every -1h rejected", () => { - expect(() => parseScheduleSpec("every -1h")).toThrow(/unrecognized form/); + test("lists and step values", () => { + expect(validateCronExpr("0,15,30,45 * * * *", "UTC")).toBe("0,15,30,45 * * * *"); + expect(validateCronExpr("*/5 * * * *", "UTC")).toBe("*/5 * * * *"); }); - test("every 1y rejected (unknown unit)", () => { - expect(() => parseScheduleSpec("every 1y")).toThrow(/unrecognized form/); + test("trims surrounding whitespace", () => { + expect(validateCronExpr(" 0 * * * * ", "UTC")).toBe("0 * * * *"); }); }); -describe("parseScheduleSpec — daily_at", () => { - test("daily_at 09:00 → hour=9 minute=0", () => { - const spec = parseScheduleSpec("daily_at 09:00"); - expect(spec.kind).toBe("daily_at"); - if (spec.kind === "daily_at") { - expect(spec.hourUtc).toBe(9); - expect(spec.minuteUtc).toBe(0); - } - }); - - test("daily_at 23:59 valid", () => { - const spec = parseScheduleSpec("daily_at 23:59"); - if (spec.kind === "daily_at") { - expect(spec.hourUtc).toBe(23); - expect(spec.minuteUtc).toBe(59); - } - }); - - test("daily_at 24:00 rejected", () => { - expect(() => parseScheduleSpec("daily_at 24:00")).toThrow(/hour out of range/); +describe("validateCronExpr — rejection", () => { + test("empty string rejected", () => { + expect(() => validateCronExpr("", "UTC")).toThrow(/empty/); }); - test("daily_at 09:60 rejected", () => { - expect(() => parseScheduleSpec("daily_at 09:60")).toThrow(/minute out of range/); + test("@daily rejected with helpful message", () => { + expect(() => validateCronExpr("@daily", "UTC")).toThrow(/predefined aliases/); }); - test("daily_at 9:00 (single-digit hour) accepted", () => { - const spec = parseScheduleSpec("daily_at 9:00"); - if (spec.kind === "daily_at") expect(spec.hourUtc).toBe(9); + test("@hourly rejected", () => { + expect(() => validateCronExpr("@hourly", "UTC")).toThrow(/predefined aliases/); }); -}); -describe("parseScheduleSpec — at", () => { - test("at 2026-05-15T13:00:00Z → atMs is the parsed timestamp", () => { - const spec = parseScheduleSpec("at 2026-05-15T13:00:00Z"); - expect(spec.kind).toBe("at"); - if (spec.kind === "at") { - expect(spec.atMs).toBe(Date.parse("2026-05-15T13:00:00Z")); - } + test("4-field expression rejected (cron-parser would accept)", () => { + expect(() => validateCronExpr("* * * *", "UTC")).toThrow(/exactly 5 fields/); }); - test("at with explicit offset accepted", () => { - const spec = parseScheduleSpec("at 2026-05-15T13:00:00+02:00"); - expect(spec.kind).toBe("at"); + test("6-field expression rejected (cron-parser would treat as seconds)", () => { + expect(() => validateCronExpr("* * * * * *", "UTC")).toThrow(/exactly 5 fields/); }); - test("timezone-naive at rejected", () => { - expect(() => parseScheduleSpec("at 2026-05-15T13:00:00")).toThrow(/timezone-naive/); + test("minute out of range", () => { + expect(() => validateCronExpr("60 * * * *", "UTC")).toThrow(/invalid expression/); }); - test("malformed iso rejected", () => { - expect(() => parseScheduleSpec("at not-a-date Z")).toThrow(/not a valid ISO8601/); - }); -}); - -describe("parseScheduleSpec — invalid", () => { - test("empty rejected", () => { - expect(() => parseScheduleSpec("")).toThrow(); + test("hour out of range", () => { + expect(() => validateCronExpr("* 25 * * *", "UTC")).toThrow(/invalid expression/); }); test("garbage rejected", () => { - expect(() => parseScheduleSpec("randomly")).toThrow(/unrecognized form/); + expect(() => validateCronExpr("not a cron", "UTC")).toThrow(); }); }); // --------------------------------------------------------------------------- -// nextRunAt — pure timestamp computation +// nextRunAt — cron + at, pure timestamp computation // --------------------------------------------------------------------------- -describe("nextRunAt — every", () => { - const spec: ScheduleSpec = { kind: "every", ms: 3_600_000 }; // 1h - - test("never run → fire on next tick (returns now)", () => { - const now = 1_000_000_000; - expect(nextRunAt(spec, null, now)).toBe(now); - }); - - test("just ran → next due is lastRunAt + ms", () => { - const lastRun = 1_000_000_000; - expect(nextRunAt(spec, lastRun, lastRun + 100)).toBe(lastRun + 3_600_000); - }); - - test("missed window (lastRun is older than interval) → next due is in the past, fires immediately", () => { - const lastRun = 1_000_000_000; - const now = lastRun + 7_200_000; // 2h later - const due = nextRunAt(spec, lastRun, now); - expect(due).toBe(lastRun + 3_600_000); - expect(due!).toBeLessThanOrEqual(now); - }); -}); - -describe("nextRunAt — daily_at", () => { - const spec: ScheduleSpec = { kind: "daily_at", hourUtc: 9, minuteUtc: 0 }; - - test("before today's anchor, never run → next due is today's anchor", () => { - const now = Date.UTC(2026, 0, 15, 8, 30); // 08:30 UTC - const due = nextRunAt(spec, null, now); - expect(due).toBe(Date.UTC(2026, 0, 15, 9, 0)); - }); - - test("after today's anchor, never run → next due is today's anchor (catch-up)", () => { - const now = Date.UTC(2026, 0, 15, 14, 0); - const due = nextRunAt(spec, null, now); - expect(due).toBe(Date.UTC(2026, 0, 15, 9, 0)); - }); +function buildTask(spec: Task["spec"], tz = "UTC"): Pick { + return { spec, tz }; +} - test("already fired today → next due is tomorrow's anchor", () => { - const now = Date.UTC(2026, 0, 15, 14, 0); - const lastRun = Date.UTC(2026, 0, 15, 9, 5); - const due = nextRunAt(spec, lastRun, now); - expect(due).toBe(Date.UTC(2026, 0, 16, 9, 0)); +describe("nextRunAt — cron", () => { + test("hourly, never run → returns next future fire (no boot-fire)", () => { + const t = buildTask({ kind: "cron", expr: "0 * * * *" }); + const now = Date.UTC(2026, 4, 18, 14, 13); // 14:13 UTC + const due = nextRunAt(t, null, now); + expect(due).toBe(Date.UTC(2026, 4, 18, 15, 0)); // 15:00 UTC + }); + + test("hourly, just ran at :00 → next fire is +1h", () => { + const t = buildTask({ kind: "cron", expr: "0 * * * *" }); + const lastRun = Date.UTC(2026, 4, 18, 14, 0); + const due = nextRunAt(t, lastRun, lastRun + 100); + expect(due).toBe(Date.UTC(2026, 4, 18, 15, 0)); + }); + + test("hourly, missed window — lastRun 3h ago → due is in the past (catch-up fires once)", () => { + const t = buildTask({ kind: "cron", expr: "0 * * * *" }); + const lastRun = Date.UTC(2026, 4, 18, 11, 0); + const now = Date.UTC(2026, 4, 18, 14, 13); + const due = nextRunAt(t, lastRun, now); + // Next cron fire after lastRun=11:00 is 12:00 — in the past, fires once. + expect(due).toBe(Date.UTC(2026, 4, 18, 12, 0)); + expect(due!).toBeLessThan(now); + }); + + test("weekday filter — Saturday skipped", () => { + // Saturday 2026-05-16 at 14:00 UTC. Expression: every hour on Mon-Fri only. + const t = buildTask({ kind: "cron", expr: "0 * * * 1-5" }); + const now = Date.UTC(2026, 4, 16, 14, 0); // Sat + const due = nextRunAt(t, null, now); + // Next fire is Monday 2026-05-18 00:00 UTC. + expect(due).toBe(Date.UTC(2026, 4, 18, 0, 0)); + }); + + test("tz applied — same expression, different tz → different UTC ms", () => { + const expr = "0 9 * * *"; + const utc = buildTask({ kind: "cron", expr }, "UTC"); + const denver = buildTask({ kind: "cron", expr }, "America/Denver"); + const tokyo = buildTask({ kind: "cron", expr }, "Asia/Tokyo"); + const anchor = Date.UTC(2026, 4, 18, 0, 0); + const dUtc = nextRunAt(utc, null, anchor); + const dDenver = nextRunAt(denver, null, anchor); + const dTokyo = nextRunAt(tokyo, null, anchor); + expect(dUtc).not.toBe(dDenver); + expect(dUtc).not.toBe(dTokyo); + expect(dDenver).not.toBe(dTokyo); + // UTC 09:00 == 2026-05-18T09:00Z. + expect(dUtc).toBe(Date.UTC(2026, 4, 18, 9, 0)); + // Denver 09:00 MDT == 15:00 UTC. + expect(dDenver).toBe(Date.UTC(2026, 4, 18, 15, 0)); + }); + + test("DST spring-forward — 2026-03-08 Denver, `0 2 * * *` skips the non-existent hour", () => { + const t = buildTask({ kind: "cron", expr: "0 2 * * *" }, "America/Denver"); + // Anchor Sat 2026-03-07 03:00 Denver (post-2am fire). Next fire would be + // Sun Mar 8 02:00 Denver — but that hour doesn't exist (DST jumps to 03). + // cron-parser skips to Mon Mar 9 02:00 (deterministic). + const anchor = Date.UTC(2026, 2, 7, 10, 0); // Sat 03:00 MST = 10:00 UTC + const due = nextRunAt(t, anchor, anchor + 1); + // Sun 2026-03-08 03:00 MDT = 09:00 UTC OR Mon Mar 9 02:00 MDT = 08:00 UTC. + // Either is "next valid moment, not crash". Just assert non-null + > anchor. + expect(due).not.toBeNull(); + expect(due!).toBeGreaterThan(anchor); + }); + + test("DST fall-back — 2025-11-02 Denver, `0 1 * * *` fires once on the doubled hour", () => { + const t = buildTask({ kind: "cron", expr: "0 1 * * *" }, "America/Denver"); + // Anchor Sat 2025-11-01 02:00 Denver — past today's fire. + const anchor = Date.UTC(2025, 10, 1, 8, 0); // Sat 02:00 MDT = 08:00 UTC + const first = nextRunAt(t, anchor, anchor + 1); + expect(first).not.toBeNull(); + // The next fire should be Sun Nov 2 01:00 (one of them — not BOTH). + // Anchor that as the new lastRun and ask again. + const second = nextRunAt(t, first, first! + 1); + // Second fire must be Mon Nov 3 01:00 (24h+ after Sun's fire), not the + // duplicated 01:00 MST on the same Sun. + const diff = second! - first!; + expect(diff).toBeGreaterThan(20 * 60 * 60 * 1000); // > 20h, ensuring no double }); }); describe("nextRunAt — at", () => { test("never run → next due is atMs", () => { - const spec: ScheduleSpec = { kind: "at", atMs: 1_500_000_000 }; - expect(nextRunAt(spec, null, 1_000_000_000)).toBe(1_500_000_000); + const t = buildTask({ kind: "at", atMs: 1_500_000_000 }); + expect(nextRunAt(t, null, 1_000_000_000)).toBe(1_500_000_000); }); test("already ran → null (one-off consumed)", () => { - const spec: ScheduleSpec = { kind: "at", atMs: 1_500_000_000 }; - expect(nextRunAt(spec, 1_500_000_000, 2_000_000_000)).toBeNull(); + const t = buildTask({ kind: "at", atMs: 1_500_000_000 }); + expect(nextRunAt(t, 1_500_000_000, 2_000_000_000)).toBeNull(); }); }); @@ -262,23 +256,38 @@ describe("nextRunAt — at", () => { // --------------------------------------------------------------------------- describe("parseTaskFile — valid", () => { - test("minimal task", () => { + test("minimal cron task", () => { const t = parseTaskFile(MINIMAL, "/tmp/TASK.md", { defaultEngine: "ollama" }); expect(t.name).toBe("digest"); expect(t.description).toBe("A digest task."); - expect(t.spec.kind).toBe("every"); + expect(t.spec.kind).toBe("cron"); + if (t.spec.kind === "cron") expect(t.spec.expr).toBe("0 * * * *"); + expect(t.tz).toBe("UTC"); expect(t.engine).toBe("ollama"); // inherits default - expect(t.catchUp).toBe(true); + expect(t.catchUp).toBe(true); // default true for cron expect(t.enabled).toBe(true); expect(t.maxCostUsd).toBeNull(); expect(t.bootCatchUpJitterS).toBe(0); }); + test("tz omitted → falls back to runtime default (non-empty)", () => { + const c = `--- +name: digest +description: x +cron: "0 * * * *" +--- +Body.`; + const t = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + expect(typeof t.tz).toBe("string"); + expect(t.tz.length).toBeGreaterThan(0); + }); + test("explicit engine: primary on ollama-default deploy", () => { const c = `--- name: heavy description: Heavy task. -schedule: every 6h +cron: "0 */6 * * *" +tz: UTC engine: primary --- Body.`; @@ -290,7 +299,8 @@ Body.`; const c = `--- name: opus description: Opus task. -schedule: every 6h +cron: "0 */6 * * *" +tz: UTC engine: secondary --- Body.`; @@ -302,7 +312,8 @@ Body.`; const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC engine: secondary max_cost_usd: 0.25 --- @@ -315,7 +326,8 @@ Body.`; const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC max_cost_usd: 0.25 --- Body.`; @@ -323,22 +335,24 @@ Body.`; expect(t.maxCostUsd).toBeNull(); }); - test("one-off task defaults catch_up to false", () => { + test("one-off task with at: defaults catch_up to false", () => { const c = `--- name: alarm description: x -schedule: at 2026-05-15T13:00:00Z +at: 2026-05-15T13:00:00Z --- Body.`; const t = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); expect(t.catchUp).toBe(false); + expect(t.spec.kind).toBe("at"); }); test("chat_id parsed as integer", () => { const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC chat_id: -100123456789 --- Body.`; @@ -351,75 +365,144 @@ Body.`; const b = parseTaskFile(MINIMAL, "/p", { defaultEngine: "ollama" }); expect(a.sourceHash).toBe(b.sourceHash); }); + + test("America/Denver tz accepted", () => { + const c = `--- +name: stretch +description: x +cron: "*/30 12-18 * * 1-5" +tz: America/Denver +--- +Body.`; + const t = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + expect(t.tz).toBe("America/Denver"); + }); }); describe("parseTaskFile — rejection", () => { - test("missing schedule rejected", () => { + test("missing both cron and at rejected", () => { const c = `--- name: digest description: x --- Body.`; - expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow(/schedule/); + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( + /one of "cron.*or "at.*is required/, + ); }); - test("engine: ollama on primary-default deploy rejected", () => { + test("cron and at both present rejected", () => { const c = `--- name: digest description: x -schedule: every 1h -engine: ollama +cron: "0 * * * *" +at: 2026-05-15T13:00:00Z --- Body.`; - expect(() => parseTaskFile(c, "/p", { defaultEngine: "primary" })).toThrow( - /unreachable when SOLRAC_DEFAULT_ENGINE != ollama/, + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( + /mutually exclusive/, ); }); - test("engine: ollama on secondary-default deploy rejected", () => { + test("invalid IANA tz rejected", () => { const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: Not/A/Timezone +--- +Body.`; + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( + /invalid IANA timezone/, + ); + }); + + test("@daily alias rejected with cron-specific message", () => { + const c = `--- +name: digest +description: x +cron: "@daily" +tz: UTC +--- +Body.`; + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( + /predefined aliases/, + ); + }); + + test("4-field cron expression rejected", () => { + const c = `--- +name: digest +description: x +cron: "0 * * *" +tz: UTC +--- +Body.`; + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( + /exactly 5 fields/, + ); + }); + + test("engine: ollama on primary-default deploy rejected", () => { + const c = `--- +name: digest +description: x +cron: "0 * * * *" +tz: UTC engine: ollama --- Body.`; - expect(() => parseTaskFile(c, "/p", { defaultEngine: "secondary" })).toThrow( - /unreachable/, + expect(() => parseTaskFile(c, "/p", { defaultEngine: "primary" })).toThrow( + /unreachable when SOLRAC_DEFAULT_ENGINE != ollama/, ); }); - test("every <5min on Claude tier rejected", () => { + test("min-interval: `* * * * *` on Claude tier rejected", () => { const c = `--- name: too_fast description: x -schedule: every 1m +cron: "* * * * *" +tz: UTC engine: primary --- Body.`; expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow( - /interval too short/, + /cron interval too tight/, ); }); - test("every 1m on ollama allowed", () => { + test("min-interval: `* * * * *` on Ollama allowed", () => { const c = `--- name: fast_local description: x -schedule: every 1m +cron: "* * * * *" +tz: UTC engine: ollama --- Body.`; const t = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); - expect(t.spec.kind).toBe("every"); - if (t.spec.kind === "every") expect(t.spec.ms).toBe(60_000); + expect(t.spec.kind).toBe("cron"); + }); + + test("min-interval: `*/5 * * * *` on Claude allowed", () => { + const c = `--- +name: every_five +description: x +cron: "*/5 * * * *" +tz: UTC +engine: primary +--- +Body.`; + const t = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + expect(t.spec.kind).toBe("cron"); }); test("max_cost_usd negative rejected", () => { const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC engine: primary max_cost_usd: -1 --- @@ -431,7 +514,8 @@ Body.`; const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC boot_catch_up_jitter_s: -1 --- Body.`; @@ -442,7 +526,8 @@ Body.`; const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC unknownkey: foo --- Body.`; @@ -453,7 +538,8 @@ Body.`; const c = `--- name: digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC --- `; expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow(/body must be non-empty/); @@ -463,11 +549,61 @@ schedule: every 1h const c = `--- name: morning-digest description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC --- Body.`; expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow(/"name" must match/); }); + + test("legacy `schedule:` key rejected as unknown", () => { + const c = `--- +name: digest +description: x +schedule: every 1h +--- +Body.`; + expect(() => parseTaskFile(c, "/p", { defaultEngine: "ollama" })).toThrow(/unknown frontmatter/); + }); +}); + +// --------------------------------------------------------------------------- +// Integration — full-weekday simulation +// --------------------------------------------------------------------------- + +describe("nextRunAt — full-day fire count", () => { + test("*/30 12-18 * * 1-5 Denver on Monday → 14 fires", () => { + // Note: cron `12-18` includes hour 18 (inclusive), so `*/30` yields + // 18:00 AND 18:30. The full 7-hour window × 2 fires/hour = 14 fires. + // Operators wanting exactly 13 fires ending at 18:00 need multi-trigger + // (PLAN OQ#5, deferred). + const t = buildTask({ kind: "cron", expr: "*/30 12-18 * * 1-5" }, "America/Denver"); + let last: number | null = null; + const startMs = Date.UTC(2026, 4, 18, 6, 0); // Mon 2026-05-18 00:00 MDT + const endMs = Date.UTC(2026, 4, 19, 6, 0); // Tue 2026-05-19 00:00 MDT + let n = 0; + let cursor = startMs; + while (true) { + const due = nextRunAt(t, last, cursor); + if (due === null || due >= endMs) break; + n++; + last = due; + cursor = due + 1; + } + expect(n).toBe(14); + }); + + test("*/30 12-18 * * 1-5 Denver on Saturday → 0 fires", () => { + const t = buildTask({ kind: "cron", expr: "*/30 12-18 * * 1-5" }, "America/Denver"); + const startMs = Date.UTC(2026, 4, 16, 6, 0); // Sat 2026-05-16 00:00 MDT + const endMs = Date.UTC(2026, 4, 17, 6, 0); // Sun 00:00 MDT + const due = nextRunAt(t, null, startMs); + // First fire after Saturday midnight must be either later that Saturday + // (nope — weekday filter) or in the following Monday window. Confirm + // it's NOT inside the Sat→Sun window. + expect(due).not.toBeNull(); + expect(due!).toBeGreaterThanOrEqual(endMs); + }); }); // --------------------------------------------------------------------------- @@ -550,17 +686,16 @@ function singleTaskRegistry(task: Task): TaskRegistry { }); } -const FROZEN_NOW = 1_700_000_000_000; // 2023-11 — predictable test clock +const FROZEN_NOW = Date.UTC(2026, 4, 18, 14, 13); // Mon 2026-05-18 14:13 UTC describe("startScheduler — boot fire", () => { - test("every 1h, never run → fires on boot tick", async () => { + test("cron, never run → does NOT boot-fire (cron is anchored, not stateful)", async () => { const db = await freshDb(); const queue = newFakeQueue(); const task = parseTaskFile(MINIMAL, "/p/TASK.md", { defaultEngine: "ollama" }); const registry = singleTaskRegistry(task); - let intervalFn: (() => void) | null = null; - const handle: SchedulerHandle = startScheduler({ + const handle = startScheduler({ db, registry, enqueue: queue.enqueue, @@ -568,35 +703,123 @@ describe("startScheduler — boot fire", () => { defaultEngine: "ollama", defaultChatId: 100, now: () => FROZEN_NOW, + setInterval: () => 0, + clearInterval: () => {}, + }); + + expect(queue.enqueued.length).toBe(0); + handle.stop(); + }); + + test("cron, never run → tick driver fires on first cron tick AFTER boot (regression: lastRunAt=null must anchor on bootTime, not the moving now)", async () => { + const db = await freshDb(); + const queue = newFakeQueue(); + // every-minute task; bootTime is mid-minute so the first cron tick after + // boot is :00 of the next minute. + const c = `--- +name: minute +description: x +cron: "* * * * *" +tz: UTC +catch_up: false +engine: ollama +--- +Body.`; + const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + + let nowMs = Date.UTC(2026, 4, 18, 14, 13, 6); // 14:13:06 UTC — boot + let tickFn: (() => void) | null = null; + const handle = startScheduler({ + db, + registry: singleTaskRegistry(task), + enqueue: queue.enqueue, + operatorFromId: 100, + defaultEngine: "ollama", + defaultChatId: 100, + now: () => nowMs, setInterval: (fn) => { - intervalFn = fn; + tickFn = fn; return 0; }, clearInterval: () => {}, }); + // Boot tick at 14:13:06 — next cron tick is 14:14:00, in the future, so + // no boot fire (matches design: cron is anchored, not stateful). + expect(queue.enqueued.length).toBe(0); + + // Advance to 14:14:06 — the first cron tick after boot (14:14:00) has + // just passed. The driver MUST detect `due ≤ now` and fire. + // Pre-fix bug: nextRunAt anchored on the moving `now` returned 14:15:00, + // which is still > now → never fires. + nowMs = Date.UTC(2026, 4, 18, 14, 14, 6); + tickFn!(); expect(queue.enqueued.length).toBe(1); - expect(queue.enqueued[0]!.message?.text).toBe(task.body); - const ctx = getScheduledContext(queue.enqueued[0]!.message); - expect(ctx?.name).toBe("digest"); + // Advance to 14:15:06 — second fire. After the first fire, lastRunAt is + // set, so the anchor now flows through the normal path. Confirms the + // post-first-fire transition. + nowMs = Date.UTC(2026, 4, 18, 14, 15, 6); + tickFn!(); + expect(queue.enqueued.length).toBe(2); + + handle.stop(); + }); + + test("cron, lastRunAt 3h stale, catch_up=true → boot-fires ONCE", async () => { + const db = await freshDb(); + const queue = newFakeQueue(); + const task = parseTaskFile(MINIMAL, "/p/TASK.md", { defaultEngine: "ollama" }); + const registry = singleTaskRegistry(task); + + // Seed lastRunAt 3h before FROZEN_NOW. + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + + const handle = startScheduler({ + db, + registry, + enqueue: queue.enqueue, + operatorFromId: 100, + defaultEngine: "ollama", + defaultChatId: 100, + now: () => FROZEN_NOW, + setInterval: () => 0, + clearInterval: () => {}, + }); + + // Exactly one catch-up fire (not N catch-ups). + expect(queue.enqueued.length).toBe(1); handle.stop(); - expect(intervalFn).not.toBeNull(); }); - test("catch_up: false, just-rebooted with periodic task → does NOT fire on boot", async () => { + test("cron, lastRunAt 3h stale, catch_up=false → does NOT fire; lastRunAt bumped to now", async () => { const db = await freshDb(); const queue = newFakeQueue(); const c = `--- name: deferred description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC catch_up: false --- Body.`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); const registry = singleTaskRegistry(task); + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + const handle = startScheduler({ db, registry, @@ -610,7 +833,6 @@ Body.`; }); expect(queue.enqueued.length).toBe(0); - // last_run_at was bumped to now so the next tick won't catch up either. const state = db.getTaskState("deferred"); expect(state?.lastRunAt).toBe(FROZEN_NOW); handle.stop(); @@ -623,7 +845,7 @@ Body.`; const c = `--- name: past_alarm description: x -schedule: at ${past} +at: ${past} catch_up: false --- Body.`; @@ -655,7 +877,7 @@ Body.`; const c = `--- name: late_alarm description: x -schedule: at ${past} +at: ${past} catch_up: true --- Body.`; @@ -680,19 +902,60 @@ Body.`; handle.stop(); }); + test("at , cold start → does NOT fire yet, no consumed mark", async () => { + const db = await freshDb(); + const queue = newFakeQueue(); + const future = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(); + const c = `--- +name: future_alarm +description: x +at: ${future} +--- +Body.`; + const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + const registry = singleTaskRegistry(task); + + const handle = startScheduler({ + db, + registry, + enqueue: queue.enqueue, + operatorFromId: 100, + defaultEngine: "ollama", + defaultChatId: 100, + now: () => FROZEN_NOW, + setInterval: () => 0, + clearInterval: () => {}, + }); + + expect(queue.enqueued.length).toBe(0); + const state = db.getTaskState("future_alarm"); + expect(state?.oneOffConsumed).toBeFalsy(); + handle.stop(); + }); + test("disabled task does not fire", async () => { const db = await freshDb(); const queue = newFakeQueue(); const c = `--- name: paused description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC enabled: false --- Body.`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); const registry = singleTaskRegistry(task); + // Seed stale lastRunAt — would normally trigger catch-up. + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + const handle = startScheduler({ db, registry, @@ -716,6 +979,15 @@ Body.`; const task = parseTaskFile(MINIMAL, "/p", { defaultEngine: "ollama" }); const registry = singleTaskRegistry(task); + // Seed stale lastRunAt to force a boot-fire that hits the queue. + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + const handle = startScheduler({ db, registry, @@ -747,10 +1019,21 @@ Body.`; }); describe("startScheduler — engine prefix mapping in synthesized text", () => { + function staleSeed(db: SolracDb, name: string) { + db.upsertTaskMetadata({ name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + } + test("task engine matches default → no prefix", async () => { const db = await freshDb(); const queue = newFakeQueue(); const task = parseTaskFile(MINIMAL, "/p", { defaultEngine: "ollama" }); + staleSeed(db, task.name); const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -772,11 +1055,13 @@ describe("startScheduler — engine prefix mapping in synthesized text", () => { const c = `--- name: hot description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC engine: primary --- fetch the weather`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + staleSeed(db, task.name); const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -798,11 +1083,13 @@ fetch the weather`; const c = `--- name: hot description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC engine: secondary --- think deeply`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + staleSeed(db, task.name); const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -824,6 +1111,13 @@ describe("startScheduler — synthetic update_id is negative", () => { const db = await freshDb(); const queue = newFakeQueue(); const task = parseTaskFile(MINIMAL, "/p", { defaultEngine: "ollama" }); + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -875,13 +1169,23 @@ describe("startScheduler — max_cost_usd pre-flight", () => { const c = `--- name: expensive description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC engine: secondary max_cost_usd: 0.20 --- Body.`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); + // Seed stale lastRunAt to force boot-fire attempt. + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: priorAuditId, + lastStatus: "fired", + }); + const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -911,13 +1215,23 @@ Body.`; const c = `--- name: free_run description: x -schedule: every 1h +cron: "0 * * * *" +tz: UTC max_cost_usd: 0.01 --- Body.`; const task = parseTaskFile(c, "/p", { defaultEngine: "ollama" }); expect(task.maxCostUsd).toBeNull(); // already nulled at parse + // Seed stale lastRunAt. + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); + const handle = startScheduler({ db, registry: singleTaskRegistry(task), @@ -944,6 +1258,13 @@ describe("getScheduledContext", () => { const db = await freshDb(); const queue = newFakeQueue(); const task = parseTaskFile(MINIMAL, "/p", { defaultEngine: "ollama" }); + db.upsertTaskMetadata({ name: task.name, sourcePath: "/p", sourceHash: "h" }); + db.markTaskFired({ + name: task.name, + lastRunAt: FROZEN_NOW - 3 * 60 * 60 * 1000, + lastAuditId: 1, + lastStatus: "fired", + }); const handle = startScheduler({ db, registry: singleTaskRegistry(task), diff --git a/src/scheduler.ts b/src/scheduler.ts index 940cea0..a4c4bbf 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -14,12 +14,14 @@ * and returns a frozen `TaskRegistry`. Fail-soft: a malformed TASK.md * adds an error to the result but does not throw; boot continues. * - * 2. Schedule grammar — three forms, parsed into a discriminated union + * 2. Schedule grammar — two forms, parsed into a discriminated union * `ScheduleSpec`: - * - `every ` interval from last_run_at; `s/m/h/d` units - * - `daily_at HH:MM` anchored daily fire (UTC) - * - `at ` absolute one-off - * `nextRunAt(spec, lastRunAt, now)` is a pure function used by both + * - `cron: <5-field expr>` unix cron in the task's `tz` (default + * host tz). Anchored cadence — no drift. + * - `at: ` absolute one-off + * Exactly one of `cron:` or `at:` must be present. `cron-parser` 5.x + * handles tz + DST (spring-forward skipped, fall-back single fire). + * `nextRunAt(task, lastRunAt, now)` is a pure function used by both * the tick loop and the unit tests; no I/O. * * 3. Tick driver — single shared `setInterval(60_000)` scans the registry, @@ -41,14 +43,15 @@ * * Catch-up policy (per task): * - * - `every `, `last_run_at` older than the interval: fire once - * on next tick (NOT N times for N missed intervals). - * - `daily_at`, today's anchor passed AND `last_run_at < today's anchor`: - * fire once. + * - `cron`, `last_run_at` set AND next cron fire after it is in the past: + * fire ONCE on the next tick (NOT N times for N missed slots). + * - `cron`, never run (`last_run_at` null): does NOT boot-fire — waits + * for the next cron tick. (cron is anchored, not stateful: a fresh + * deploy at 14:00 with `0 9 * * *` waits until tomorrow 09:00.) * - `at`, `at_ms < now` AND `!one_off_consumed`: fire once iff `catch_up`, * else mark consumed without firing. * - * `catch_up: true` is the default for periodic, `false` for one-off. + * `catch_up: true` is the default for `cron`, `false` for `at`. * `boot_catch_up_jitter_s` deferrs the boot fire by `random(0, jitter_s)` * seconds so 12 daily tasks don't pile up simultaneously on restart. * @@ -59,9 +62,9 @@ * - `Task`, `TaskRegistry`, `ScheduleSpec`, `ScheduledTaskContext` — types. * - `EMPTY_TASK_REGISTRY` — sentinel when scheduling is disabled. * - `loadTasksSync(dir, defaultEngine)` — boot loader, returns registry + errors. - * - `parseScheduleSpec(raw)` — pure parser. + * - `validateCronExpr(raw, tz)` — pure cron validator (wraps cron-parser). * - `parseTaskFile(content, sourcePath, defaultEngine)` — pure parser. - * - `nextRunAt(spec, lastRunAt, now)` — pure timestamp computation. + * - `nextRunAt(task, lastRunAt, now)` — pure timestamp computation. * - `startScheduler(deps)` — installs the tick loop; returns `{ stop }`. * - `getScheduledContext(message)` — runner-side helper to recover task * metadata off a message that flowed through the queue. @@ -109,6 +112,7 @@ import { createHash } from "node:crypto"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Update } from "@grammyjs/types"; +import { CronExpressionParser } from "cron-parser"; import type { DefaultEngine } from "./config.ts"; import type { SolracDb } from "./db.ts"; import { log } from "./log.ts"; @@ -122,8 +126,7 @@ import type { BotCommand } from "./telegram.ts"; export type TaskEngine = "primary" | "secondary" | "ollama"; export type ScheduleSpec = - | { kind: "every"; ms: number } - | { kind: "daily_at"; hourUtc: number; minuteUtc: number } + | { kind: "cron"; expr: string } | { kind: "at"; atMs: number }; export interface Task { @@ -133,6 +136,10 @@ export interface Task { readonly chatId: number | null; readonly engine: TaskEngine; readonly spec: ScheduleSpec; + // Resolved at parse time; never null. For `at` tasks this is informational + // only (the timestamp is absolute); for `cron` tasks it governs which + // wall-clock the expression evaluates against. + readonly tz: string; readonly catchUp: boolean; readonly enabled: boolean; readonly maxCostUsd: number | null; @@ -253,58 +260,81 @@ function parseFrontmatter(yaml: string): RawFrontmatter { } // --------------------------------------------------------------------------- -// Schedule grammar parser +// Schedule grammar parser — 5-field unix cron OR ISO8601 `at` // --------------------------------------------------------------------------- -const DURATION_UNIT_MS: Record = { - s: 1_000, - m: 60_000, - h: 3_600_000, - d: 86_400_000, -}; - -export function parseScheduleSpec(raw: string): ScheduleSpec { +/** + * Validate a 5-field cron expression and the tz it'll be evaluated under. + * Returns the normalized expression on success; throws with a contextual + * error on failure. cron-parser is permissive (accepts 4-field, 6-field, + * empty string, `@daily`/`@hourly`); we pre-validate at our layer for + * tighter semantics and clearer error messages. + */ +export function validateCronExpr(raw: string, tz: string): string { const v = raw.trim(); - if (v === "") throw new Error('schedule must be one of "every ", "daily_at HH:MM", "at "'); - - // every - const everyMatch = /^every\s+(\d+)\s*([smhd])$/i.exec(v); - if (everyMatch) { - const n = Number(everyMatch[1]); - const unit = everyMatch[2]!.toLowerCase(); - if (!Number.isInteger(n) || n <= 0) { - throw new Error(`every: duration must be a positive integer (got "${raw}")`); - } - const ms = n * DURATION_UNIT_MS[unit]!; - return { kind: "every", ms }; - } - - // daily_at HH:MM - const dailyMatch = /^daily_at\s+(\d{1,2}):(\d{2})$/i.exec(v); - if (dailyMatch) { - const h = Number(dailyMatch[1]); - const m = Number(dailyMatch[2]); - if (h < 0 || h > 23) throw new Error(`daily_at: hour out of range (got ${h})`); - if (m < 0 || m > 59) throw new Error(`daily_at: minute out of range (got ${m})`); - return { kind: "daily_at", hourUtc: h, minuteUtc: m }; - } - - // at - const atMatch = /^at\s+(.+)$/i.exec(v); - if (atMatch) { - const isoStr = atMatch[1]!.trim(); - // Reject timezone-naive strings: must end with `Z` or `+HH:MM`/`-HH:MM`. - if (!/(Z|[+\-]\d{2}:\d{2})$/.test(isoStr)) { - throw new Error(`at: timezone-naive timestamp rejected (got "${isoStr}"); use a Z suffix or explicit offset`); - } - const atMs = Date.parse(isoStr); - if (!Number.isFinite(atMs)) { - throw new Error(`at: not a valid ISO8601 timestamp (got "${isoStr}")`); - } - return { kind: "at", atMs }; + if (v === "") { + throw new Error('cron: expression is empty (use a 5-field expression like "*/30 12-18 * * 1-5")'); + } + if (v.startsWith("@")) { + throw new Error( + `cron: predefined aliases like "${v}" are not supported. Use the 5-field equivalent ` + + `(e.g., @daily → "0 0 * * *", @hourly → "0 * * * *")`, + ); + } + const fieldCount = v.split(/\s+/).length; + if (fieldCount !== 5) { + throw new Error( + `cron: expected exactly 5 fields (minute hour day-of-month month day-of-week), got ${fieldCount} in "${v}"`, + ); + } + try { + CronExpressionParser.parse(v, { tz }); + } catch (err) { + throw new Error(`cron: invalid expression "${v}" (tz=${tz}): ${(err as Error).message}`); + } + return v; +} + +/** Parse an ISO8601 absolute timestamp; reject tz-naive strings. */ +function parseAtField(raw: string): number { + const isoStr = raw.trim(); + if (!/(Z|[+\-]\d{2}:\d{2})$/.test(isoStr)) { + throw new Error( + `at: timezone-naive timestamp rejected (got "${isoStr}"); use a Z suffix or explicit offset`, + ); + } + const atMs = Date.parse(isoStr); + if (!Number.isFinite(atMs)) { + throw new Error(`at: not a valid ISO8601 timestamp (got "${isoStr}")`); + } + return atMs; +} + +/** + * Validate an IANA timezone name (e.g., "America/Denver", "UTC"). Uses the + * platform's tz database via `Intl.DateTimeFormat` — produces a clean error + * before cron-parser would emit a cryptic "CronDate: unhandled timestamp". + */ +function validateTz(tz: string): void { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + } catch { + throw new Error(`tz: invalid IANA timezone "${tz}"`); } +} - throw new Error(`schedule: unrecognized form "${raw}" (expected "every ", "daily_at HH:MM", or "at ")`); +/** Resolve the host's local IANA timezone (explicit env > runtime default). */ +function resolveHostTz(): string { + const envTz = process.env.TZ; + if (envTz && envTz.trim() !== "") { + try { + new Intl.DateTimeFormat("en-US", { timeZone: envTz }); + return envTz; + } catch { + // fall through to runtime default + } + } + return Intl.DateTimeFormat().resolvedOptions().timeZone; } // --------------------------------------------------------------------------- @@ -317,41 +347,40 @@ export function parseScheduleSpec(raw: string): ScheduleSpec { * tick driver (which compares to `now`) and tests. * * Semantics: - * - `every`: returns `(lastRunAt ?? 0) + ms`. When `lastRunAt` is null - * the value is just `ms` ms after epoch — effectively "fire on first - * tick" (the tick compares due ≤ now). The tick caller decides whether - * catch-up is allowed. - * - `daily_at`: returns today's anchor if `lastRunAt < today's anchor`, - * otherwise tomorrow's anchor. "Today" computed in UTC. + * - `cron`: returns `iter.next()` anchored at `lastRunAt ?? bootMs ?? now`. + * When `lastRunAt` is set, the anchor is the previous fire — `iter.next()` + * returns the next cron tick after that. When `lastRunAt` is null and a + * stable `bootMs` is provided, the anchor is boot time — `iter.next()` + * returns the first cron tick AFTER boot, which stays fixed across + * subsequent ticks so the driver can detect `due ≤ now` and fire. When + * both are null, the anchor falls back to `now` (display-time queries + * from `commands.ts::formatNextFire` use this path — they want "next + * fire from this moment"). * - `at`: returns `atMs` while `lastRunAt === null`; returns `null` once * the task has fired (one-off). + * + * The `bootMs` parameter is what makes scheduled fires actually happen for + * never-run cron tasks: without it, anchoring on the moving `now` produces a + * `due` that advances with every tick and never crosses `due ≤ now`. The + * tick driver passes the process boot time; display callers omit it. */ export function nextRunAt( - spec: ScheduleSpec, + task: Pick, lastRunAt: number | null, now: number, + bootMs?: number, ): number | null { - if (spec.kind === "at") { + if (task.spec.kind === "at") { if (lastRunAt !== null) return null; - return spec.atMs; - } - if (spec.kind === "every") { - if (lastRunAt === null) return now; - return lastRunAt + spec.ms; - } - // daily_at - const d = new Date(now); - const todayAnchor = Date.UTC( - d.getUTCFullYear(), - d.getUTCMonth(), - d.getUTCDate(), - spec.hourUtc, - spec.minuteUtc, - ); - if (lastRunAt === null || lastRunAt < todayAnchor) { - return todayAnchor; - } - return todayAnchor + 86_400_000; + return task.spec.atMs; + } + // cron + const anchorMs = lastRunAt ?? bootMs ?? now; + const iter = CronExpressionParser.parse(task.spec.expr, { + tz: task.tz, + currentDate: new Date(anchorMs), + }); + return iter.next().getTime(); } // --------------------------------------------------------------------------- @@ -361,7 +390,9 @@ export function nextRunAt( const ALLOWED_KEYS = new Set([ "name", "description", - "schedule", + "cron", + "at", + "tz", "chat_id", "engine", "catch_up", @@ -428,16 +459,58 @@ export function parseTaskFile( throw new Error(`${sourcePath}: "description" must be ≤${MAX_DESCRIPTION_LEN} chars (got ${descVal.length})`); } - // schedule - const scheduleVal = f.schedule; - if (typeof scheduleVal !== "string" || scheduleVal.trim() === "") { - throw new Error(`${sourcePath}: "schedule" is required (e.g., "every 1h", "daily_at 09:00", "at 2026-05-15T13:00:00Z")`); + // tz — explicit > $TZ > runtime default. Resolved BEFORE cron validation + // so the parser sees the same tz the runtime will use. + let tz: string; + if ("tz" in f) { + const v = f.tz; + if (typeof v !== "string" || v.trim() === "") { + throw new Error(`${sourcePath}: "tz" must be a non-empty IANA timezone string`); + } + try { + validateTz(v); + } catch (err) { + throw new Error(`${sourcePath}: ${(err as Error).message}`); + } + tz = v; + } else { + tz = resolveHostTz(); + } + + // cron / at — exactly one required. + const hasCron = "cron" in f; + const hasAt = "at" in f; + if (hasCron && hasAt) { + throw new Error(`${sourcePath}: "cron" and "at" are mutually exclusive — pick one`); + } + if (!hasCron && !hasAt) { + throw new Error( + `${sourcePath}: one of "cron: <5-field expr>" or "at: " is required`, + ); } let spec: ScheduleSpec; - try { - spec = parseScheduleSpec(scheduleVal); - } catch (err) { - throw new Error(`${sourcePath}: ${(err as Error).message}`); + if (hasCron) { + const v = f.cron; + if (typeof v !== "string" || v.trim() === "") { + throw new Error(`${sourcePath}: "cron" must be a non-empty 5-field expression`); + } + try { + const expr = validateCronExpr(v, tz); + spec = { kind: "cron", expr }; + } catch (err) { + throw new Error(`${sourcePath}: ${(err as Error).message}`); + } + } else { + const v = f.at; + if (typeof v !== "string" || v.trim() === "") { + throw new Error(`${sourcePath}: "at" must be a non-empty ISO8601 timestamp`); + } + try { + const atMs = parseAtField(v); + spec = { kind: "at", atMs }; + } catch (err) { + throw new Error(`${sourcePath}: ${(err as Error).message}`); + } } // engine — defaults to deploy default. When explicit `ollama`, refuse if @@ -457,13 +530,27 @@ export function parseTaskFile( engine = engineVal; } - // Enforce minimum interval for `every` based on the engine cost profile. - if (spec.kind === "every") { + // Min-interval guard for cron tasks. Inspect the first 5 fire times after + // a fixed anchor; reject if any consecutive gap < tier floor. Rejects + // pathological `* * * * *` on Claude tiers at load time with a clear error. + // `at` tasks are one-off; no interval to police. + if (spec.kind === "cron") { const minMs = engine === "ollama" ? MIN_OLLAMA_INTERVAL_MS : MIN_CLAUDE_INTERVAL_MS; - if (spec.ms < minMs) { - throw new Error( - `${sourcePath}: "every" interval too short for engine=${engine} (got ${spec.ms}ms, minimum ${minMs}ms)`, - ); + const iter = CronExpressionParser.parse(spec.expr, { + tz, + currentDate: new Date(0), + }); + let prev = iter.next().getTime(); + for (let i = 0; i < 5; i++) { + const cur = iter.next().getTime(); + const gap = cur - prev; + if (gap < minMs) { + throw new Error( + `${sourcePath}: cron interval too tight for engine=${engine} ` + + `(gap ${gap}ms < min ${minMs}ms in expression "${spec.expr}")`, + ); + } + prev = cur; } } @@ -526,6 +613,7 @@ export function parseTaskFile( chatId, engine, spec, + tz, catchUp, enabled, maxCostUsd, @@ -726,6 +814,12 @@ interface TaskRuntime { // Set on boot for catch-up tasks with jitter > 0; after consumption // (the boot-fire actually happens), reset to null. bootFireAt: number | null; + // Process boot time. Used as the stable anchor for never-run cron tasks + // so the tick driver can detect `due ≤ now` once the wall clock crosses + // the first cron tick after boot. Without this, `nextRunAt(task, null, + // now)` would re-anchor on the moving `now` every tick and `due` would + // always be in the future. + bootAnchor: number; } export function startScheduler(deps: SchedulerDeps): SchedulerHandle { @@ -748,17 +842,17 @@ export function startScheduler(deps: SchedulerDeps): SchedulerHandle { sourceHash: t.sourceHash, }); if (!t.enabled) { - runtimes.set(t.name, { task: t, bootFireAt: null }); + runtimes.set(t.name, { task: t, bootFireAt: null, bootAnchor: bootNow }); continue; } const state = deps.db.getTaskState(t.name); if (state?.oneOffConsumed) { - runtimes.set(t.name, { task: t, bootFireAt: null }); + runtimes.set(t.name, { task: t, bootFireAt: null, bootAnchor: bootNow }); continue; } - const due = nextRunAt(t.spec, state?.lastRunAt ?? null, bootNow); + const due = nextRunAt(t, state?.lastRunAt ?? null, bootNow, bootNow); if (due === null) { - runtimes.set(t.name, { task: t, bootFireAt: null }); + runtimes.set(t.name, { task: t, bootFireAt: null, bootAnchor: bootNow }); continue; } const wouldFireOnBoot = due <= bootNow; @@ -775,7 +869,7 @@ export function startScheduler(deps: SchedulerDeps): SchedulerHandle { } else { deps.db.setTaskLastRunOnly(t.name, bootNow); } - runtimes.set(t.name, { task: t, bootFireAt: null }); + runtimes.set(t.name, { task: t, bootFireAt: null, bootAnchor: bootNow }); continue; } let bootFireAt: number | null = null; @@ -783,7 +877,7 @@ export function startScheduler(deps: SchedulerDeps): SchedulerHandle { const jitterMs = Math.floor(random() * t.bootCatchUpJitterS * 1000); bootFireAt = bootNow + jitterMs; } - runtimes.set(t.name, { task: t, bootFireAt }); + runtimes.set(t.name, { task: t, bootFireAt, bootAnchor: bootNow }); } function fireTask(rt: TaskRuntime, fireAt: number): void { @@ -918,7 +1012,7 @@ export function startScheduler(deps: SchedulerDeps): SchedulerHandle { continue; } - const due = nextRunAt(rt.task.spec, state?.lastRunAt ?? null, t); + const due = nextRunAt(rt.task, state?.lastRunAt ?? null, t, rt.bootAnchor); if (due === null) continue; if (t < due) continue; fireTask(rt, t); diff --git a/tasks/stretch/TASK.md b/tasks/stretch/TASK.md deleted file mode 100644 index 2ce96cc..0000000 --- a/tasks/stretch/TASK.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: stretch -description: Every-5-minute reminder to stand up and stretch. -schedule: every 5m -catch_up: false ---- - -Send a short, friendly reminder (one sentence, under 140 chars) telling the -operator to stand up and stretch. Vary the wording each fire so it doesn't -feel robotic. No questions, no follow-up — just the reminder.