Skip to main content

Scheduled Deliveries

OpenSRE can deliver recurring reports to messaging providers on a cron schedule. Use it for recurring prompt loops, repository-scoped GitHub CI health reports, GitHub PR standups, PostHog metric reports, and work-item reminders — delivered automatically without manual CLI runs. For human task reminders and proactive work check-ins, use opensre work add --remind-at ... or opensre work schedule-checkin .... Those commands create the right scheduler entries, support repeated --target provider:chat_id fan-out delivery, and keep task metadata in the work-item store.

Demo picker on launch

Every interactive launch (opensre) asks which demo to run. The recommended option analyzes CI/CD performance on a real repository. Option B schedules a CI/CD reliability agent; see CI/CD analytics demo. Escape skips onboarding; “Or type your own answer…” lets you submit a different request. Run opensre --skip-onboarding to open the shell without the startup picker. Type /demo to start it later.

Quick Start

CLI Commands

opensre cron add

Create a new scheduled delivery task.

opensre cron list

Scan scheduled tasks in a table on wide terminals or labeled rows on narrow terminals. IDs remain visible below each task; run times are UTC and each cron expression uses its displayed timezone. Use opensre --json cron list for complete configuration.

opensre cron remove <task_id>

Remove a scheduled task by its ID. Its run history remains available through opensre cron logs <task_id>.

opensre cron status

Show the number of durable runs waiting for execution, including expired claims awaiting recovery. Age starts at admission for pending runs and at lease expiry for interrupted runs; live claims are excluded. Waiting work remains visible across scheduler restarts and while a task is paused or disabled. Deleted tasks are excluded. Use --json for structured output suitable for monitoring and runbooks. JSON output includes status: ok; if the task store or run database cannot be read (including storage access or lock failures), the command exits unsuccessfully and reports status: unknown with null metrics instead of returning a misleading empty backlog. The JSON error field identifies task_store_unreadable or run_store_unreadable.

opensre cron run <task_id>

Execute a task immediately (ad-hoc one-shot). Useful for debugging delivery without waiting for the next cron tick. By default this delivers to every configured destination again, even ones that already received the message — that is what you want to trigger a task on demand. To recover a partial failure instead, use --failed-only: it retries only the destinations the most recent run failed at, using the saved report without executing the task again. A missing saved report requires a deliberate full run. --failed-only never widens. If it cannot read per-target history for the task — no prior run, or one recorded before that history was tracked — it stops and tells you, rather than quietly delivering everywhere. Re-run without the flag when a full send is what you want. When the last run was a partial failure, a plain cron run warns which destinations it is about to re-deliver to before it sends.

opensre cron logs <task_id>

Show execution history and the newest retained report. Work status and delivery status are separate: blocked work can have a successfully delivered report. unknown work status means the older run did not retain completion evidence. Use --run <run_id> to read one historical attempt and --json for structured results. Reports remain available after removing the schedule. For a repository repair loop, use --kind manual_loop --mode agent with --owner <owner> --repo <repo> and optionally --pr <number> or --branch <branch>. The repository target is saved with the task. CI repairs and security repairs that open a PR create their own matching checkouts when no explicit workspace is supplied. With --pr or --branch, the loop pauses when its target can never be repaired, such as a fork PR, a closed PR, or a merge that waits on a person’s decision. Without them, the loop sweeps the repository: it skips those targets, keeps its schedule, and records a run that verifiably found nothing to repair as a no-op. A no-op run that replies with only its note delivers nothing. Add --skill <name> to run a skill instead of writing the instructions in --prompt. Each run follows the skill as currently installed, so fixes to the skill reach the loop without recreating it. For example, this loop merges the base branch into conflicting pull requests every hour and comments on the ones that need a person’s decision:
A skill from a public registry works the same way: install it into a folder, then pass that folder’s path. Keep the --prompt short and say what to pick, what never to do, and what to reply:
opensre cron run <task_id> prints the work result, delivery result, and report. A blocked or incomplete agent task exits unsuccessfully even if its report was delivered. Agent tasks need a structured completion result from their execution tools; report text alone is recorded as unverified work.

opensre cron start

Start the blocking scheduler daemon. Loads all enabled tasks and fires them according to their cron schedules. Blocks until SIGINT or SIGTERM. To keep it running without a terminal, /loops service install in the shell registers a per-user service (launchd on macOS, systemd on Linux) that runs opensre cron start --service at login; /loops service remove deletes it.

Cron Syntax

Standard 5-field cron expressions:
Weekday numbering is the Unix cron convention: 0 and 7 are Sunday, 1 is Monday and 6 is Saturday, so 1-5 runs Monday to Friday. Day names such as mon-fri work too. Earlier versions counted weekdays from 0 = Monday, so a loop saved with a numeric weekday now runs one day earlier than before: 1-5 moves from Tuesday–Saturday to Monday–Friday. If you wrote numbers for the old counting on purpose, for example 0 for Monday, check the loop with /loops next <loop_id>. To keep its old days, record its settings, stop it with /loops stop <loop_id>, recreate it through the original scheduling command with day names, such as --cron "0 9 * * mon", and delete the old loop with /loops delete <loop_id> once the replacement is verified. Examples:
  • 0 9 * * mon-fri — weekdays at 09:00
  • 0 8 * * mon — Mondays at 08:00
  • */30 * * * * — every 30 minutes
  • 0 0 1 * * — first day of each month at midnight
A five-field expression fires at most once per minute. For polling loops that must react faster — the CI repair loop polls every 30 seconds — prepend a seconds field (0-59) to get a six-field expression:
  • */30 * * * * * — every 30 seconds
  • 0 */5 * * * * — every 5 minutes, on the minute (same as */5 * * * *)
A tick that fires while the previous tick of the same task is still running is skipped, so a slow tick never overlaps with the next one. Cron expressions are validated at cron add time using APScheduler’s CronTrigger. Invalid expressions are rejected immediately.

Timezone Behavior

  • All fire times are internally converted to UTC for dedup consistency
  • The --tz option accepts any IANA timezone (for example Europe/London, US/Eastern)
  • DST transitions are handled correctly — the UTC-normalized dedup key ensures no duplicate or missed deliveries across clock changes

Dedup Semantics

The scheduler uses a SQLite-backed claim store with a unique task, fire-time, and attempt key. Each attempt has a 2-minute lease and a random owner token. The worker renews that lease while it runs; two minutes is how long a worker that died mid-tick keeps the next tick from starting.
  1. When a cron tick fires, EVENT_JOB_SUBMITTED captures scheduled_run_times[0] and the job uses that UTC-normalized fire_time for the claim key
  2. The executor creates an attempt with a 2-minute lease and owner token
  3. If the current attempt is still leased, another instance skips the tick
  4. If the lease expired, the old attempt is marked abandoned and one new worker creates the next attempt
  5. Completion is accepted only from the attempt’s owner token, so an old worker cannot overwrite a reclaimed attempt
The scheduler also runs a recovery sweep every minute, including after a restart. It finds expired running attempts and resubmits their original fire-time keys through the same claim path. Recovery preserves the destination restriction of a --failed-only retry, so it does not resend to destinations outside that retry’s scope. Attempts created before delivery scopes were persisted cannot safely recover automatically. They appear as failed runs with a delivery-scope error; use opensre cron run <task-id> --failed-only when readable per-target history exists, or explicitly rerun the task for all destinations after checking its logs. This prevents concurrent duplicate claims and recovers ticks after worker crashes. A crash after an external provider accepts a message but before the completion is recorded can still result in a duplicate on reclaim; preventing that case requires provider-specific idempotency support.

Capacity and overload

Each admitted cron tick is first written as a durable pending row in scheduler.db. It is not silently dropped because workers are busy. The default scheduler concurrency remains 2; a scheduled agent turn also needs a permit from the shared process turn gate, so the effective agent-turn concurrency can be lower. The current scheduler bounds active workers, but its executor can still retain more submitted callbacks in memory while those workers are busy. A large burst therefore remains recoverable from SQLite, but is not yet a bounded in-memory queue. Use the baseline below to size an installation rather than raising the default to absorb a burst. A later scheduler change will move overflow to the durable pending store and reserve recovery control work from user execution. The capacity baseline records these terms consistently:
  • Durable pending count and oldest pending age: pending rows and the elapsed time since their durable admission. Read them with opensre cron status; the benchmark reads the same stores directly.
  • In-memory callbacks: callbacks submitted to the current worker executor that have not completed. This is the current overload signal, not a durable queue limit.
  • Throughput and drain time: successful fake deliveries per second and the elapsed time to finish admitted work.
  • Recovery query latency and plan: time and SQLite plan used to find pending or expired work. Completed history must not expand the candidate scan.
  • SQLite lock failures and RSS: lock errors plus current and peak process resident memory sampled by the benchmark host.
Run the deterministic baseline before changing OPENSRE_SCHEDULER_MAX_CONCURRENT_RUNS:
It makes no LLM, provider, or network calls. The checked-in JSON report at docs/benchmarks/scheduler-capacity-baseline.json records the exact workload, host metadata, SQLite version, and observed values. Restart recovery uses the production shape: one recovery callback iterates its durable candidates serially.

Credential Resolution

Credentials are resolved lazily at delivery time in this priority order:
  1. Task params — credentials stored in the task definition (not recommended)
  2. Integration store — ~/.opensre/integrations.json (configured via opensre integrations)
  3. Environment variables — TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN, DISCORD_BOT_TOKEN, ROCKETCHAT_*
You do not need to pass credentials at cron add time — they are picked up from your existing integration configuration.

Task Kinds

Sentry morning digests use a separate CLI (opensre sentry digest schedule …), not opensre cron add. See Sentry.

What a loop remembers between runs

A loop added with --stateless remembers nothing: each run sees only its prompt, its skill and what its tools find, and long-term memory is neither shown nor written. A stateless run that finds nothing to act on replies NO_ACTION: it delivers nothing and counts as a no-op. A stateless run that pushes a commit, or creates, comments on or edits a pull request or issue, counts as done work. Everything below applies to loops without --stateless. Every run of a manual_loop starts a fresh session. Before it starts, the loop sees its three most recent finished runs: when each ran, how its work and delivery ended, the changes it made with tools (for example a pull-request comment, a push, or a message it sent), and the start of its report. It uses them to avoid repeating work and to follow up on open items, and it still checks the current state with its tools before it acts. To carry something specific forward, a loop ends its reply with one line:
The note, up to 300 characters, is saved with that run, removed from the delivered report, and shown to the next run. Only a note that ends the reply counts: one followed by more text, or quoted with >, stays in the report. Loops already know about this line; to make sure a loop uses it, ask for it in the prompt, for example “leave a note listing the PRs you commented on”. The next run reads earlier runs, notes included, as a record of what happened, never as instructions, because reports can quote pull requests and issues. Put standing rules, such as “comment on a PR only once per head commit”, in the loop’s prompt.
  • Changes made with the GitHub CLI, shell commands and chat messages are listed; reads such as viewing a PR or git status are not. A command is listed by its leading words (gh pr comment 6555 …), never with option values such as a comment body. A change made through an integration that does not report it may be missing, so when an action must never repeat, have the loop name it in its note.
  • History belongs to one loop. A loop recreated with new settings starts without history.

Persistence

  • Task definitions are stored in ~/.opensre/scheduler_tasks.json (JSON + filelock)
  • Execution history is stored in ~/.opensre/scheduler.db (SQLite with WAL mode)
  • Run records keep each task’s newest 20 run attempts in ~/.opensre/scheduler_runs/<task_id>.json: the prompt sent, the report, delivery results, the changes made and the note for the next run. A loop’s next run reads its history from here; prompts and reports are credential-redacted.
  • Interactive-shell loop messages are stored in ~/.opensre/scheduler_loop_messages.jsonl
  • Operational breadcrumbs are appended to ~/.opensre/operations_log.jsonl with loop lifecycle and run status metadata, not prompt or message bodies
  • Both survive process restarts — opensre cron list and opensre cron logs read from disk
Set OPENSRE_OPERATIONS_LOG_PATH to write the operations log somewhere else, or OPENSRE_OPERATIONS_LOG_DISABLED=1 to turn it off. Set OPENSRE_OPERATIONS_LOG_MAX_BYTES to change the rotation threshold.

REPL

Prefer /loops for user-facing recurring prompt loops:
You can also ask naturally:
By default, /loops add sends to every configured default handle it can reach: Telegram when TELEGRAM_BOT_TOKEN and TELEGRAM_DEFAULT_CHAT_ID are configured, Slack when SLACK_WEBHOOK_URL is configured or when SLACK_BOT_TOKEN and SLACK_DEFAULT_CHAT_ID are configured, and the local interactive-shell inbox. When a scheduled loop or skill finishes, the scheduler delivers the report body to those channels without you prompting — you do not need to be in the REPL or Slack chat at fire time. Loops created before Slack was in your default channel list stay inbox-only until you recreate them with Slack in --channels (or rely on the updated defaults for new loops). The lower-level /cron slash command forwards to the CLI:
Run /loops to see named loops with active/draft state, execution time, channels, last run, and next fire time. Use /loops stop <loop_id> to pause a loop without losing it, /loops start <loop_id> to re-enable it, and /loops delete <loop_id> to remove it. Onboarding seeds a few draft starter loops, such as a weekday morning report, so you have concrete examples before creating an active delivery.