# SPEC: add-blast-radius-scheduler ## Context Task 2 (`add-status-brakes`) shipped `--can-edit --loop [--loop-worktree]`, the `_gate_worktree_drift` brake gate, and `platform.system()` dispatch for scheduler generation. Task 3 (`add-loop-runner`) shipped the runner with a stub at step 4: `# worktree plumbing lands in task add-blast-radius-scheduler`. The runner currently uses `state["worktree_path"]` if set, else falls back to `project_root` -- but never **creates** the worktree. This task closes that gap: the runner ensures a per-loop git worktree exists before spawning the Implement role, per `technical.md` section 7 step 4 and D2. ## Non-Goals (deferred) - `--no-worktree` CLI flag for `--create-loop` -> v1.1 (the `blast_radius.use_worktree: false` field in `loop.json` is the v1 opt-out mechanism; a CLI flag is convenience sugar). - Worktree garbage collection / pruning -> v1.1 (BACKLOG `worktree-gc`). - `blast_radius.base_branch` parameterization -> v1.1 (hardening item; v1 hardcodes `main` as the base). - `--claim-loop-task` atomic ownership -> v1.1. - Fcntl lock on worktree creation -> v1.1 (same TOCTOU item as `add-status-brakes` A6). ## Requirements ### R1 -- `_ensure_worktree` helper in `loop-runner.py` - New function `_ensure_worktree(state, cfg, loop_path, project_dir) -> str` that returns the cwd to use for harness invocations. - Reads `blast_radius.use_worktree` from `loop.json` (default: `True` when the field is missing, matching D2 "default is worktree-on"). - When `use_worktree` is `False`: return `str(project_dir)` immediately. No git calls. No state mutation. - When `use_worktree` is `True` and `state["worktree_path"]` is already set and the path exists: return the existing worktree path. No state mutation. - When `use_worktree` is `True` and `state["worktree_path"]` is null or the path no longer exists: 1. Determine the worktree path: `/worktree` (using `LOOP_WORKTREE_DIR = "worktree"`). 2. Determine the branch name: `loop/` where `` is `state["name"]` or the loop dir name. 3. Run `git rev-parse --is-inside-work-tree` from `project_dir` to verify it is a git repo. If not, fall back to R2. 4. Run `git worktree add -b loop/` from `project_dir`. If the branch already exists, use `git worktree add loop/` (checkout existing branch, no `-b`). 5. On success: update `state["worktree_path"]` and `state["worktree_branch"]`, write state atomically, return the worktree path. 6. On failure: fall back to R2. - **Tests:** `test_ensure_worktree_creates_worktree`, `test_ensure_worktree_reuses_existing`, `test_ensure_worktree_use_worktree_false_returns_project_root`, `test_ensure_worktree_missing_field_defaults_true`. ### R2 -- Graceful degradation (no git / not a repo / worktree creation fails) - If `git` is not found (`FileNotFoundError`), or `git rev-parse --is-inside-work-tree` fails (non-zero exit), or `git worktree add` fails (non-zero exit): log a WARNING to `.state.log` and return `str(project_dir)` as cwd. - The loop does NOT halt. The tick proceeds with `cwd = project_dir`. The drift gate (`_gate_worktree_drift`) will skip itself because `worktree_path` remains null. - This makes worktree creation **best-effort**: a loop configured with `use_worktree: true` on a non-git project simply edits the primary checkout. The operator is responsible for understanding this trade-off (documented in `functional.md`). - **Tests:** `test_ensure_worktree_falls_back_when_not_git_repo`, `test_ensure_worktree_falls_back_when_git_missing`, `test_ensure_worktree_falls_back_when_worktree_add_fails`, `test_ensure_worktree_logs_warning_on_fallback`. ### R3 -- Integration into `cmd_tick` - Replace the current step 4 block in `cmd_tick` (lines ~493-498 of `loop-runner.py`) with a call to `_ensure_worktree(state, cfg, loop_path, project_dir)`. - The returned cwd is used for all three role invocations (Implement, Verify, Orchestrate). - The state mutation (setting `worktree_path`/`worktree_branch`) happens inside `_ensure_worktree` via `_write_state_loop`. This is safe because it occurs before any harness subprocess; a crash after this point but before step 10 leaves the worktree path recorded (which is correct -- the worktree exists on disk). - **Tests:** `test_tick_creates_worktree_on_first_tick`, `test_tick_reuses_worktree_on_second_tick`, `test_tick_falls_back_to_project_root_when_no_git`. ### R4 -- Worktree branch already exists - When `git worktree add -b loop/` fails because the branch already exists (exit code 128, stderr contains `already exists`), retry with `git worktree add loop/` (checkout existing branch without `-b`). - If the retry also fails, fall back to R2. - This handles the case where a loop was previously created, the worktree was deleted, but the branch remains in the repo. - **Tests:** `test_ensure_worktree_reuses_existing_branch`, `test_ensure_worktree_falls_back_when_branch_checkout_fails`. ### R5 -- State consistency - `_ensure_worktree` writes `worktree_path` and `worktree_branch` to `.state.loop` atomically via `_write_state_loop` (same tmp+rename pattern). - If the worktree path was previously set but the directory no longer exists (e.g. manually deleted), clear `worktree_path` and `worktree_branch` in state before attempting recreation. If recreation fails, leave them cleared (R2 fallback). - **Tests:** `test_ensure_worktree_clears_stale_worktree_path`, `test_ensure_worktree_recreates_after_deletion`. ### R6 -- Platform path handling - Use `pathlib.Path` for all path construction. On Windows, `Path` handles backslash separators automatically. - The `git worktree add` command receives the worktree path as a string; git handles OS-specific path normalization on its own. - No `platform.system()` calls needed in the runner for worktree creation (unlike `--install-schedule` which generates OS-native scheduler units). The runner's worktree creation is platform-agnostic via `Path`. - **Tests:** `test_worktree_path_uses_pathlib` (verify the path is constructed via `Path` not string concatenation; checked by examining the argv passed to `subprocess.run`). ### R7 -- Doc updates - Update `design/loops/technical.md` section 7 step 4 to note the runner now creates the worktree (remove the "deferred" language if present). - Update `CHANGELOG.md` under `[unreleased]`. - **Tests:** none (doc-only). ### R8 -- New test file `tests/test_blast_radius.py` - Mirrors `test_loop_runner.py`'s stubbing pattern (`monkeypatch.setattr(subprocess, "run", fake_run)`). - Covers R1-R6 as itemized above; target 12-16 tests. - All subprocess calls stubbed; no live git operations in CI. For tests that need a real git repo, use `tmp_path` + `subprocess.run(["git", "init"])` in a fixture (these are integration tests that hit the real git binary but are fast and deterministic). - Add one regression test: `test_existing_loop_with_worktree_path_ticks_unchanged` -- a loop with `worktree_path` set and the path existing still ticks without calling `git worktree add`. - **Tests:** self-referential (the file IS the test). ## Verification - `python3 -m py_compile scripts/loop-runner.py` - `python3 -m pytest tests/test_blast_radius.py -v` - `python3 -m pytest tests/ -q` -- full suite must remain green; expected total approx 370 (354 + 12-16 new). - `bash -n scripts/*.sh` (no shell changes; safety check).