A run the harness schedules for itself never gets more authority than the
run that first created it — a scheduled job cannot grant itself permissions
nobody gave it. Working out what is due to run is a pure function, tested
against a frozen clock, so the whole schedule is fully deterministic.
flowchart TD
toml[("schedules.toml
append-only")] --> load["load()
parser.py"]
load --> due["due()
pure, given last-run and now"]
due --> catchup["catch_up()
skip / once / all"]
catchup -->|"no runs due"| skip["last-run advances, nothing runs"]
catchup -->|"runs due"| lock["try_lock()
atomic O_CREAT|O_EXCL"]
lock -->|"already held"| skip
lock -->|"acquired"| run["runner_for()
--scope from the entry"]
run --> unlock["unlock()"]
1Due, as a pure function
schedule/due.py
Look for: Cron Interval
parse_when due
parse_when() turns a schedule's when string into
either a Cron (five POSIX fields, plus the @hourly
/@daily/@weekly/@monthly shorthands) or
an Interval (every 15m). due() takes
that value plus the entry's last-run time and the current instant, and
returns every occurrence that fell between them — nothing else touches a
clock or a file, which is what makes it exhaustively property-testable
against fixed, hand-picked instants rather than the real one.
- POSIX weekday fields treat both 0 and 7 as Sunday;
Cron.matches() normalises to 0 before comparing, so a schedule
written either way behaves the same.
- An entry with no last-run runs once, now — the first tick after
edgar schedule add always fires, rather than waiting for the
next boundary.
2The overlap-safe tick
schedule/tick.py schedule/state.py
Look for: catch_up tick
State FileState try_lock
catch_up() turns a backlog of missed occurrences from
due() into what actually runs: skip drops all of
them (but still advances last-run, so the backlog is never revisited),
once collapses any backlog into a single run for the most
recent occurrence, and all runs once per missed occurrence.
tick() is the loop over every entry, written against the
State Protocol so its own tests use an in-memory fake and never
touch a real file.
FileState.try_lock() is the one atomic step —
os.O_CREAT | os.O_EXCL — that keeps two overlapping
edgar tick processes (cron or launchd firing twice) from
double-running the same entry. An earlier draft checked
"is it running?" and then set "now it is running" as two separate steps;
that has a race window a real overlapping tick can land in, which is why
the two collapsed into one atomic open.
- A lock held by another process is treated as "skip this tick", not as
an error: the next tick will pick the entry up again once the lock clears.
3schedules.toml, and the caveats a run inherits
schedule/parser.py schedule/run.py
Look for: ScheduleEntry load
append remove runner_for
append() (what edgar schedule add calls) parses
and validates the new entry before writing a single byte, then only ever
appends a new [[entries]] block — it never rewrites what is
already in the file, so a bad edit can never corrupt an existing entry.
remove() is the one explicit rewrite, and only when a human asks
for it by name. runner_for() is where a schedule entry's own
scope table and allowlist become the same
--scope caveats a typed --scope KEY=VALUE would
pass to run_prompt() — a scheduled run reuses the broker's own
seam rather than a second one, so it can never hold more authority than the
run that first created it (invariant 2).
- Every field has a sensible default:
mode is
read-only, catch_up is once — an
entry can be as short as a name, a when and a
prompt.
tomllib reads the file; there is no stdlib TOML writer, so
render() hand-serialises one entry's fields with
json.dumps() for correct string escaping.
4The host installer, and edgar tick
schedule/install.py schedule/cli.py
Look for: render_launchd render_cron
render_schtasks install project_id
command
install() and uninstall() route on
platform.system() to exactly one host mechanism: launchd on
macOS, crontab on Linux, schtasks on Windows — one
entry per project, named by project_id(), a stable hash of the
project's resolved path (not Python's own hash(), which is
randomised per process and would make uninstall() unable to
find what install() created). The pure render_*()
functions are what get tested on every platform: NFR-6 allows no test to
skip a branch based on the real host OS, so all three are exercised on every
CI run with platform.system() monkeypatched.
cli.py's command() is
edgar schedule list|add|remove|run,
edgar tick [--now ISO] and
edgar install-tick/uninstall-tick's whole
implementation, reached from cli/main.py by name through the
same lazy import_module seam as the controller and the
broker (NFR-12).
- None of these commands take
--cwd: the plist's
WorkingDirectory key, and a cd <project> &&
prefix on the cron and schtasks lines, carry the project directory instead.
5schedule_self: a run scheduling its own future run
schedule/store.py schedule/tool.py
Look for: SelfSchedules ScheduleSelfTool
MAX_PENDING MAX_PER_DAY MAX_DEPTH
A model that can schedule its own future run needs a leash, not a
policy statement [SCH-11]. ScheduleSelfTool narrows what it
asks for on three axes: mode never widens past the calling
session's own mode, the same narrow_mode() a subagent's
task call uses (PERM-8); scope and
allowlist attenuate the caller's live broker ticket, when one
exists, through the same narrowed() a subagent's own
scope argument reaches (CAP-5); and nesting is capped by
MAX_DEPTH, read from an environment marker only
run.py's runner_for() sets on a self-schedule's
own run — never something the model's tool arguments can set, so the
ceiling cannot be argued past. A pending-count cap and a rolling per-day
rate limit round out the guard. Entries land in the
self_schedules table of the project's edgar.db,
the same file grants and trust live in — never in
schedules.toml — and edgar schedule list tags
them [self].
Sizes
Lines of code: blank lines and comments do not count, docstrings do.
just loc prints the current totals.
| File | Lines of code | What it is |
schedule/due.py | ~73 | Cron and interval parsing, and the pure due() |
schedule/tick.py | ~48 | Catch-up policy, and the tick loop over every entry |
schedule/state.py | ~32 | The atomic file lock and last-run times, on disk |
schedule/parser.py | ~107 | Reading and append-only writing of schedules.toml |
schedule/run.py | ~43 | An entry's scope and allowlist, as a turn's caveats |
schedule/install.py | ~68 | launchd, cron and schtasks, behind one seam |
schedule/cli.py | ~95 | edgar schedule, tick and install-tick's whole implementation |
schedule/store.py | ~81 | The self_schedules table and its rate-limit queries |
schedule/tool.py | ~71 | The schedule_self tool and its three guardrails |
Source: just loc, which counts with
tests/support/budget.py. Rounded; the tour's test fails if a figure drifts
more than 25 lines of code from the code.