- **Restore 82 completed tasks** from tasks/complete/ back to tasks/ top level (all <7 days old per the cleanup policy; premature bulk archive was fixed). - **Dashboard: fix scroll-reset on auto-refresh** — renderBoard rebuilds the board via innerHTML every 2s, destroying each column-body's scrollTop. Now snapshots column-body scrollTop + board.scrollLeft + view.scrollTop before rebuild and restores after (matched by PHASE_GROUPS index). - **Dashboard UI additions** (pre-existing unstaged work): approval section cards, transition buttons, inline artifact editor (textarea for writing missing SPEC/VERDICT/etc from the detail modal). - **Bind ornith as Implement model** — config.md: Model explicit to omlx/Ornith-1.0-35B-4bit-mlx, context window 32768. Interactive autopilot already used ornith via opencode default; now explicit. - **Fix cleanup stub** — automaton-cleanup.sh had a stale --project arg pointing at a pytest temp dir (test isolation leak). Rewired to point at ~/.automaton. - **Fix plist-isolation test** — test asserted host plist doesn't exist, but a real install creates it. Now snapshots mtime before run, asserts unchanged after (only a write during the test counts as bleed). - **New Playwright smoke test** (tests/test_dashboard_ui.py) — 2 tests: board renders tasks, column scroll survives auto-refresh tick. Verified the test fails without the scroll fix (scrollTop resets to 0). Skipped via importorskip when playwright is absent (main CI stays green). - **Clarify SI loop scope in README** — new-project onboarding section documents the framework-scoped self-improvement loop and options (leave/pause/create project loop). - **CHANGELOG** documents all changes including the known model-divergence gap (mde tasks marked complete but per-role model binding was never implemented).
158 lines
10 KiB
Markdown
158 lines
10 KiB
Markdown
# Add `.state.loop` File Lock
|
|
|
|
Close the tick/approve TOCTOU races flagged in `add-status-brakes/ADVERSARIAL_BUG_REPORT.md` (A6) and `add-loop-runner/ADVERSARIAL_BUG_REPORT.md` (A2, A7). Both reports name the same v1.1 fix: a file lock on `.state.loop` that serializes read-modify-write cycles across processes.
|
|
|
|
This is a v1.1 hardening task. No new features; no user-visible CLI change. Pure robustness.
|
|
|
|
## Goal
|
|
|
|
Add a cross-platform file-lock helper that wraps every `_read_state_loop` → mutate → `_write_state_loop` cycle in `status.py` and `loop-runner.py`. Concurrent ticks (two schedulers firing the same loop) and concurrent approve-vs-tick writes will serialize instead of overwriting each other.
|
|
|
|
## Background — the race
|
|
|
|
`_write_state_loop` already does atomic tmp-then-`replace` (status.py:1662). The write itself is atomic. The race is **read-modify-write**:
|
|
|
|
1. Tick A reads `.state.loop` (count=9).
|
|
2. Tick B reads `.state.loop` (count=9).
|
|
3. Tick A passes `--check-gate` (count=9 < max=10).
|
|
4. Tick B passes `--check-gate` (count=9 < max=10).
|
|
5. Tick A runs harness, writes count=10.
|
|
6. Tick B runs harness, writes count=10. (Still bounded, but two ticks ran for one increment.)
|
|
|
|
The `--approve --loop` write vs a concurrent tick's iteration increment is the same shape (status.py A6): approve wins, tick's increment is lost.
|
|
|
|
The lock closes both by serializing the full read-modify-write critical section.
|
|
|
|
## Requirements
|
|
|
|
### R1. New helper: `_loop_lock(loop_path, exclusive=True)`
|
|
|
|
A context manager (`contextlib.contextmanager` or `__enter__/__exit__` class) that:
|
|
|
|
- Opens `<loop_path>/.state.lock` (creating it if absent) and holds an OS-level **exclusive** lock for the duration of the `with` block.
|
|
- On exit: releases the lock. The `.state.lock` file may be left on disk (it's tiny and idempotent across runs); not garbage-collected.
|
|
- **Blocking acquire**: a second acquirer waits until the first releases. No timeout in v1.1 (loop ticks are short; if a tick wedges, the operator notices via `--loop-list` showing stale `last_tick_at` and intervenes manually).
|
|
- **Cross-platform**:
|
|
- POSIX (`sys.platform != "win32"`): `fcntl.flock(fd, LOCK_EX)` for acquire, `fcntl.flock(fd, LOCK_UN)` for release.
|
|
- Windows (`sys.platform == "win32"`): `msvcrt.locking(fd, LK_LOCK, 1)` blocking acquire on a 1-byte region; release via `msvcrt.locking(fd, LK_UNLCK, 1)`. `msvcrt` is stdlib on Windows.
|
|
- On `BrokenPipeError`/`IOError` from a vanished loop dir mid-hold: surface a clear error `"loop dir vanished mid-lock"` and exit nonzero. Don't mask it.
|
|
- File handle is kept open for the life of the `with`; closed in `finally`.
|
|
|
|
### R2. Wrap every read-modify-write cycle in `status.py`
|
|
|
|
Locate each `_write_state_loop(...)` callsite in `scripts/status.py` (lines 1721, 1948, 1971, 1993) and confirm each is preceded by a `_read_state_loop(...)` that seeds it. Wrap the read+mutate+write block in `with _loop_lock(loop_path):`. Do NOT wrap the `--create-loop` path (status.py:1819) — there is no prior state to race against; duplicate create is already refused by name (R-of-create-task).
|
|
|
|
Affected commands in status.py:
|
|
- `cmd_check_gate` (halt write) — status.py:1721
|
|
- `cmd_pause_loop` (paused) — status.py:1948
|
|
- `cmd_resume_loop` (running) — status.py:1971
|
|
- `cmd_approve_loop` (halt clear + resumed_count++) — status.py:1993
|
|
|
|
The lock must cover the read that precedes each of these writes, not just the write. (Wrapping only the write wouldn't close the race — that just makes writes atomic, which they already are.)
|
|
|
|
### R3. Wrap every read-modify-write cycle in `loop-runner.py`
|
|
|
|
In `scripts/loop-runner.py`, wrap the read+mutate+write in `cmd_tick`'s step 10 ("atomic state write" per `design/loops/technical.md` §7) and any other `_write_state_loop` callsite (lines 125, 244, 684). Mirror the same `with _loop_lock(loop_path):` pattern.
|
|
|
|
The lock **must** be held across:
|
|
- The `--check-gate` subprocess call's effective decision (i.e. the read of `iteration_count`/`status` it makes), AND
|
|
- The subsequent state mutation write.
|
|
|
|
Since `--check-gate` runs as a subprocess and reads `.state.loop` itself, the lock acquired by the runner blocks the *runner's own* subsequent subprocess read from racing a concurrent approve write, but it cannot lock the *subprocess* itself. This is acceptable: the lock scope we control is the parent runner's read-modify-write; a concurrent tick would block on `.state.lock` at the parent-runner level and the gate call inside it would still see consistent state.
|
|
|
|
### R4. Idempotence and no-op fast path
|
|
|
|
If a command reads `.state.loop`, discovers no mutation is needed (e.g. `--pause-loop` on an already-paused loop), it still releases the lock cleanly. The lock MUST always be released, even on early-return code paths inside the `with` block. Use `try/finally` inside the context manager, not inside callers.
|
|
|
|
### R5. Lock file location
|
|
|
|
`.state.lock` lives in the loop's own dir (`<loop_path>/.state.lock`), NOT the framework root. Rationale: per-loop granularity; a lock on loop A's tick must not block loop B's approve. Untracked loops (no `.state.loop`) still get a `.state.lock` file on first acquire — that's fine; the file is empty.
|
|
|
|
### R6. No new pip deps
|
|
|
|
Use stdlib only: `fcntl` (POSIX), `msvcrt` (Windows), `contextlib`, `sys`, `os`. Both are already conditionally imported elsewhere in the framework (`platform.system()` dispatch in task 5).
|
|
|
|
### R7. Compatibility with existing atomic write
|
|
|
|
The existing `_write_state_loop` tmp-then-replace stays. The lock adds a coarse mutex around the read-modify-write cycle; the atomic write provides last-write-wins safety even if some future code path forgets the lock. Defense-in-depth; no regression to the existing atomic semantics.
|
|
|
|
## Non-goals
|
|
|
|
- No `--claim-loop-task` (that's task 6).
|
|
- No timeout / deadlock detection — out of scope; ticks are short. If a future tick grows long, address then.
|
|
- No advisory locking visible to harnesses — internal only; no CLI surface.
|
|
- No `outputs.retention` GC (task 3).
|
|
- No `blast_radius.base_branch` parameterization (task 4).
|
|
|
|
## Test plan (`tests/test_state_loop_lock.py`)
|
|
|
|
New tests, all stdlib, all using `tmp_path`:
|
|
|
|
1. `test_lock_serializes_concurrent_writes`: two threads kicked off simultaneously, each does read→sleep(0.05)→write under the lock. Assert timestamps don't interleave (one finishes before the other starts its write). Use a shared "interleave detector" (a list append of enter/exit times compared after).
|
|
2. `test_lock_releases_on_clean_exit`: acquire+release; the next acquire on the same loop succeeds immediately.
|
|
3. `test_lock_releases_on_exception`: `with _loop_lock(p): raise ValueError`; next acquire succeeds.
|
|
4. `test_lock_is_per_loop`: two lock acquisitions on two different loop dirs run concurrently without blocking each other (assert both complete within a tightly bounded wall-clock window).
|
|
5. `test_no_lock_on_create_loop`: `--create-loop` of a new loop does NOT create a `.state.lock` file (create-path is unwrapped per R2). Then `--check-gate` on it acquires/releases the lock, leaving `.state.lock` behind.
|
|
6. `test_pause_loop_serialized_with_concurrent_read`: spawn a thread that holds `_loop_lock` for 0.1s; main thread calls `--pause-loop` and assert it completes after 0.1s (not before). Confirms commands actually acquire the lock.
|
|
7. `test_runner_tick_holds_lock_across_state_write`: integration-style — invoke `loop-runner.py --mode tick` against a loop whose tick is artificially delayed, while a parallel `--approve --loop` is held; assert approve completes after the tick. (Skip if it grows flaky — turns into a smoke test asserting the lock file appears.)
|
|
|
|
Reuse the `_make_loop` helper pattern from `tests/test_status_brakes.py` for loop dir scaffolding.
|
|
|
|
## Concrete code shape
|
|
|
|
```python
|
|
@contextlib.contextmanager
|
|
def _loop_lock(loop_path: Path, exclusive: bool = True):
|
|
lock_file = loop_path / ".state.lock"
|
|
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
|
|
acquired = False
|
|
try:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
msvcrt.locking(fd, msvcrt.LK_LOCK if exclusive else msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
|
acquired = True
|
|
yield
|
|
finally:
|
|
if acquired:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
try:
|
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
except OSError:
|
|
pass
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
```
|
|
|
|
Callers:
|
|
```python
|
|
with _loop_lock(loop_path):
|
|
state = _read_state_loop(loop_path) or _initial_state_loop(name)
|
|
state["status"] = "paused"
|
|
_write_state_loop(loop_path, state)
|
|
```
|
|
|
|
## D-items (decisions locked for this task)
|
|
|
|
- **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 dir, not garbage-collected.
|
|
- **D-L3**: `--create-loop` path is unwrapped (no prior state to race against; create is name-unique-refused).
|
|
- **D-L4**: stdlib only (`fcntl` POSIX, `msvcrt` Windows). No `filelock` package.
|
|
- **D-L5**: existing atomic write semantics retained (defense-in-depth).
|
|
|
|
## Risks
|
|
|
|
- **Deadlock if a path holds the lock and re-enters a function that tries to re-acquire.** Mitigation: `_loop_lock` is not re-entrant — audit every callsite to ensure no nested `_loop_lock` within the same `with` block. POSIX `flock` is re-entrant on the same fd; Windows `msvcrt.locking` is not. Safer to forbid nesting and document it.
|
|
- **Linux `flock` on NFS has known caveats.** Out of scope: the loop dir is always local (project root or `~/.automaton`). Document in the helper's docstring.
|
|
|
|
## Verification
|
|
|
|
- `python3 -m py_compile scripts/status.py scripts/loop-runner.py`
|
|
- `python3 -m pytest tests/test_state_loop_lock.py -v`
|
|
- `python3 -m pytest tests/ -q` (full suite must remain green; 433 baseline + new)
|
|
- Manual: `python3 scripts/status.py --create-loop t --from-template ci-triage && python3 scripts/status.py --check-gate t` — should succeed and leave `.state.lock` behind on first acquire. |