- **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).
7.7 KiB
Code Review: add-state-loop-lock
Reviewed implementation against tasks/add-state-loop-lock/SPEC.md.
SPEC coverage
| Requirement | Status |
|---|---|
R1 — _loop_lock context manager with POSIX/Windows branches, blocking acquire, FD lifecycle in finally |
✓ (added in status.py AND loop-runner.py) |
R2 — Wrap _write_state_loop callsites in status.py (not --create-loop) |
✓ (cmd_pause_loop, cmd_resume_loop, cmd_approve_loop, cmd_check_gate); --create-loop per D-L3 left unwrapped |
R3 — Wrap read-modify-write in cmd_tick's step 10; lock must cover the --check-gate subprocess decision and the state write |
✓ — runner holds _loop_lock from before _gate through step 10's write. The _gate subprocess is invoked with AUTOMATON_NO_LOOP_LOCK=1 so its own _loop_lock no-ops (avoids self-deadlock on the parent's held flock) |
R4 — Idempotence: early-returns inside the with release cleanly (try/finally inside the context manager, not caller) |
✓ — the finally block in _loop_lock checks acquired and unlocks; safe on early returns |
| R5 — Lock file location is per-loop dir | ✓ — lock_file = loop_path / ".state.lock" |
| R6 — Stdlib only (fcntl/msvcrt/contextlib/sys/os) | ✓ — import contextlib, conditional import fcntl (POSIX) / import msvcrt (Windows), os.open, os.close |
R7 — Existing atomic write (_write_state_loop tmp-then-replace) retained |
✓ — _write_state_loop untouched; lock is coarse mutex on top |
Deviations from SPEC (with rationale)
-
SPEC R2 listed
cmd_check_gateas a callsite to wrap, but R3 said the runner must hold the lock across the gate subprocess. These contradict: if both wrap, runner holds flock, spawns--check-gate, subprocess tries to flock the SAME file → deadlock. Resolved by introducing D-L6 (env-var bypass).cmd_check_gateacquires_loop_lock— but when$AUTOMATON_NO_LOOP_LOCK=1is set in the subprocess env (the runner sets it ONLY for the--check-gatesubprocess's env),_loop_lockbecomes a no-op. Standalone CLI invocations don't set the env var, so they lock normally and still serialize against--pause-loopetc.This deviates from the SPEC wording by adding an env-var mechanism not listed in the SPEC, but the SPEC's stated intent ("the lock acquired by the runner blocks the runner's own subsequent subprocess read... 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.lockat the parent-runner level") is preserved exactly. The env var is the mechanism that achieves the SPEC's stated intent without deadlock. -
SPEC R3 mentioned loop-runner.py callsite line 684 for
_write_state_loop. The actual line is 690 in the pre-task tree (787 in the post-task tree). The cmd_tick wrapping covers all four_write_state_loopcallsites in the runner (the early_ensure_worktreewrite at line 244, the_halt_loopwrites for context-floor and verifier-fail, and the final step-10 state write). All are insidecmd_tick'swith _loop_lockblock, so they're all covered by the single outer lock. -
_read_state_loopis invoked before the lock incmd_tick(to fast-fail untracked loops without paying the lock cost), then re-read inside the lock. This is not a race — the unlocked read only determines whether the loop is untracked; subsequent decisions re-read fresh under the lock. Documented in cmd_tick's docstring.
Helpers audit (avoiding nested _loop_lock)
_halt_loop(status.py:1718): does write + log +_disable_schedule. None re-acquire the lock. Called fromcmd_check_gatewhile the lock is held — safe._halt_loop(loop-runner.py:122): same shape, but doesn't call_disable_schedule(runner is short-lived per tick; OS schedule unit is best-effort disabled elsewhere). Called fromcmd_tickwhile the lock is held — safe._ensure_worktree: does subprocessgit+ state write. Doesn't lock. Called fromcmd_tickinside_loop_lock— safe._disable_schedule/_enable_schedule(status.py): now called OUTSIDE the_loop_lockblock (after thewithexits) in all three commands — keeps the critical section tight. They invoke OS shells (launchctl, cron, schtasks) and don't touch.state.loop. Safe.
Cross-script duplication
_loop_lock is duplicated across status.py and loop-runner.py. This is consistent with the existing convention (_read_state_loop, _write_state_loop, _read_loop_config, etc. are all duplicated across the two scripts; the design doc explicitly says "no cross-script imports"). status.py's version adds the env-var bypass; loop-runner.py's does not (the runner is the lock holder, never the bypass consumer).
Race-window closure confirmation
Scenarios the lock closes:
- Two scheduler firings of the same loop → second runner blocks at
_loop_lockuntil first finishes step 10. ✓ - Concurrent
--pause-loopand runner tick → pause blocks at the runner's lock; pause resumes after tick releases. ✓ - Concurrent
--approve --loopand runner tick → same as #2. - Concurrent
--check-gate(CLI) and--pause-loop(CLI) → both acquire the lock, serialize. ✓ - Concurrent
--check-gateinvoked from runner (env var set) and--pause-loop→ runner holds the lock; pause blocks at runner's lock. ✓ - Concurrent
--approve --loopfrom harness (no env var) and a runner tick → harness's approve blocks at runner's lock. ✓ (Test 7 covers this scenario.)
Edge cases verified
- Untracked loop: cmd_tick returns early before acquiring the lock — no
.state.lockis created for untracked loops on tick. - Empty
.state.loop: not possible —_read_state_loopreturns None on JSON decode failure; treat as untracked. .state.lockfile pre-existing from a previous crash:_loop_lockopens withO_RDWR | O_CREAT— re-uses existing file. Idempotent.- Loop dir deleted mid-hold:
BrokenPipeError/OSErrorfrom writes would surface; documented as acceptable per SPEC.
Test review
TestSerializeConcurrent: relies on athreading.Lockto append enter/exit times safely. Good. Could be flaky on extremely slow CI; threshold islast_enter >= first_exitwhich is monotonic — not a wall-clock assertion. Robust.TestPerLoop: 1.0s upper bound on B's acquire while A holds a different lock. Could be flaky on a heavily loaded box, but 1s is generous. Acceptable.TestNoLockOnCreate: tests D-L3 —--create-loopdoes NOT create.state.lock; first--check-gatedoes. Excellent regression guard.TestPauseSerializedWithConcurrentHolder: relies on--pause-loop's subprocess spawning (~100ms Python startup) plus the holder's 100ms hold. Asserts"Paused loop"is in the output. Doesn't strictly assert wall-clock > 100ms (the comment admits this). The monotonic ordering check (results["code"] is 0) plus the implicit blocking-on-flock suffice as a smoke test. Could be tightened to assertresults["elapsed"] >= 0.05(the holder held for >=0.1s, minus subprocess startup), but the smoke-level assertion is adequate for v1.1.TestRunnerHoldsLockAcrossStateWrite: cleaner than the SPEC's "Skip if it grows flaky" suggestion — usesthreading.Eventsynchronization rather than wall-clock delays for the critical assertions; wall-clock sleeps only to allow the approve subprocess to spin up. Robust. The key assertion isassert "approve_out" not in resultsBEFOREtick_can_finish.set()— proves the approve subprocess is blocked on flock while the tick is mid-flight.
Test count
- Baseline: 440 passed (post-
fix-harness-command-template). - New: +7 in
tests/test_state_loop_lock.py. - Final: 447 passed, 0 regressions.
Verdict
PASS. Proceed to bug_find.