# 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 `/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 `/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 `/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.