Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased — scheduler: switch to unix cron (BREAKING TASK.md format)

Replaces the three-form schedule grammar (`every <dur>` / `daily_at HH:MM` / `at <ISO8601>`) 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 <name>` 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 `<ul>`/`<br>` 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.
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dur>`, `daily_at HH:MM`, `at <ISO8601>`. 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=<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 <past>` 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 <past>` 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).

Expand Down
3 changes: 2 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<name>` slash command. |
| `SOLRAC_SKILLS_DIR` | no | `./skills` | path | Directory scanned for `<name>/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 <dur>`, `daily_at HH:MM`, `at <ISO8601>`). 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 `<name>/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 `<name>/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__<tool>`. **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 `<name>/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). |
Expand Down
4 changes: 3 additions & 1 deletion docs/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<name>` (wire format on Ollama: `skills__<name>`). 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/<name>/` that fires a prompt on a schedule (`every <dur>`, `daily_at HH:MM`, `at <ISO8601>`) 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=<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/<name>/` 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=<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).

Expand Down
11 changes: 11 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` fire on a per-task schedule (`every <dur>`, `daily_at HH:MM`, `at <ISO8601>`). Fires synthesize updates through the existing turn queue, so cost caps + allowlist + policy hooks all apply automatically. `/tasks` lists loaded tasks; `/tasks run <name>` 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/<name>/` 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 <name>` 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.

---

Expand Down
Loading
Loading