Skip to main content

Routinely

CI

Scheduled jobs that just work — on macOS and Linux, with zero dependencies.

Routinely turns "run this program every morning at six" into one block in your pyproject.toml and one command. It installs real, native scheduled jobs — launchd agents on macOS, systemd user timers on Linux — so your job survives reboots and sleeps the way OS services do, without you ever writing a plist or a unit file. And because native schedulers fail silently, Routinely ships the debugging kit they forgot: doctor catches every known silent-failure mode before install, kick runs your job right now in the exact environment the scheduler will use, and status answers "did it run, and when's the next one?" identically on both platforms. Pure standard library — adding Routinely adds nothing else.

Contents

Quickstart

  1. Add routinely to your project (as a dev dependency is fine):

    uv add --dev routinely
    
  2. Add a job block to your pyproject.toml:

    [tool.routinely.digest]
    command  = "python -m digest.main"
    schedule = "daily at 06:00"
    catch_up = true
    
  3. Install it:

    uv run routinely install
    

That's a working daily job. routinely render shows the exact unit file before anything touches your system.

How it works

launchd expands nothing in a plist: no ~, no $HOME, no environment variables. Every path must be absolute and literal, so a working plist necessarily contains /Users/<you>/... and can't be committed to a shared repo. Routinely instead renders the unit at install time from the path-free spec above, resolving paths against the project root, $HOME, and the active interpreter (sys.executable) — so the committed config stays machine-independent, and moving a project just means running routinely install again. Preflight checks run before anything is written, catching the failure modes launchd never reports.

Requires Python 3.11+. Zero runtime dependencies (stdlib only).

Config reference

All keys live under [tool.routinely.<job-name>]. One block per job; multiple blocks are fine.

Key Required Default Meaning
command yes Command line, shell-split. A leading python/python3/python3.x is replaced with the interpreter running routinely install — so uv run / an active venv "just works". A leading relative path resolves against the project root; a bare command is looked up on PATH at install time.
schedule yes 5-field cron ("0 6 * * *") or one of four phrase forms. Full grammar: Schedule syntax.
catch_up no false Run a missed slot when the machine comes back. See the semantics table below — this is the key whose meaning differs most across platforms.
label no local.<project-name>.<job-name> Unit identity: launchd label / systemd unit basename. Uninstall works by label even after the project moves.
working_directory no "." (project root) Relative paths resolve against the project root; ~ expands at install time.
stdout, stderr no macOS: ~/Library/Logs/<job>/{out,err}.log; Linux: the journal Log destinations. Parent directories are created for you.
environment no {} Extra environment variables. Scheduled jobs inherit no shell environment; set PATH explicitly if your job spawns other tools.

The project root is the git toplevel, or the directory containing pyproject.toml if there's no git repo.

Schedule syntax

A schedule string is either 5-field cron or one of four phrase forms. The phrases are routinely's own (there is no external standard for them); this section is their complete definition, and src/routinely/schedule.py is the source of truth. Parsing is case-insensitive and whitespace-insensitive ("Daily at 6:00" is fine). Anything that isn't a recognized phrase is parsed as cron; anything invalid is rejected at render/install time with an error naming the bad field — nothing falls through silently.

Phrase forms

Phrase Grammar Equivalent Meaning
"hourly" exactly that word cron 0 * * * * at minute 0 of every hour
"daily at HH:MM" HH = 0–23 (one or two digits), MM = exactly two digits 00–59 cron MM HH * * * once a day at that local time
"weekdays at HH:MM" same time rule cron MM HH * * 1-5 Monday–Friday at that local time
"every N <unit>" N ≥ 1; unit s/sec(s)/second(s), m/min(s)/minute(s), h/hr(s)/hour(s); space before the unit optional (15m or 15 minutes) — no cron equivalent an interval, not a calendar time

That's the whole phrase language — there is deliberately no "monthly", "every tuesday", or natural-language date parsing. Anything beyond these four shapes is cron's job.

Calendar vs interval matters. The first three phrases produce calendar schedules (launchd StartCalendarInterval / systemd OnCalendar), which support catch-up semantics. every N … produces an interval (launchd StartInterval / systemd OnBootSec + OnUnitActiveSec), which fires "every N since the last run" with no fixed wall-clock alignment — and on macOS, interval firings missed during sleep are simply lost (see the semantics table below).

Cron form

Standard 5 fields — minute hour day-of-month month day-of-week — with the usual constructs per field:

  • * — any value
  • 5 — a single value
  • 1,15 — a list
  • 9-17 — an inclusive range
  • */15, 9-17/2, 5/20 — steps over a range (5/20 = from 5 to the field max, every 20)

Ranges: minute 0–59, hour 0–23, day 1–31, month 1–12, weekday 0–7 (0 and 7 are both Sunday; numeric only — mon/jan names are not supported). Descending ranges (5-1) are rejected rather than wrapped.

One classic cron subtlety is preserved on both platforms: if both day-of-month and day-of-week are restricted, the job runs when either matches (0 6 1,15 * 1 = the 1st, the 15th, and every Monday).

All times are local time. There is no timezone field and no seconds field.

CLI reference

routinely install   [name]   # render → validate → install → load. Idempotent.
routinely render    [name] [--platform launchd|systemd]   # print unit(s), touch nothing
routinely doctor    [name] [--json]   # preflight diagnostics (see below)
routinely kick      [name] [--restart]   # run the job NOW, in the real scheduled environment
routinely status    [name] [--json]   # loaded? last exit? next fire? — same view on both platforms
routinely logs      [name] [-f] [-n N]
routinely uninstall [name] [--label LABEL]   # by label; works after the project moves

With no name, commands operate on every job in the project. Everything is user scope (~/Library/LaunchAgents, systemctl --user) — no sudo.

install is a reinstall when the job already exists (unload → rewrite → reload), so it's also how you apply config changes. It refuses to install if any preflight check fails, and tells you what to fix.

For scripts and agents: status --json and doctor --json emit structured JSON instead of the human formatting. Exit codes are meaningful everywhere: doctor exits 1 if any check fails, status exits 1 if any selected job is not loaded, install exits 1 on refusal or error, and all commands exit 0 on success. Nothing ever prompts interactively, install is safe to retry, and kick lets an automated caller verify a job end-to-end without waiting for its schedule.

routinely doctor

Every check corresponds to a real way launchd jobs die silently:

$ routinely doctor
✓ label        com.evanjones.paperdigest
✓ program      .../.venv/bin/paper-digest -> .../.venv/bin/python3
✓ working dir  /Users/…/daily_scholar_digest
✓ log dir      /Users/…/Library/Logs/paper-digest (created)
✓ unit syntax  plutil -lint OK
✓ loaded       yes · last exit 0 · next fire 2026-07-22 06:00
  • program exists and is executable — the #1 cause of a silently-dead agent
  • working directory exists
  • log parent directories exist (created if missing — launchd won't)
  • rendered unit passes plutil -lint (macOS) / systemd-analyze verify (Linux)
  • loaded state, last exit code, next fire time
  • Linux: warns if loginctl linger is off (user timers stop at logout)
  • warns when a cron expression explodes combinatorially on launchd (see below)

routinely kick — test the job for real

Running your command in a terminal proves almost nothing about how it behaves under the scheduler: your shell has a full PATH, your environment variables, your working directory. The scheduled run has none of that — launchd starts jobs with an empty environment, and "works in my terminal, dies at 6am" is the classic launch-agent debugging time sink.

kick asks the init system itself to run the job immediately (launchctl kickstart / systemctl --user start), so it executes with exactly the scheduled run's environment, working directory, and log destinations:

routinely kick          # fire the job now
routinely logs -f       # watch what it did
routinely status        # …and how it exited

--restart kills a currently-running instance first (kickstart -k / systemctl restart); without it, kick only starts the job if it isn't already running.

Two things kick deliberately does not do:

  • It doesn't bypass your app's own run policy. If the job internally decides "already ran today, nothing to do," a kick runs the process and the process declines — which is itself a useful test. Give your app a force flag (e.g. --once) if you need to override its policy.
  • It doesn't simulate the environment by re-spawning the process itself. The init system is the only thing that ever runs your job, so what you debug is what ships.

The debugging loop, in order:

  1. routinely doctor — static checks: paths, permissions, syntax, loaded state. Catches most silent failures before anything runs.
  2. routinely kick — dynamic check: does the job actually work under the scheduler, right now?
  3. routinely logs / routinely status — what happened and how it exited.

Why does my job not run?

launchd fails silently — no error, no log, the job just never fires. In rough order of likelihood:

  1. The interpreter path is stale. You recreated .venv (e.g. fresh uv sync), so the path baked into the installed plist no longer exists. Run routinely doctor — the program check fails — then routinely install to re-render against the new interpreter.
  2. You edited config but didn't reinstall. Neither launchd nor systemd watches files: units are read at load time. routinely install again.
  3. The machine was asleep or off at the scheduled time and catch_up = false. That slot is simply gone. Set catch_up = true if a late run is better than no run.
  4. Interval schedules (every 15m) miss firings during sleep on macOS — per launchd.plist(5), that's inherent to StartInterval. Use a calendar schedule if catch-up matters.
  5. The job ran but crashed instantly. routinely status shows the last exit code; routinely logs shows stderr. A common cause: the job's own subprocesses need a PATH you haven't set in environment. Reproduce it on demand with routinely kick instead of waiting for the next fire.
  6. Linux: you logged out. systemctl --user timers stop at logout unless loginctl enable-linger is set. doctor warns about this.
  7. macOS said "Background Items Added" and someone clicked it off in System Settings → General → Login Items. Re-enable it there.

launchd vs systemd: same knob, different guarantees

routinely maps one spec to both platforms honestly rather than pretending parity:

Situation launchd (macOS) systemd (Linux)
Slot missed while asleep (calendar) Fires on next wake; multiple missed slots coalesce into one Persistent=true catches it at next timer evaluation
Slot missed while powered off (calendar) Covered only by RunAtLoad → runs at next login Persistent=true runs it at next boot (last-trigger time is tracked on disk)
Extra runs when nothing was missed Yes: catch_up = RunAtLoad, which fires at install and at every login — launchd keeps no last-run state No: Persistent fires only if a slot was actually missed
Slot missed while asleep (interval) LostStartInterval limitation Timer resumes; OnBootSec restarts it after reboot
Schedule expressiveness Single integers per field: */15 9-17 * * 1-5 expands to 180 calendar dicts (routinely warns past 24 and suggests an interval) OnCalendar expresses lists/ranges compactly
DST / timezone change Cached next-fire date can be wrong until reload Recomputed
Logs Files (launchd has no journal) journald (routinely logs wraps journalctl)
Last exit code in status Reported after every run Reported only after failures. A successful oneshot resets its exec state to the same zeros as a never-ran service (verified live), and the fix — RemainAfterExit=yes — would stop the timer and kick from re-triggering the job. Success is inferred from the journal (routinely logs), not the exit code.
Unit syntax preflight plutil -lint (always available) systemd-analyze verify --user needs systemd ≥ 250 (Ubuntu 24.04+); on older hosts doctor downgrades the check to a warning

The practical consequence: a catch_up = true job must tolerate being started when there's nothing to do — e.g. "already ran today? exit 0". That's by design: routinely schedules opportunities to run; whether a run is actually due is application state, and only the application can judge it.

macOS bonus: install points the job at a descriptively-named symlink to the interpreter, so Login Items and ps show your job's name instead of "python".

What routinely is not

  • Not a run-policy engine — "at most once per day" logic belongs in your app, which owns the state that defines "done".
  • Not a process backgrounder: no double-forking, no PEP 3143 detaching — the init system owns the process.
  • Not a supervisor and not a task queue — see supervisord, Celery et al. Scope here is unit-file lifecycle: render, validate, install, inspect, remove.

Development

just test    # run the test suite
just run    # run the CLI from source

Renderers are pure functions and both are tested on every host platform; no test touches launchctl, systemctl, or your LaunchAgents directory. Contributing with an AI agent? Start with AGENTS.md — it holds the invariants and the list of behaviors that look like bugs but aren't.

Authorship

Routinely was pair-programmed with Claude Code (Claude Fable 5), with every change human-directed and human-reviewed; the Co-Authored-By trailers in the git history mark the AI's hand, and CONVERSATION.md is a running log of the collaboration — including the design arguments and the live findings on real hardware. Trust, though, should come from the evidence rather than the byline: the behavior documented here is backed by a unit suite that runs on every commit, live validation against real launchd and systemd (including the platform quirks in the semantics table above, several of which were discovered empirically), and a production job that has been running on this code throughout its development.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

routinely-0.1.0.tar.gz (59.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

routinely-0.1.0-py3-none-any.whl (28.4 kB view details)

Uploaded Python 3

File details

Details for the file routinely-0.1.0.tar.gz.

File metadata

  • Download URL: routinely-0.1.0.tar.gz
  • Upload date:
  • Size: 59.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.7.19

File hashes

Hashes for routinely-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0aaa0143adb1bd87a4a17e5cd4ae4516b087dccc4510bfe4e244270aa0320c43
MD5 e89bd39458c2617e40fa1abff7c436a2
BLAKE2b-256 c7e2fe2fd2916df3f1249682960aa1b6dace17236a2cdd68ab42a2863648e9e2

See more details on using hashes here.

File details

Details for the file routinely-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: routinely-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 28.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.7.19

File hashes

Hashes for routinely-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 37f018914b236f4752082f002c74744dc720a08b0f5fe17af85ebc587f3ede88
MD5 7234f54213fcfd256b641ac334b8ffae
BLAKE2b-256 a6b099ebfdfbf760b978520b2616b3f9cdda4e8c42b83a852c72be03075cc8b2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page