fix(checkpoint): scope every approval gate to one run - #13
Conversation
The gate an R2 task waited on was named after `session_ref`: `gates/gate_<session_ref>-plan-<index>.json`. That is a human label — `justai run` leaves it empty unless `--session` is passed, the module default is the shared string `sprint-2`, and the dashboard reused `dashboard-<epoch-second>`. Two ordinary concurrent runs therefore agreed on a label and a plan index, agreed on a filename, and waited on the same file. One operator approval released both, including the run the operator never looked at. The R1 veto collided the same way, in the other direction, and gate cleanup was free to delete a concurrent run's pending approval. Reproduced with two real interpreters entering through the shipped CLI: against the previous implementation the unapproved run prints `R2 approved by operator` and executes, on a decision nobody made about it. A run id is now a UUID and nothing else — not derived from the label, the clock, the process, or the goal, since each of those collides between two runs started together, and never read from the environment, which would hand one run's identity to every later run in the API server's single interpreter. Gates live at `gates/<run_id>/plan-<index>.json`, records are written atomically and carry the run they decided, a record contradicting its own location is read as no decision rather than as an approval, and cleanup takes a run id and nothing else so it cannot express a wider target. Gates in the old layout are not consulted at all, in either direction: a file that names no run decides nothing. `session_ref` stays what it always was — a label for tracing and memory that scopes nothing. `--run-id` resumes a run against gates already on disk; omitting it mints a fresh identity. Fail-closed behaviour is unchanged and pinned: R2 still waits for an approval, R3 still never proceeds, an unreadable gate is never an approval, and auto mode still skips only the R1 wait. Callers of the previous `evaluate(task, task_id=...)` string contract are migrated. `test_auto_mode_isolation.py` keeps the PR #8 guard intact by naming the run each veto is planted for, and its API case now pauses the run before its gate so both vetoes are genuinely pre-planted rather than racing it. Suite: 494 passed, 14 subtests, in both collection orders (444 at base). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NN4rzeBo2F6eu7dhTMhPPG
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Scoping every gate to a run id fixed which file a decision lands in. Three things around that file still lost it. An approval that arrived before the run recorded its gate was destroyed by the recording. The R2 branch announced the gate and then wrote `pending` to it unconditionally, so everything already on disk went with it — a `--run-id` resume approved before the restart, a dashboard operator using the run id `POST /api/run` returns before the run reaches a gate, or anyone answering the Discord notification inside its ten-second send. The run then waited forever for a decision it had been given and had itself deleted. The marker is now created rather than written, via `os.link`, so it only appears where nothing has decided yet. A record that cannot speak for this run is still no decision, so an R2 task holding one keeps waiting, and `echo > gate` still resolves it. Cleanup unlinked the lock file while holding it. `flock` excludes the holders of one inode, so that ended exclusion rather than the run: a process queued on that file woke holding an inode nothing could reach by name, the next process found the name free and created a second lock, and both then drove the same run's gates at once. Reproduced with two real interpreters, both printing LOCK-HELD for one run. Cleanup now removes the run's records and keeps the lock, and `run_gate_lock` re-takes its lock if the file it acquired is no longer the one at the path — which is what makes an external /tmp reaper, or the sweep below, safe. It is a revalidation, not a lease: nothing expires and a live holder is never displaced. Keeping the lock means the directory outlives the run, and a run interrupted before cleanup already left one. `sweep_gate_dirs` bounds both: it collects directories that hold no gate record once they are older than `JUSTAI_GATE_TOMBSTONE_TTL` (default one hour), and each run sweeps after cleaning up. It takes the run's lock without waiting, renames the directory out of the namespace while holding it, and deletes only after releasing, so no unlink ever happens under a held lock. A run still going, still holding a decision, or holding a file JustAi did not write is never swept — an interrupted R2 gate is a pending approval nobody answered and is what `--run-id` resumes against, so those are retained deliberately and indefinitely. `tests/test_gate_lifecycle.py` adds 23 tests, 20 of which fail against the parent commit: the preapproved resume and the approval written inside the notification window both hang there, and the queued-lock case exhibits two simultaneous holders. Four existing assertions that encoded "cleanup removes the directory" now assert what cleanup honestly leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RCzyihd6aGZ95zH12LboGv
An approval is a decision to execute one task in one run, once. Scoping gates to a run id fixed which file a decision lands in; it left four ways the decision could still be acted on twice, thrown away, or never checked. One approval released two executions. The checkpoint stage held the run's lock for the gate loop and released it there, so everything the decision authorises ran unserialised: a second `--run-id` process queued for that lock, woke the moment the first stage ended, read the same approval — cleanup had not yet re-taken the lock to remove it — and dispatched the same work. `own_run` now claims a run for one process from before the first stage until after the terminal cleanup, and refuses a second process outright, because a queued one executes the same run against the same decision a moment later. Cleanup moves after dispatch and takes the claim instead of the lock, which `flock` would block even inside one process. The lock file is still never unlinked. The sweep deleted directories for their names. Anything under `gates/` called `.trash-*` was removed without this module ever having written one, so an operator's own `.trash-operator-owned/keep.txt` went with it; and a directory was parked under `.trash-<run_id>`, one name per run, with whatever already sat there deleted to make room. A directory now leaves the namespace under `.trash-<run_id>-<fresh uuid>` carrying a marker that names this module, the run, and that name — and only a directory whose name, marker and contents all agree is deleted, entry by entry, never through a symbolic link. A run killed at its gate was kept for the life of the machine. "Holds a record" meant "never collected, at any age", which is right for a gate an operator may still answer and wrong as an end state. `run_state` names what a directory is — active, resumable, abandoned, terminal, foreign — and `prune_abandoned_runs` collects the abandoned ones on a window measured in days plus a count bound. A run is dated by its records, since an approval is written into an existing file and leaves the directory's own timestamp alone. None of this was checked by anything. Every green check a pull request could show was static analysis, a Node build, or a preview deploy, so a change that broke the fail-closed behaviour still showed all green. `python-ci.yml` runs ruff and the full suite on 3.12 and 3.13, with the gate and concurrency suite named separately, against the pull request's head SHA rather than the merge preview GitHub synthesises from it. Evidence: 557 passed (ruff clean) on CPython 3.13.12 and 3.12.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SToNzf8WL9ciGotn5UoDzV
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
This PR fixes a concurrency defect in JustAi’s checkpoint approval gates by scoping every operator gate and cleanup action to a unique per-run UUID, preventing cross-run approval/veto collisions and unsafe concurrent resumes.
Changes:
- Introduces
run_identity.pyand threads a validatedrun_idthrough CLI/API/orchestrator, with gates stored atgates/<run_id>/plan-<index>.json. - Hardens gate lifecycle and concurrency: claim-only pending markers, per-run ownership (
own_run) to prevent double execution, safe cleanup + bounded sweeping/pruning of gate directories. - Adds Python CI workflow and extensive multi-process regression tests covering identity, lifecycle, retention, and run ownership semantics.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
tests/test_sprint8.py |
Updates CLI run test to assert run_id=None for the ordinary (fresh identity) case. |
tests/test_sprint7.py |
Updates checkpoint tests to use GateIdentity with fresh run ids per test. |
tests/test_slice2.py |
Makes trace-event assertion resilient to formatting/wrapping changes. |
tests/test_orchestrator.py |
Isolates gate dirs per test and updates checkpoint tests to the new gate identity API. |
tests/test_llm_checkpoint_cli_coverage.py |
Updates gate read/write roundtrip test to use GateIdentity + per-test gate root + cleanup_run. |
tests/test_gate_run_ownership.py |
Adds multi-process tests ensuring one process owns a run end-to-end (no queued resumes / double execution). |
tests/test_gate_run_identity.py |
Adds comprehensive tests for run id validation, gate scoping, legacy gate ignoring, cleanup containment, and fail-closed behavior. |
tests/test_gate_retention.py |
Adds tests for safe sweeping/pruning semantics (no deletion by name, bounded retention, symlink safety). |
tests/test_gate_lifecycle.py |
Adds multi-process lifecycle tests for “no overwrite”, “no unlink under lock”, and directory lifecycle invariants. |
tests/test_gate_identity_collision.py |
Adds the two-interpreter reproduction proving one approval cannot release two concurrent runs. |
tests/test_completion_integrity.py |
Updates local stub signature to match the new checkpoint gate identity shape. |
tests/test_ci_workflows.py |
Adds tests asserting Python CI exists, runs on PRs, pins actions, and names critical suites. |
tests/test_auto_mode_isolation.py |
Updates auto-mode isolation tests to plant vetoes scoped to explicit run_ids and remove API races. |
tests/gate_lifecycle_child.py |
Adds a child-process helper for lifecycle tests (hold lock / clean / sweep). |
tests/gate_harness.py |
Adds shared harness for multi-process gate tests, stubbing orchestration while keeping gates real. |
tests/gate_collision_child.py |
Adds child-process entrypoint that runs through justai.cli.main for realistic identity reproduction. |
README.md |
Documents run ids, gate paths, resume semantics, and gate directory lifecycle/retention behavior. |
justai/run_identity.py |
Implements minting and validation for run ids (UUID-based, canonical hex; rejects labels/timestamps). |
justai/orchestrator.py |
Threads run_id through orchestration, scopes gates via GateIdentity, and performs cleanup/sweep/prune under run ownership. |
justai/cli.py |
Adds --run-id, passes it through to orchestrator, and handles invalid ids / active-run refusal cleanly. |
justai/checkpoint.py |
Replaces string gate ids with GateIdentity, adds per-run locking/ownership, atomic gate IO, and bounded retention collectors. |
justai/api.py |
Mints a UUID run id server-side and returns it immediately alongside session_ref for operator gating. |
docs/TESTING.md |
Documents the new Python CI workflow and what it does/does not prove. |
docs/ARCHITECTURE.md |
Updates architecture boundaries/flow to include run identity and run-scoped gates + ownership. |
CHANGELOG.md |
Records the signature/layout changes and new CI coverage for Python. |
.github/workflows/python-ci.yml |
Adds a pinned, PR-running Python CI workflow running ruff + named gate suite + full pytest on 3.12/3.13. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| lock_file = directory / LOCK_FILENAME | ||
| try: | ||
| fd = os.open(lock_file, os.O_CREAT | os.O_RDWR, 0o600) | ||
| except OSError: | ||
| return None |
The defect
The gate an R2 task waits on was named after
session_ref:session_refis a human label.justai runleaves it empty unless--sessionis passed, the module default is the shared string
sprint-2, and thedashboard minted
dashboard-<epoch-second>— two runs started in the samesecond produced the same string. Two ordinary concurrent runs therefore agreed
on a label and a plan index, agreed on a filename, and waited on the same file.
One operator approval released both — including the run the operator never
looked at. The R1 veto collided the same way in the other direction, and gate
cleanup was free to delete a concurrent run's pending approval.
Reproduction (red before green)
tests/test_gate_identity_collision.pyruns two real interpreters enteringthrough
justai.cli.main, so the identity under test is the one the shippedCLI derives. Against the previous implementation, on a clean export of the base
commit, the unapproved run reports:
and executes — cleared through a hard gate by a decision nobody made about it.
The test asserts that consequence before it asserts the mechanism, so a
regression fails on the behaviour rather than on two paths matching.
Both parametrizations fail on base (
ordinary-cli-defaults, i.e. no--sessionat all, andreused-session-ref) and pass here. A positive controlin the same test releases the second run the moment its own gate is written, so
"still waiting" is the gate holding it and not the test failing to observe a
release.
The fix
A run id is a UUID and nothing else — not derived from the label, the clock,
the process, or the goal, because each of those collides between two runs
started together. It is never read from the environment, which would hand one
run's identity to every later run in the API server's single interpreter (the
same rule, and the same reason, as auto mode in #8).
gates/<run_id>/plan-<index>.json, identified by aGateIdentityof run id plus plan index.location — another run's id, another plan index — is read as no decision,
not as an approval. Fail-closed: the R2 task keeps waiting.
{"status": "approved"}still works. The path already scopesit, and demanding metadata the operator was never told to write would break
the documented workflow without making anything safer.
that names no run decides nothing.
cleanup_runtakes a run id and nothing else, so it cannot express a widertarget. A finished run cannot delete a concurrent run's pending approval.
--run-idresumes a run against gates already on disk; omitting it mints afresh identity, which is what an ordinary run wants.
session_refstays what it always was: a label for tracing and memory thatscopes nothing.
Gate lifecycle (added after review)
Scoping fixed which file a decision lands in. Review found three ways the
lifecycle around that file still lost it. All three are fixed in
8dd5be3,each with a real-process counterexample that fails against
16b4734.1. An approval that arrived early was overwritten
The R2 branch announced its gate and then wrote
pendingto itunconditionally, destroying whatever was already on disk — which is exactly
the decision it was about to wait for:
--run-idresume approved before the restart;POST /api/runreturns before therun reaches a gate, which is the reason that endpoint returns it;
The run then waited forever for a decision it had been given and had itself
deleted.
The marker is now created, never written over — one
os.link, which failsrather than replaces, so no reader catches it half-created and no writer loses
a race. Preserving what is on disk costs nothing in the other direction: a
record that cannot speak for this run (another run's id, another plan index,
malformed) reads as no decision whether it is kept or overwritten, so an R2
task holding one keeps waiting, and
echo '{"status":"approved"}' > gatestillresolves it.
2. Cleanup unlinked the lock while holding it
flockexcludes the holders of one inode, so unlinking the lock file underthe lock ended exclusion rather than the run. A process queued on that file
woke holding an inode nothing could reach by name; the next process found the
name free, created a second lock, and both then drove the same run's gates at
once.
Two real interpreters, both reporting they hold one run's lock:
cleanup_runnow removes the run's records and keeps the lock.run_gate_lockadditionally re-takes its lock if the file it acquired is nolonger the one at the path — which is what makes an external
/tmpreaper, orthe sweep below, safe. That is a revalidation, not a lease: nothing expires,
nothing is stolen, and a live holder is never displaced.
3. Directories leaked, and nothing collected them
Keeping the lock means a finished run's directory outlives it, and a run
interrupted before cleanup already left one.
sweep_gate_dirs()bounds both:it collects directories holding no gate record once they are older than
JUSTAI_GATE_TOMBSTONE_TTL(default one hour), and each run sweeps aftercleaning up.
The ordering is the design, and it is why no lease is needed:
a parked R2 gate holds it for as long as the operator takes;
What is honestly not swept, at any age:
--run-idresumes against. Retained deliberately and indefinitely — an operator who is done with one removes the directory.gates/not named by a run idThe one-hour default is a margin, not a retention policy: a spent directory's
only content is a zero-byte lock, so the delay guards the microseconds another
process spends between creating a directory and locking the file inside it.
Fail-closed behaviour is unchanged, and pinned
R2 still waits for an approval (including in auto mode), R3 still never
proceeds, an unreadable or malformed gate is never an approval, and auto mode
still skips only the R1 wait. Preserving a refused record does not soften any
of that —
test_a_record_that_decides_nothing_leaves_the_gate_closedpins aforeign run id, a foreign plan index, and unreadable JSON as still closed.
Migrated callers
The previous
evaluate(task, task_id=...)string contract had 15 callers acrossfive test modules.
tests/test_auto_mode_isolation.py— the #8 auto-mode guard— keeps its strength: each veto now names the run it is planted for, and the
API case pauses the run before its gate so both vetoes are genuinely
pre-planted rather than racing it. Verified by mutation: dropping the vetoes
fails 4 of its tests.
Four existing assertions encoded the old "cleanup removes the directory"
contract and now assert what cleanup honestly leaves: no gate records, and a
lock that outlives them.
Evidence
16b4734)ruff check justai/ tests/tests/test_gate_lifecycle.pyvs16b4734tests/test_gate_lifecycle.pyhereThe 3 that already passed at
16b4734are the ones that were already true:fail-closed reads of a refused record, the operator override, and cleanup's
containment.
Known sharp edges
run_gate_lockis still not reentrant — each call opens its own descriptorand
flockblocks a second one even within a process — so taking it aroundcleanup_run, which locks internally, self-deadlocks. The one call site iscorrect and both docstrings say so.
_claim_gateusesos.link, which every plausible runtime root supports(tmpfs, ext4, xfs, overlayfs, NFS) but FAT/exFAT and some FUSE mounts do not.
A runtime root on one of those would raise rather than silently misbehave. No
fallback is wired, because an untested branch for a filesystem nobody points
JUSTAI_RUNTIME_ROOTat is worse than a loud error.GATE_SIGNAL_DIRnow leave ~20 empty directories under the real
/tmp/justai/gatesper suiterun, where cleanup used to
rmdirthem. They are lock-only, and any laterrun sweeps them once they are an hour old. Pointing
JUSTAI_RUNTIME_ROOTata temp dir for the suite would remove the residue; that is test
infrastructure and is not changed here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RCzyihd6aGZ95zH12LboGv