Files
automaton/tasks/complete/add-state-loop-lock/IMPLEMENTATION.md
T

155 lines
7.6 KiB
Markdown
Raw Normal View History

# 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.