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

11 KiB

SPEC: linux-schedule-parity

Problem

scripts/status.py::_enable_schedule has asymmetric per-OS behavior:

  • Darwin — renames *.plist.disabled back to *.plist. Symmetric with _disable_schedule which renames forward to .disabled extension.
  • Windows — runs schtasks /run /tn .... Symmetric with _disable_schedule which runs schtasks /end.
  • Linux — pass (no-op). NOT symmetric with _disable_schedule which strips the cron block.

Source: tasks/add-status-brakes/BUG_REPORT.md O2/O4.

--pause-loop on Linux removes the cron block; --resume-loop's Linux branch is a no-op. So a Linux user who pauses a loop loses their schedule. Mitigation: the user can re-run --install-schedule after resuming.

Net effect: Linux users who --pause-loop a loop permanently lose the cron block on --resume-loop. The next tick will only fire if the cron block survived (it doesn't — _disable_schedule's Linux branch strips it from the user's crontab via crontab -).

Mitigation in v1 was acceptable — operator manually re-runs --install-schedule. For v1.1 hardening, we close the gap properly: _enable_schedule on Linux should re-install the cron block.

Goal

Make _enable_schedule on Linux re-install the cron block, mirroring what cmd_install_schedule does, using the loop's existing tick stub at <loop_path>/run-tick.sh. The Linux path becomes symmetric with Darwin and Windows.

Constraints

  • Do NOT duplicate the install code into _enable_schedule. Extract a shared helper _install_cron_block(name, project, loop_path) and call it from both cmd_install_schedule (Linux branch) and _enable_schedule (Linux branch).
  • Do NOT touch the Darwin or Windows branches of _enable_schedule. They already work.
  • Do NOT change _disable_schedule. Linux strips via crontab - with block-marker filter (correct; symmetric on the disable side).
  • The cron block uses <loop_path>/run-tick.sh as the tick stub path. The stub must exist from a prior --install-schedule. If missing, _enable_schedule logs WARNING and exits 0 (operator can re-run --install-schedule from scratch).
  • Preserve idempotency: re-enabling an already-installed cron block produces one block (the install code already strips prior blocks for the same loop name before appending).

Detailed design

Refactor: extract _install_cron_block

A new function _install_cron_block(name: str, loop_path: Path, interval: int) -> int (returns 0 on success, 2 on error). Extracted from cmd_install_schedule's Linux branch. Logic:

  1. Compute minutes_interval = max(1, interval // 60).
  2. Read existing crontab via crontab -l (best-effort; permission failure → existing = [], no error).
  3. Strip any prior block for this loop (lines between # automaton-loop:{name} and # end automaton-loop:{name}).
  4. Append fresh block:
    # automaton-loop:{name}
    */{minutes_interval} * * * * {stub_path}
    # end automaton-loop:{name}
    
  5. Write via crontab -.
  6. Return 0 on success; return 2 (with stderr message) on subprocess.SubprocessError/OSError.

Refactor: cmd_install_schedule uses _install_cron_block

The Linux branch of cmd_install_schedule becomes:

elif system == "Linux":
    rc = _install_cron_block(name, loop_path, interval)
    if rc == 0:
        print(f"Installed crontab block (every {minutes_interval} min). Tick stub: {stub_path}")
    return rc

New _enable_schedule Linux branch

elif system == "Linux":
    stub = loop_path / LOOP_TICK_SCRIPT_SH
    if not stub.exists():
        # Operator never ran --install-schedule; can't re-enable.
        # Silent: re-enable without a prior install is a no-op intent.
        return
    cfg = _read_loop_config(loop_path) or {}
    interval = int(cfg.get("schedule", {}).get("interval_seconds", 3600))
    _install_cron_block(name, loop_path, interval)

Stays best-effort: caught-by-caller (or wrapped in a try/except in the caller as it already is in _halt_loop / cmd_resume_loop / cmd_approve_loop — all call _enable_schedule and tolerate failure).

Behavior matrix

Event Darwin Windows Linux (v1) Linux (v1.1)
--install-schedule write plist create schtasks write cron block write cron block (via shared helper)
--pause-loop rename to .disabled schtasks /end strip cron block strip cron block
--resume-loop rename back to .plist schtasks /run pass (gap) re-write cron block
--approve --loop re-enable schedule re-enable schedule pass (gap) re-enable schedule

Requirements

R1 — Extracted helper

_install_cron_block(name, loop_path, interval) -> int exists and is called from cmd_install_schedule (Linux branch) AND from _enable_schedule (Linux branch).

R2 — Linux resume re-installs cron

cmd_resume_loop on Linux (which calls _enable_schedule after the read-modify-write block) re-writes the cron block. Verified by capturing crontab - input in a mocked subprocess.

R3 — Approve re-enables schedule on Linux

cmd_approve_loop on Linux (which calls _enable_schedule after clearing the halt) re-writes the cron block. Same verification as R2.

R4 — Idempotent

Two consecutive _enable_schedule invocations result in exactly one cron block per loop (the strip-and-append logic dedupes).

R5 — Missing stub → silent skip

If <loop_path>/run-tick.sh doesn't exist, _enable_schedule logs WARNING to stderr ("cannot re-enable: no tick stub at ; run --install-schedule") and returns without error. The caller's behavior is unaffected (best-effort contract).

R6 — Interval from config

_enable_schedule on Linux reads loop.json's schedule.interval_seconds (default 3600) for the cron block's */N minutes. Non-int coerces via int(...); on TypeError/ValueError falls back to 3600.

R7 — No new pip deps; stdlib only

subprocess, platform, pathlib — all stdlib.

Test plan

Tests in tests/test_linux_schedule_parity.py (NEW). Mock subprocess.run to capture crontab - calls. Use tmp_path.

  1. _install_cron_block writes fresh block: mock crontab -l → empty; call helper; assert crontab - input contains # automaton-loop:{name} block with */{minutes_interval} * * * * {stub_path}.
  2. _install_cron_block strips prior block: mock crontab -l returning an existing block; call helper; assert the NEW crontab-strip call has exactly one block (the new one).
  3. _install_cron_block fails on subprocess error: mock crontab -l raising subprocess.SubprocessError; assert helper returns 2 and prints ERROR.
  4. _install_cron_block rounds interval to minutes: interval_seconds=90 → minutes_interval = max(1, 90//60) = 1. interval_seconds=3700 → 61 minutes (rounds down, ≥1).
  5. cmd_install_schedule on Linux delegates: invoke via argparse, mock _install_cron_block (or mock subprocess), assert Linux branch produces "Installed crontab block" message.
  6. _enable_schedule on Linux re-installs when stub exists: write a stub file in tmp_path, mock crontab -l empty, call _enable_schedule; assert crontab - was called to install a block.
  7. _enable_schedule on Linux silent when stub missing: no stub file; call _enable_schedule on Linux; assert no crontab - subprocess call; assert WARNING printed to stderr.
  8. _enable_schedule reads interval from loop.json: write a loop.json with schedule.interval_seconds=120; call _enable_schedule; assert block uses */2 * * * *.
  9. _enable_schedule falls back to 3600 when interval is garbage: loop.json with interval_seconds="twenty"; assert block uses */60 * * * * (60 min = 3600s) — or skip the test if 60 minutes is too long; assert WARNING instead. Editorial: prefer falling back to 60 (hourly) rather than 1 (every minute — too aggressive).
  10. Idempotent two calls: call _enable_schedule twice with mocked crontab; assert the SECOND call's crontab - input still has exactly one block (strip-then-append dedupes).
  11. Darwin branch unchanged: on a Darwin platform, _enable_schedule still does the .plist.disabled → .plist rename (assert via mocking). Ensures R2/R3 don't break the working Darwin path.
  12. Windows branch unchanged: on Windows, _enable_schedule still runs schtasks /run. Same assurance as R11.
  13. _disable_schedule Linux still strips: post-R-vector — call _disable_schedule on Linux with mocked crontab containing a block; assert the block is removed (strip via marker filter). Verifies the disable side wasn't accidentally broken by the install-helper extraction.

Decisions

  • D-S1: Extract _install_cron_block as a shared helper called from BOTH cmd_install_schedule and _enable_schedule (Linux branch). Single source of truth for the install sequence.
  • D-S2: Missing tick stub → silent WARNING skip (not error). Operator can manually --install-schedule to regenerate both stub + cron. Hard fail would punish operators who never installed in the first place; soft skip preserves resume semantics.
  • D-S3: Interval read from loop.json's schedule.interval_seconds, default 3600. Hands-off: --install-schedule's CLI override (or future --install-schedule --interval flag) doesn't apply to resume flow — the config IS the source of truth.
  • D-S4: Garbage interval_seconds → fallback 3600 (hourly) NOT 60 (every minute). Garbage in, conservative out. WARNING logged.
  • D-S5: No state-side change. Resume doesn't bump resumed_count (already handled in the read-modify-write block of cmd_resume_loop); _enable_schedule is side-effect-of-state-change.
  • D-S6: _enable_schedule stays best-effort. Caller wraps in try/except. No new exit-code contract.
  • D-S7: Don't touch _disable_schedule — the strip behavior already works correctly. Extraction only on the install side.
  • D-S8: Tests use platform.system() mocking to simulate Linux on a Darwin CI host (the dev machine is macOS but the Linux branch must be exercised in tests). Pattern: patch status.platform.system to return "Linux".

Files touched

  • scripts/status.py — extract _install_cron_block(name, loop_path, interval); cmd_install_schedule Linux branch uses it; _enable_schedule Linux branch uses it.
  • CHANGELOG.md — new entry under [unreleased].
  • design/loops/technical.md §6 — note the Linux-parity fix in the scheduler section.
  • tests/test_linux_schedule_parity.py (NEW) — 13 tests per plan above.

Out of scope

  • --install-schedule --interval CLI override flag. Future task.
  • systemctl --user timer as an alternative to cron. Future task; Linux-specific ergonomics.
  • --validate-schedule that checks the installed cron block matches the current loop config. Filed to BACKLOG.md.
  • Garbage intervals in v1 loops: no auto-detection / migration. Operator fixes on first resume.

Pipeline plan

research → research:awaiting_approval → research:approved → implement → code_review → code_review:awaiting_approval → code_review:approved → bug_find → adversarial_bug_find → doc_review → referee → complete.