Files
Lap Tran bc7daf8590 Restore archived tasks, fix dashboard scroll-reset, bind ornith, add Playwright smoke test
- **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).
2026-06-26 10:05:18 -04:00

7.6 KiB

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.