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).
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# 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:
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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 <path>; 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.
|
||||
Reference in New Issue
Block a user