A Tour of the Harness · v4 · M16

Scheduling

Unattended runs that cannot run away on their own. Four stops, about fifteen minutes.

Built M16, v4
Kept in sync by a test

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.

FileLines of codeWhat it is
schedule/due.py~73Cron and interval parsing, and the pure due()
schedule/tick.py~48Catch-up policy, and the tick loop over every entry
schedule/state.py~32The atomic file lock and last-run times, on disk
schedule/parser.py~107Reading and append-only writing of schedules.toml
schedule/run.py~43An entry's scope and allowlist, as a turn's caveats
schedule/install.py~68launchd, cron and schtasks, behind one seam
schedule/cli.py~95edgar schedule, tick and install-tick's whole implementation
schedule/store.py~81The self_schedules table and its rate-limit queries
schedule/tool.py~71The 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.