Skip to content

Schedule recurring runs

agora orch submit fires a run once. When you want to run the same plan on a recurring schedule — nightly, hourly, weekly — wire a scheduleStore into your config and use agora orch schedule add. The serve driver polls due schedules on each tick and submits them through the same inbox a client uses; no external scheduler is required.

Open your agora.config.mjs (or .ts/.js) and add a SqliteScheduleStore to the orch export. Pass it the same database path as your SqliteRunStateStore — they share one SQLite file:

import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
SqliteRunStateStore,
SqliteScheduleStore,
serve,
} from '@quarry-systems/agora-orchestrator';
const dbPath = join(tmpdir(), 'my-orchestrator.db');
const store = new SqliteRunStateStore(dbPath);
const scheduleStore = new SqliteScheduleStore(dbPath);
// ... orchestrator / transport setup unchanged ...
export const orch = {
transport,
runService: (signal) => serve({ orchestrator, transport, scheduler, signal }),
scheduleStore, // <-- add this
};

Without scheduleStore, the agora orch schedule verbs error immediately with a clear message; serve and all other orch verbs are unaffected.

Terminal window
agora orch schedule add \
--id nightly-audit \
--cron "0 2 * * *" \
--plan ./plans/audit.json

--cron accepts a standard 5-field cron expression (min hour dom mon dow, UTC). The command validates the expression up front and rejects invalid syntax before writing anything. On success it prints the id and the first nextDueAt:

schedule 'nightly-audit' next due 2026-06-04T02:00:00.000Z

Re-running add with the same --id is an idempotent update — the expression, plan template, and actor are replaced, and the nextDueAt is recomputed.

Terminal window
agora orch schedule list

Prints a tab-delimited line per schedule:

nightly-audit 0 2 * * * last=- next=2026-06-04T02:00:00.000Z

last=- means the schedule has not fired yet; once it fires, last shows the ISO timestamp of the most recent slot.

Terminal window
agora orch serve

serve polls due schedules on each tick (default 2 s). When a schedule’s nextDueAt is reached, serve submits the plan to the inbox with a deterministic run id <scheduleId>@<slotISO> — for example, nightly-audit@2026-06-04T02:00:00Z. The run then flows through the normal pollInbox → ManualTrigger → tick pipeline unchanged.

After firing, serve advances nextDueAt to the next slot and records lastFiredAt. The schedule persists across restarts — if serve is down when a slot falls due, it fires one coalesced catch-up run (for the most-recently missed slot) on the next startup, then resumes the normal cadence. Earlier missed slots are dropped, not replayed.

SqliteScheduleStore holds an open SQLite handle (like SqliteRunStateStore). When serve returns — i.e. after the SIGINT/SIGTERM AbortController fires — call close() on the store so the database file lock is released. This matters on Windows, where an unreleased handle keeps the file locked. Close it after serve resolves in your runService:

runService: async (signal) => {
try {
await serve({ orchestrator, transport, scheduler, signal });
} finally {
scheduleStore.close();
}
},
Terminal window
agora orch schedule rm --id nightly-audit

The schedule is removed immediately. Any run already in flight continues normally; no new run for that schedule will be submitted. Removing an id that does not exist is a no-op.

  • Time zone: cron expressions are evaluated in UTC (the host’s clock). If you need a local time, adjust the expression for your offset or set the host TZ environment variable.
  • Minute granularity: standard 5-field cron is minute-granular. Sub-minute schedules are not supported.
  • Single-serve assumption: schedule state lives in the same SQLite DB as run state — one serve is the sole writer, matching V1’s design.
  • MCP: schedule management is an operator action and is CLI-only; no MCP tool surface is exposed for schedule mutation.