Complete tasks 3-7: harden verdict parsing, outputs retention, base branch, linux schedule parity, claim loop task
CI / build (push) Has been cancelled

This commit is contained in:
Lap Tran
2026-06-24 10:31:49 -04:00
parent dd2726c0dd
commit e13513faaa
193 changed files with 14934 additions and 98 deletions
+1
View File
@@ -0,0 +1 @@
complete
@@ -0,0 +1,2 @@
research:approved|2026-06-23T12:45:23.607016+00:00|user
code_review:approved|2026-06-23T12:50:27.981922+00:00|user
@@ -0,0 +1,27 @@
# ADVERSARIAL_BUG_REPORT: add-blast-radius-scheduler
Attack the worktree creation as a hostile environment would: escape blast radius, inject branch names, or corrupt state.
## Attack vectors tried
### A1 -- Can a hostile `loop.json` set `worktree_path` to an arbitrary location?
`_ensure_worktree` reads `state["worktree_path"]`, not `loop.json`. The state file is controlled by the framework (written via `_write_state_loop`). A hostile `loop.json` cannot set `worktree_path` directly. The worktree path is always constructed as `<loop_path>/worktree` by the runner. PASS
### A2 -- Can a hostile loop name create a branch outside the `loop/` namespace?
The branch name is `f"loop/{loop_name}"` where `loop_name` comes from `state.get("name")` or `loop_path.name`. The loop name is validated by `_is_kebab_case` in `status.py --create-loop` (rejects non-kebab-case names, including slashes). So the branch name is always `loop/<kebab-case-name>`. A hostile state file could set `name` to `../evil`, but `_write_state_loop` is only called by the framework. If the state file is manually edited, the attacker already has filesystem access. PASS (config-trust model).
### A3 -- Can `git worktree add` be coerced into writing outside the loop dir?
The worktree path is `<loop_path>/worktree` which is under `.automaton/loops/<name>/`. The `git worktree add` command receives this as an absolute path. Git creates the worktree at exactly that path. No path traversal possible because the path is constructed from `Path` objects, not string concatenation. PASS
### A4 -- Can a concurrent tick create two worktrees?
TOCTOU: two ticks both see `worktree_path is null`, both call `git worktree add <same-path>`. The second call fails because the path exists. The second tick falls back to project root. The first tick succeeds and records the worktree. No state corruption (atomic write; last-writer-wins, but the second write doesn't happen because the fallback path doesn't write state). Next tick: both see the worktree exists and reuse it. PASS (bounded by scheduler interval).
### A5 -- Can `git worktree add` execute arbitrary commands via the branch name?
The branch name is `loop/<kebab-case-name>`. It's passed as a separate argv element to `subprocess.run(["git", "worktree", "add", path, "-b", branch])`. No shell invocation (`shell=False` by default in `subprocess.run` with list args). A branch name starting with `-` would be interpreted as a git flag, but `_is_kebab_case` requires alphanumeric + hyphens + dots + underscores, and the `loop/` prefix ensures the branch never starts with `-`. PASS
### A6 -- Can a symlink at `<loop_path>/worktree` redirect file writes?
If an attacker creates a symlink from `<loop_path>/worktree` to `/etc`, `git worktree add` would fail (git refuses to use existing paths). If the attacker creates the symlink AFTER worktree creation but BEFORE the harness runs, the harness would write to the symlink target. But the attacker needs filesystem access to create the symlink, which already implies compromise. PASS (filesystem-trust model).
## Verdict
PASS -- no exploitable escape. Worktree creation is path-safe, branch-name-safe, and shell-injection-safe. TOCTOU is bounded by scheduler interval.
@@ -0,0 +1,25 @@
# BUG_REPORT: add-blast-radius-scheduler
Probed worktree creation, fallback, and state consistency against edge cases.
## Bugs found
None blocking. Informational observations below.
## Observations (non-blocking)
### O1 -- Worktree state written before step 10 (idempotence gap)
`_ensure_worktree` calls `_write_state_loop` to record `worktree_path`/`worktree_branch` immediately after worktree creation. If the tick crashes between this point and step 10 (state advance), `iteration_count` is NOT incremented (correct), but `worktree_path` IS set in `.state.loop`. On the next tick, the runner reuses the existing worktree (which exists on disk). This is correct behavior -- the worktree was created, it exists, reusing it is right. The "idempotent in failure" contract from task 3 refers to `iteration_count` and `last_verdict`, not to worktree state. Accepted.
### O2 -- `git worktree add` on a repo with uncommitted changes
`git worktree add` creates a new working tree from the current HEAD. It does not require a clean working tree in the main checkout. So this is fine -- the worktree gets a clean copy of HEAD. No issue.
### O3 -- Worktree path collides with existing directory
If `<loop_path>/worktree` already exists as a non-git directory (e.g. the user manually created it), `git worktree add` will fail with "already exists". The runner falls back to project root. The user would need to remove the directory manually. Acceptable for v1.
### O4 -- No cleanup of worktree on `--approve --loop` or loop deletion
When a loop is halted and then approved (resumed), the worktree remains. When a loop dir is deleted, the worktree branch remains in the repo. Worktree GC is a v1.1 item (BACKLOG `worktree-gc`). Accepted.
## Verdict
PASS -- no blocker bugs. All observations are accepted trade-offs or v1.1 hardening items.
@@ -0,0 +1,36 @@
# CODE_REVIEW: add-blast-radius-scheduler
Reviewed against SPEC.md R1-R8.
## R1-R8 checklist
| Req | Status | Notes |
|-----|--------|-------|
| R1 _ensure_worktree | PASS | Dispatches on `blast_radius.use_worktree` (default True); creates via `git worktree add`; records in state |
| R2 graceful degradation | PASS | Not-a-repo, git-missing, and worktree-add-fail all return project root with WARNING |
| R3 cmd_tick integration | PASS | Step 4 replaced with `cwd = _ensure_worktree(...)` |
| R4 branch already exists | PASS | Retries without `-b` when stderr contains "already exists" |
| R5 state consistency | PASS | Stale path cleared; state written atomically |
| R6 platform paths | PASS | pathlib.Path throughout; git handles OS normalization |
| R7 doc updates | PASS | technical.md section 7 updated; CHANGELOG updated |
| R8 tests | PASS | 15 tests, 6 classes + regression |
## Edge cases checked
1. **`use_worktree` missing from `blast_radius`** -- defaults to `True` via `blast.get("use_worktree", True)`. PASS
2. **`blast_radius` entirely missing** -- `cfg.get("blast_radius") or {}` returns empty dict; `use_worktree` defaults True. PASS
3. **Worktree path exists but is not a git worktree** -- `git worktree add` would fail; runner falls back to project root. PASS
4. **Branch exists but worktree was deleted** -- first `git worktree add -b` fails with "already exists"; retry without `-b` succeeds. PASS
5. **`git worktree add` times out** -- `_git_run` has `timeout=15`; `subprocess.TimeoutExpired` is a `SubprocessError`, caught by `_git_run`. PASS
6. **State written before step 10** -- intentional: the worktree exists on disk, so recording it is correct even if the tick crashes later. The drift gate will check it on the next tick. PASS
7. **Concurrent ticks both creating worktree** -- TOCTOU: both might pass `worktree_path is null`, both call `git worktree add`, second one fails because the path exists. The second tick falls back to project root. Not ideal but safe (no state corruption; atomic write). Same TOCTOU class as `add-status-brakes` A6. PASS for v1.
## Code-quality observations
1. **`_git_run` is a generic wrapper** -- could be reused for other git operations in the runner. Currently only used by `_ensure_worktree`. Fine for v1.
2. **Branch name `loop/<name>`** -- matches technical.md. If the loop name contains slashes (e.g. `ci/triage`), the branch name would be `loop/ci/triage` which git treats as a hierarchical branch. But `_is_kebab_case` in status.py rejects slashes in loop names. PASS.
3. **No worktree removal on loop deletion** -- if the user deletes a loop dir, the worktree branch remains in the repo. Worktree GC is deferred to v1.1 (BACKLOG). Accepted.
## Verdict
APPROVE. Ready for bug_find.
@@ -0,0 +1,40 @@
# DOC_REVIEW: add-blast-radius-scheduler
Reviewed doc impact for task `add-blast-radius-scheduler`.
## Doc edits in this task
### 1. `design/loops/technical.md` section 7 step 4
Updated to document the runner's worktree creation behavior, including branch-exists retry and non-git fallback. No "deferred" language remains. PASS
### 2. `CHANGELOG.md`
New `[unreleased]` entry for blast-radius scheduler. PASS
### 3. `AGENTS.md`
No new CLI surface. The runner's worktree creation is internal behavior, not a user-facing command. No change needed.
### 4. `README.md`
The loop engineering section already mentions per-loop worktrees (D2). No change needed.
### 5. `design/loops/functional.md`
Already documents `--no-worktree` as the opt-out mechanism (via `blast_radius.use_worktree: false`). No change needed.
### 6. `templates/loops/ci-triage/loop.json`
Already has `"use_worktree": true` in `blast_radius`. No change needed.
### 7. `prompts/`
No prompt changes in this task. No change.
## Code-doc consistency check
- `technical.md` section 7 step 4: worktree creation flow matches `_ensure_worktree` implementation. PASS
- `functional.md` section on `--no-worktree`: matches `use_worktree: false` behavior. PASS
- `ci-triage/loop.json` `blast_radius.use_worktree`: matches the default-true behavior when field is missing. PASS
## Summary
Doc edits in this task:
- `design/loops/technical.md` section 7 step 4 updated.
- `CHANGELOG.md` new entry.
No code-doc mismatches. READY for referee.
@@ -0,0 +1,49 @@
# Implementation: add-blast-radius-scheduler
Implements per-loop git worktree creation in `scripts/loop-runner.py` per SPEC R1-R6.
## Files changed
- `scripts/loop-runner.py` -- added `_git_run`, `_ensure_worktree`, `LOOP_WORKTREE_DIR` constant; replaced step 4 stub with worktree creation; updated module docstring.
- `tests/test_blast_radius.py` -- 15 tests covering R1-R6 + regression.
## R-by-R coverage
| Req | Code |
|-----|------|
| R1 _ensure_worktree | `_ensure_worktree(state, cfg, loop_path, project_dir)` -- checks `blast_radius.use_worktree` (default True), reuses existing worktree, creates new via `git worktree add` |
| R2 graceful degradation | `_git_run` catches `OSError`/`SubprocessError`; not-a-repo and worktree-add failures log WARNING and return `project_dir` |
| R3 cmd_tick integration | Step 4 replaced: `cwd = _ensure_worktree(state, cfg, loop_path, project_dir)` |
| R4 branch already exists | First try `-b loop/<name>`; on "already exists" in stderr, retry without `-b` (checkout existing branch) |
| R5 state consistency | Stale `worktree_path` (path doesn't exist) is cleared before recreation; state written atomically via `_write_state_loop` |
| R6 platform paths | `pathlib.Path` for all path construction; git handles OS-specific normalization |
| R7 doc updates | technical.md section 7 step 4 updated; CHANGELOG.md updated |
| R8 tests | 15 tests in `tests/test_blast_radius.py` |
## Key design decisions
- `use_worktree` defaults to `True` when the field is missing (D2: "default is worktree-on").
- Worktree path is `<loop_path>/worktree` (matches `LOOP_WORKTREE_DIR` in status.py).
- Branch name is `loop/<loop_name>` (matches technical.md section 7 step 4).
- `_git_run` is a thin wrapper around `subprocess.run(["git", ...])` that returns `(rc, stdout, stderr)` and catches all `OSError`/`SubprocessError`.
- State is written inside `_ensure_worktree` (not deferred to step 10) because the worktree exists on disk immediately after creation; recording it in state is correct even if the tick crashes later.
- The drift gate (`_gate_worktree_drift` in status.py) already handles the case where `worktree_path` is null (skips the check). So fallback to project root is safe.
## Tests (`tests/test_blast_radius.py`)
15 tests across 6 classes; all `subprocess.run` calls stubbed via monkeypatch.
- `TestEnsureWorktree` (4): creates worktree; reuses existing; use_worktree=false returns project root; missing field defaults true.
- `TestGracefulDegradation` (3): falls back when not git repo; falls back when git missing; falls back when worktree add fails.
- `TestBranchExists` (1): reuses existing branch (retry without -b).
- `TestStateConsistency` (2): clears stale worktree path; recreates after deletion.
- `TestTickIntegration` (3): first tick creates worktree; second tick reuses; falls back when no git.
- `TestPlatformPaths` (1): worktree path constructed via pathlib.
- `TestRegression` (1): existing loop with worktree_path ticks unchanged.
## Verification
- `python3 -m py_compile scripts/loop-runner.py` -- PASS
- `python3 -m pytest tests/test_blast_radius.py -v` -- 15 passed
- `python3 -m pytest tests/ -q` -- 369 passed (354 + 15 new)
- `bash -n scripts/*.sh` -- no shell changes
+77
View File
@@ -0,0 +1,77 @@
# 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: `<loop_path>/worktree` (using `LOOP_WORKTREE_DIR = "worktree"`).
2. Determine the branch name: `loop/<name>` where `<name>` 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 <worktree_path> -b loop/<name>` from `project_dir`. If the branch already exists, use `git worktree add <worktree_path> loop/<name>` (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 <path> -b loop/<name>` fails because the branch already exists (exit code 128, stderr contains `already exists`), retry with `git worktree add <path> loop/<name>` (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).
@@ -0,0 +1,44 @@
# VERDICT: add-blast-radius-scheduler
**Status: PASS**
Task delivers per-loop git worktree creation in the runner, closing the last gap in the blast-radius enforcement chain. With this task, the runner ensures a worktree exists before spawning any role, the drift gate checks it, and `--can-edit --loop-worktree` scopes file edits to it.
## Requirement coverage
| Req | Status | Tests |
|-----|--------|-------|
| R1 _ensure_worktree | delivered | TestEnsureWorktree (4) |
| R2 graceful degradation | delivered | TestGracefulDegradation (3) |
| R3 cmd_tick integration | delivered | TestTickIntegration (3) |
| R4 branch already exists | delivered | TestBranchExists (1) |
| R5 state consistency | delivered | TestStateConsistency (2) |
| R6 platform paths | delivered | TestPlatformPaths (1) |
| R7 doc updates | delivered | technical.md + CHANGELOG |
| R8 tests | delivered | 15 tests + regression |
Tests: 15 new. Full suite: **369 passed** (was 354 + 15 new). No regressions.
## Blast-radius enforcement chain -- complete
1. **Worktree creation** (this task): runner creates `<loop>/worktree` on branch `loop/<name>`.
2. **Drift detection** (task 2): `--check-gate` runs `git diff --name-only main...HEAD` restricted to `file_scope`.
3. **File scope enforcement** (task 2): `--can-edit --loop [--loop-worktree]` checks paths against `blast_radius.file_scope`.
4. **Graceful fallback** (this task): non-git projects fall back to project root with WARNING; drift gate skips when `worktree_path` is null.
## Agnosticism preserved
- **Git-agnostic**: falls back gracefully when git is unavailable. Loops on non-git projects work (edits go to primary checkout).
- **Platform-agnostic**: `pathlib.Path` for all path construction. Git handles OS-specific path normalization.
- **Model-agnostic**: no model inspection. Worktree creation is infrastructure, not model behavior.
## Hardening items deferred
1. Worktree GC / pruning (BACKLOG `worktree-gc`) -> v1.1.
2. `--no-worktree` CLI flag for `--create-loop` -> v1.1 (convenience sugar).
3. `blast_radius.base_branch` parameterization -> v1.1.
4. fcntl lock on worktree creation (TOCTOU A4) -> v1.1 (same item as `add-status-brakes` A6).
## Resolution
**PASS -- proceed to `complete`.** Task 5 completes the blast-radius enforcement chain. The runner now creates worktrees, the drift gate checks them, and `--can-edit` scopes file edits. Remaining tasks: 6 (templates-onboarding), 7 (self-improvement-loop), 8 (install-update-flow), 9 (move-completed-tasks).