A small organisation keeps its work in a folder tree. One folder per project, a task list in each, a few shared files at the root. Some of the work is done by people in sessions, some by unattended runs on a schedule. Without a protocol three things go wrong, quietly. Two writers land in the same project and the slower one wins; a run does something irreversible because the task said so; a run finds something out and nobody who works next ever sees it.
This repository is the protocol, as code, with a comparison run that counts how often each of those things happens with and without it.
Everything here is invented. The projects atlas, birch and cedar, the
actors run-a, run-b and session-m, and every task text exist only in
example.py. No dependency beyond the Python standard library.
python -m venv .venv
.venv/Scripts/activate # Windows
# source .venv/bin/activate # Linux, macOS
pip install -e ".[dev]"
python -m runner compare # ungated vs gated, on a fresh example
python -m runner --root org init # write the example organisation
python -m runner --root org run run-a # one scheduled run
python -m runner --root org status # leases and pending decisions
python -m runner --root org decide <id> approve person
python -m runner --root org run run-a # executes what was approved
pytest # 16 testsacquire ──► work inside the project ──► complete ──► next project ──► integrate
│ │ │ │
│ skip once │ reversible: do it │ hashes of touched files │ handover.md
│ if leased │ irreversible: propose │ stop if anything else moved │ situation.md
│ stop if │ a decision, mark ? │ release │ under the
│ expired │ approved earlier: do │ │ root lease
- Acquire. One lease file per project, created with
O_CREAT | O_EXCL, so two actors cannot both succeed. A project that is leased is skipped once and recorded as skipped. A lease whose heartbeat is older than the catalogue'slease_secondsis not taken over. The run stops and a person runsrecoverafter looking. Silent takeover is how two writers end up in one folder. - Work. Tasks are lines in
tasks.md,- [ ] verb: text. A reversible verb is executed (here: a line inlog.md). A verb listed as irreversible incatalog.json(send,publish,delete,pay) is never executed by the run that meets it. It is appended todecisions.jsonlaspendingand the task is marked[?]. - Complete. The run compares the project folder against the snapshot
it took at acquire. Only files the run itself touched may differ. If
anything else changed under its lease, the run stops fail-closed and
leaves the lease on disk, so the next person sees who was there. On
success it records the changed files with before/after SHA-256 in
completions/<run>.jsonland releases the lease. - Integrate. After the last project, under a separate root lease, the
run compiles
handover.mdfrom every completion on disk (not only its own) and rewritessituation.md. Project work never touches root files; only the integrator does, one at a time.
A session (session open <actor> <project>) takes the same lease, so
runs skip that project until session close. A decision
(decide <id> approve|reject <actor>) is executed by a later run that owns
the project, and the record names who approved it. A run never executes a
decision it proposed itself, even if it was approved in between; the
proposing run's id is stored with the decision and checked.
python -m runner compare builds the example twice and starts the same
three actors at the same moment on each. Two scheduled runs and one person
who opens atlas, edits one task text, and saves. The ungated actors do
what an ordinary script does: read the task list, work through it, write
it back, execute whatever the task says. Then everything is counted from
the files, not from what the actors report.
variant | irreversible | executed without | executed after | tasks done | session edit | left in | still | findings in | decisions
| tasks | decision | decision | twice | survived | progress | open | handover | pending
ungated | 4 | 8 | 0 | 7-12 | no | 0 | 0 | 0 | 0
gated, after the first runs | 4 | 0 | 0 | 0 | yes | 0 | 0-4 | 5-8 | 3-4
gated, after decisions and 1-2 more runs | 4 | 0 | 3 | 0 | yes | 0 | 0 | 8 | 0
Reading the ungated row: every one of the four irreversible tasks was executed twice, once per run, with no record of anyone deciding. Between seven and all twelve tasks were done twice, depending on how far the first run got before the second read the file (12 on Linux, 7 on Windows in our runs). The person's edit was overwritten by a run that had read the file before the edit. There is no handover, so the eight findings live only in three log files.
Reading the gated rows: nothing irreversible ran without a decision. Three
decisions were approved and executed by a later run; the fourth (delete)
was rejected and the task stays marked [?]. The person's edit survived
because the runs skipped atlas while the session held it. All eight
findings are in handover.md.
The middle row varies between invocations, and the table says so. Which
actor wins atlas first depends on thread timing: if the session gets it,
both runs skip it and its four tasks stay open until the next run; if a run
gets it, the session waits. Both outcomes are the protocol working. The
final row is stable; tests/test_compare.py asserts it.
docs/design.md was written first. Two things changed while building:
- The design said a run checks that nothing outside its scope changed. That is wrong when two runs legitimately work different projects at the same time. The check is now: nothing inside the leased scope changed except what this run touched. The root files are protected by the root lease instead.
- The rule "never execute a decision in the run that proposed it" was first implemented by comparing timestamps. With one-second resolution, a decision proposed and approved within the same second as the next run's start was skipped wrongly; the comparison run caught it (one execution where three were expected). The decision now carries the proposing run's id, and that is what is checked.
- Real work. Executing a task means appending a line. Executing an
approved irreversible task means writing a file to
outbox/. The runner is about who may do what and when, not about the doing. - A network. One machine, one filesystem. The lease is a file; the atomic create that makes it safe holds for local filesystems and not reliably for network shares.
- Journalled writes. The per-project lease here is deliberately simpler than a global write journal. That mechanism, with its own comparison run, is in concurrent-file-writes; this repository sits one level above it and does not repeat it.
- Scheduling. Nothing here starts runs on a timer; the caller does.
- Model calls. The runs are plain Python. The protocol is what a model driven run would have to obey, and it is testable without one.
src/runner/
store.py catalogue, task lines, decision log, hashes
lease.py per-project lease files: acquire, heartbeat, release, recover
gates.py propose, decide, record execution
run.py the run, the integrator, sessions
example.py the invented organisation
compare.py the ungated actors, the measuring, the two scripts
cli.py init / run / session / decide / recover / status / compare
docs/design.md the design as written before the code
tests/
test_lease.py refusal, expiry, keys, recover
test_run.py gating, skipping, execution by a later run, fail-closed stop
test_compare.py the final row of the table
This repository was written with AI assistance. The protocol comes from an operating setup I run; the design decisions, the choice of what to measure and the review of the result are mine. The two deviations above came out of running the comparison, not out of getting it right first.
MIT. See LICENSE.