155 lines
7.6 KiB
Markdown
155 lines
7.6 KiB
Markdown
# Implementation: add-state-loop-lock
|
|||
|
|
|
||
|
|
## SCOPE
|
||
|
|
|
||
|
|
Closed the read-modify-write TOCTOU race flagged in
|
||
|
|
`add-status-brakes/ADVERSARIAL_BUG_REPORT.md` A6 and
|
||
|
|
`add-loop-runner/ADVERSARIAL_BUG_REPORT.md` A2/A7 by wrapping the
|
||
|
|
critical section in a cross-process `_loop_lock` (POSIX `fcntl.flock`,
|
||
|
|
Windows `msvcrt.locking`).
|
||
|
|
|
||
|
|
## FILES TOUCHED
|
||
|
|
|
||
|
|
- `scripts/status.py`
|
||
|
|
- Added `import contextlib`.
|
||
|
|
- Added `_LOOP_LOCK_ENV_BYPASS = "AUTOMATON_NO_LOOP_LOCK"` constant.
|
||
|
|
- Added `_loop_lock(loop_path, exclusive=True)` context manager (with
|
||
|
|
docstring + per-loop granularity + env-bypass for the
|
||
|
|
runner-spawns-check-gate subprocess case).
|
||
|
|
- Wrapped `cmd_pause_loop`'s read-modify-write block in
|
||
|
|
`with _loop_lock(loop_path):` (re-read state inside the lock before
|
||
|
|
the pause branch decision and write).
|
||
|
|
- Wrapped `cmd_resume_loop`'s read-modify-write block the same way.
|
||
|
|
- Wrapped `cmd_approve_loop`'s read-modify-write block the same way.
|
||
|
|
- Wrapped `cmd_check_gate`'s evaluate-gates-then-maybe-halt-write block
|
||
|
|
in `with _loop_lock(loop_path):`. Re-read state inside the lock.
|
||
|
|
`_halt_loop` itself is left unwrapped (the lock is held at the
|
||
|
|
caller; re-acquiring would deadlock).
|
||
|
|
- `--create-loop` path is intentionally unwrapped (D-L3): no prior
|
||
|
|
state to race against; create is name-unique-refused.
|
||
|
|
|
||
|
|
- `scripts/loop-runner.py`
|
||
|
|
- Added `import contextlib`.
|
||
|
|
- Added `_LOOP_LOCK_ENV_BYPASS = "AUTOMATON_NO_LOOP_LOCK"` constant.
|
||
|
|
- Added `_loop_lock(loop_path, exclusive=True)` context manager
|
||
|
|
(without env bypass — the runner is the lock holder, not the bypass
|
||
|
|
consumer).
|
||
|
|
- Modified `_run_json` to accept an optional `env` dict passed through
|
||
|
|
to `subprocess.run`.
|
||
|
|
- Modified `_gate` to pass `env={**os.environ, _LOOP_LOCK_ENV_BYPASS:
|
||
|
|
"1"}` so the spawned `status.py --check-gate` subprocess's
|
||
|
|
`_loop_lock` becomes a no-op (avoiding a self-deadlock on the same
|
||
|
|
flock). Env var is scoped to `_gate`'s subprocess only — the
|
||
|
|
Harness subprocesses (Implement/Verify/Orchestrate) do NOT inherit
|
||
|
|
it, so any `status.py --transition` the harness transitively
|
||
|
|
invokes will lock normally.
|
||
|
|
- Wrapped cmd_tick's body in `with _loop_lock(loop_path):`. The fast
|
||
|
|
untracked early-return still happens OUTSIDE the lock (no `.state.loop`
|
||
|
|
to race against). Inside the lock, state is re-read fresh; if it
|
||
|
|
transitioned to untracked between the unlocked read and the lock
|
||
|
|
acquire, we return `SKIP untracked`.
|
||
|
|
|
||
|
|
## D-ITEMS Locked
|
||
|
|
|
||
|
|
- D-L1: blocking acquire, no timeout in v1.1 (ticks short; operator
|
||
|
|
notices via `--loop-list` stale `last_tick_at`).
|
||
|
|
- D-L2: `.state.lock` is per-loop, lives in the loop's own dir, not
|
||
|
|
garbage-collected.
|
||
|
|
- D-L3: `--create-loop` path is unwrapped.
|
||
|
|
- D-L4: stdlib only (`fcntl` POSIX, `msvcrt` Windows). No `filelock`.
|
||
|
|
- D-L5: existing atomic write semantics retained (defense-in-depth).
|
||
|
|
- **D-L6 (new, this task)**: subprocess-deadlock avoidance via env-var
|
||
|
|
bypass. `_loop_lock` in `status.py` checks `$AUTOMATON_NO_LOOP_LOCK`. If
|
||
|
|
set, it yields without flocking (trusting the caller's outer lock). The
|
||
|
|
runner sets this env var ONLY in the `--check-gate` subprocess's env;
|
||
|
|
harness subprocesses inherit a clean env. Not user-settable.
|
||
|
|
|
||
|
|
## NOT RE-ENTRANT
|
||
|
|
|
||
|
|
`_loop_lock` is not re-entrant across processes. POSIX `flock` is
|
||
|
|
per-fd-per-process; a second runner process blocks cleanly until the
|
||
|
|
first releases. Nested `_loop_lock` within the same `with` block is
|
||
|
|
forbidden (would deadlock). Audited all callsites — none nest.
|
||
|
|
|
||
|
|
## NESTED-LOCK AUDIT
|
||
|
|
|
||
|
|
Status.py callsites:
|
||
|
|
- `cmd_pause_loop`: acquires once, no nested acquires inside.
|
||
|
|
- `cmd_resume_loop`: acquires once, calls `_enable_schedule` AFTER the
|
||
|
|
`with` block (outside the lock — keeps critical section tight).
|
||
|
|
- `cmd_approve_loop`: acquires once, calls `_enable_schedule` AFTER the
|
||
|
|
`with` block.
|
||
|
|
- `cmd_check_gate`: acquires once; inside calls `_halt_loop` (which does
|
||
|
|
`_write_state_loop` + `_append_tick_log` + `_disable_schedule`). None of
|
||
|
|
those re-acquire the lock. Safe.
|
||
|
|
|
||
|
|
Loop-runner.py callsites:
|
||
|
|
- `cmd_tick`: acquires once. Inside, calls `_gate` (subprocess: status.py
|
||
|
|
acquires its own lock, but env-var bypass makes it a no-op — safe).
|
||
|
|
Calls `_halt_loop` (does not re-acquire). Calls
|
||
|
|
`_ensure_worktree`→`_git_run` (subprocess `git`, doesn't touch
|
||
|
|
`.state.lock`). Calls `_invoke_harness` (subprocess: harness calls
|
||
|
|
unknown code, but `loop-runner` does NOT pass `AUTOMATON_NO_LOOP_LOCK`
|
||
|
|
to the harness env, so any `status.py` the harness transitively
|
||
|
|
invokes will lock normally — and the parent runner holds the loop's
|
||
|
|
outer lock, so those transitions block until the tick releases. This
|
||
|
|
is the intended serialization).
|
||
|
|
Calls `_append_tick_log` and `_write_state_loop` inside the lock —
|
||
|
|
safe (neither re-acquires).
|
||
|
|
|
||
|
|
## LATE BUG FIXED INLINE
|
||
|
|
|
||
|
|
While running the broken test file from the first py_compile pass, I
|
||
|
|
hit `NameError: name 'loop_file' is not defined` in `status.py._loop_lock`.
|
||
|
|
I'd typed `lock_file = loop_path / ".state.lock"` then `os.open(str(loop_file), ...)`
|
||
|
|
— wrong variable name. Fixed to `os.open(str(lock_file), ...)`. Caught
|
||
|
|
by manual `status.py --check-gate` invocation before pytest; never
|
||
|
|
reached CI.
|
||
|
|
|
||
|
|
## TESTS
|
||
|
|
|
||
|
|
New file `tests/test_state_loop_lock.py` — 7 tests:
|
||
|
|
|
||
|
|
1. `TestSerializeConcurrent::test_lock_serializes_concurrent_writes`
|
||
|
|
— two threads, read→sleep(0.05)→write under the lock. Asserts one
|
||
|
|
thread's enter time is >= the other's exit time (serialization).
|
||
|
|
2. `TestReleasesClean::test_lock_releases_on_clean_exit` — acquire,
|
||
|
|
release, re-acquire succeeds immediately.
|
||
|
|
3. `TestReleasesOnException::test_lock_releases_on_exception` —
|
||
|
|
`with _loop_lock: raise ValueError` then re-acquire succeeds.
|
||
|
|
4. `TestPerLoop::test_lock_is_per_loop` — two threads holding locks on
|
||
|
|
different loop dirs concurrently; B's acquire completes within 1s
|
||
|
|
while A holds a different lock.
|
||
|
|
5. `TestNoLockOnCreate::test_no_lock_on_create_loop` — `--create-loop`
|
||
|
|
does NOT leave a `.state.lock` (D-L3); first `--check-gate` does.
|
||
|
|
6. `TestPauseSerializedWithConcurrentHolder::test_pause_loop_serialized_with_concurrent_read`
|
||
|
|
— a thread holds `_loop_lock` for 0.1s; main thread invokes
|
||
|
|
`status.py --pause-loop`. Asserts pause completed after the holder
|
||
|
|
released (i.e. `--pause-loop` blocked on flock).
|
||
|
|
7. `TestRunnerHoldsLockAcrossStateWrite::test_runner_tick_holds_lock_across_state_write`
|
||
|
|
— monkeypatches `_invoke_harness`, `_gate`, `_context_floor_ok`,
|
||
|
|
`_ensure_worktree`, `_find_work`, `_read_task_brief`, etc. A thread
|
||
|
|
runs `runner_mod.cmd_tick`; the implement-stub blocks on a
|
||
|
|
`threading.Event` until tick_can_finish is set. Meanwhile main thread
|
||
|
|
starts `status.py --approve --loop`. Asserts `--approve` hadn't
|
||
|
|
completed BEFORE tick_can_finish was set (proves the lock is held
|
||
|
|
across the harness subprocess), then sets tick_can_finish and asserts
|
||
|
|
approve completed after.
|
||
|
|
|
||
|
|
## TEST RESULTS
|
||
|
|
|
||
|
|
- `python3 -m py_compile scripts/status.py scripts/loop-runner.py` ✓
|
||
|
|
- `python3 -m pytest tests/test_state_loop_lock.py -v` — 7 passed
|
||
|
|
- `python3 -m pytest tests/ -q` — **447 passed** (was 440; +7 new; 0
|
||
|
|
regressions).
|
||
|
|
- Manual: `python3 scripts/status.py --create-loop t --from-template
|
||
|
|
ci-triage && python3 scripts/status.py --check-gate t` — succeeds and
|
||
|
|
leaves `.state.lock` behind on first acquire.
|
||
|
|
- Manual env-bypass: `AUTOMATON_NO_LOOP_LOCK=1 python3 scripts/status.py
|
||
|
|
--check-gate t` — succeeds (bypass path exercised).
|
||
|
|
|
||
|
|
## PIPELINE TO COMPLETION
|
||
|
|
|
||
|
|
Driven through `research -> research:awaiting_approval -> research:approved
|
||
|
|
-> implement -> code_review`. Next: code_review awaited approval -> bug_find
|
||
|
|
-> adversarial_bug_find -> doc_review -> referee -> complete.
|