- **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).
11 KiB
SPEC: linux-schedule-parity
Problem
scripts/status.py::_enable_schedule has asymmetric per-OS behavior:
- Darwin — renames
*.plist.disabledback to*.plist. Symmetric with_disable_schedulewhich renames forward to.disabledextension. - Windows — runs
schtasks /run /tn .... Symmetric with_disable_schedulewhich runsschtasks /end. - Linux —
pass(no-op). NOT symmetric with_disable_schedulewhich strips the cron block.
Source: tasks/add-status-brakes/BUG_REPORT.md O2/O4.
--pause-loopon 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-scheduleafter 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 bothcmd_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 viacrontab -with block-marker filter (correct; symmetric on the disable side). - The cron block uses
<loop_path>/run-tick.shas the tick stub path. The stub must exist from a prior--install-schedule. If missing,_enable_schedulelogs WARNING and exits 0 (operator can re-run--install-schedulefrom 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:
- Compute
minutes_interval = max(1, interval // 60). - Read existing crontab via
crontab -l(best-effort; permission failure → existing =[], no error). - Strip any prior block for this loop (lines between
# automaton-loop:{name}and# end automaton-loop:{name}). - Append fresh block:
# automaton-loop:{name} */{minutes_interval} * * * * {stub_path} # end automaton-loop:{name} - Write via
crontab -. - 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.
_install_cron_blockwrites fresh block: mockcrontab -l→ empty; call helper; assertcrontab -input contains# automaton-loop:{name}block with*/{minutes_interval} * * * * {stub_path}._install_cron_blockstrips prior block: mockcrontab -lreturning an existing block; call helper; assert the NEW crontab-strip call has exactly one block (the new one)._install_cron_blockfails on subprocess error: mockcrontab -lraisingsubprocess.SubprocessError; assert helper returns 2 and prints ERROR._install_cron_blockrounds interval to minutes:interval_seconds=90→minutes_interval = max(1, 90//60) = 1.interval_seconds=3700→61minutes (rounds down, ≥1).cmd_install_scheduleon Linux delegates: invoke via argparse, mock_install_cron_block(or mock subprocess), assert Linux branch produces "Installed crontab block" message._enable_scheduleon Linux re-installs when stub exists: write a stub file intmp_path, mockcrontab -lempty, call_enable_schedule; assertcrontab -was called to install a block._enable_scheduleon Linux silent when stub missing: no stub file; call_enable_scheduleon Linux; assert nocrontab -subprocess call; assert WARNING printed to stderr._enable_schedulereads interval from loop.json: write aloop.jsonwithschedule.interval_seconds=120; call_enable_schedule; assert block uses*/2 * * * *._enable_schedulefalls back to 3600 when interval is garbage:loop.jsonwithinterval_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).- Idempotent two calls: call
_enable_scheduletwice with mocked crontab; assert the SECOND call'scrontab -input still has exactly one block (strip-then-append dedupes). - Darwin branch unchanged: on a Darwin platform,
_enable_schedulestill does the.plist.disabled→.plistrename (assert via mocking). Ensures R2/R3 don't break the working Darwin path. - Windows branch unchanged: on Windows,
_enable_schedulestill runsschtasks /run. Same assurance as R11. _disable_scheduleLinux still strips: post-R-vector — call_disable_scheduleon 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_blockas a shared helper called from BOTHcmd_install_scheduleand_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-scheduleto 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'sschedule.interval_seconds, default 3600. Hands-off:--install-schedule's CLI override (or future--install-schedule --intervalflag) 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 ofcmd_resume_loop);_enable_scheduleis side-effect-of-state-change. - D-S6:
_enable_schedulestays best-effort. Caller wraps intry/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: patchstatus.platform.systemto return"Linux".
Files touched
scripts/status.py— extract_install_cron_block(name, loop_path, interval);cmd_install_scheduleLinux branch uses it;_enable_scheduleLinux 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 --intervalCLI override flag. Future task.systemctl --usertimer as an alternative to cron. Future task; Linux-specific ergonomics.--validate-schedulethat checks the installed cron block matches the current loop config. Filed toBACKLOG.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.