55 lines
4.7 KiB
Markdown
55 lines
4.7 KiB
Markdown
# Implementation: add-loop-runner
|
||||
|
|
|
|||
|
|
Implements `scripts/loop-runner.py` per SPEC R1–R8.
|
|||
|
|
|
|||
|
|
## File added
|
|||
|
|
|
|||
|
|
`scripts/loop-runner.py` — single entry point for `--mode tick` and `--mode daemon`. Stdlib only (no new pip deps).
|
|||
|
|
|
|||
|
|
## Layout
|
|||
|
|
|
|||
|
|
- `LOOP_*` constants mirroring `status.py` for the few state-shape facts the runner needs.
|
|||
|
|
- Small helpers duplicated inline rather than imported across scripts (per technical.md: scripts stay independent; no cross-script imports): `_find_project_dir`, `_loops_dir`, `_loop_dir`, `_read_state_loop`, `_write_state_loop`, `_read_loop_config`, `_append_tick_log`, `_halt_loop`.
|
|||
|
|
- `_run_json(args)` — invokes a subprocess and parses the last stdout line as JSON. Returns `None` on subprocess failure, non-zero exit, empty output, or JSON parse failure. Used by both `_gate` and `_context_floor_ok`.
|
|||
|
|
- `_substitute(template, mapping)` — token substitution for `loop.json` `harness.command` strings. Recognized tokens: `{prompt}`, `{cwd}`, `{output}`, `{artifact}`, `{verdict}`, `{current_task}`, `{current_phase}`.
|
|||
|
|
- `_invoke_harness(harness_cfg, role, prompt_path, cwd, extras)` — builds the harness command, substitutes tokens, runs `subprocess.run`, returns stdout. Default command when `harness.command` is missing is `["opencode", "run", "--prompt-file", "{prompt}", "--cwd", "{cwd}"]`.
|
|||
|
|
- `parse_verdict(text)` — strict graded-JSON parser. Accepts raw JSON, fenced ```json blocks, lines with leading `//` or `#` comments stripped. Returns `None` when missing `pass` key or total garbage. Otherwise returns `{"pass": bool, "score": float, "reasons": list, "next_hint": str?}`.
|
|||
|
|
- `_gate(...)`, `_context_floor_ok()`, `_role_prompt(...)`, `_score_window(...)`, `_loop_max_iterations(...)`, `_outputs_dir(...)`, `_make_completed` (test helper used inline).
|
|||
|
|
- `cmd_tick(args)` — the tick flow per technical.md §7. Returns a summary dict, never raises (clean-exit on every path).
|
|||
|
|
- `cmd_daemon(args)` — `time.sleep(interval)` loop bounded by `--max-iterations`. `KeyboardInterrupt` stops cleanly with a `DAEMON_STOPPED` log entry.
|
|||
|
|
- `main()` — argparse with `--mode {tick,daemon}`, `--loop`, `--project`, `--interval`, `--max-iterations`, `--json`.
|
|||
|
|
|
|||
|
|
## R-by-R coverage
|
|||
|
|
|
|||
|
|
| Req | Code |
|
|||
|
|
|-----|------|
|
|||
|
|
| R1 entrypoint | `main()` argparse, `--mode` required choices; `cmd_tick` returns summary with `skipped:True` and `reason:"untracked"` for missing `.state.loop` |
|
|||
|
|
| R2 tick flow | `cmd_tick` 7-route: load → gate → find_work → cwd → ctx-floor → spawn Implement → spawn Verify → parse verdict → cap score → spawn Orchestrate → atomic write state → tick log |
|
|||
|
|
| R3 daemon | `cmd_daemon` |
|
|||
|
|
| R4 harness substitution | `_substitute`, `_invoke_harness` |
|
|||
|
|
| R5 context-floor guard | `_context_floor_ok` called before any harness subprocess; halts `human_intervention` on `loop_mode_eligible=False` |
|
|||
|
|
| R6 idempotence | state writes only in step 10 (after parse_verdict succeeds and orchestrator ran); pre-step-10 crashes leave `.state.loop` untouched |
|
|||
|
|
| R7 tests | `tests/test_loop_runner.py` (18 tests) |
|
|||
|
|
| R8 out-of-scope | none — deferred to tasks 4–7 (audit work_source, backlog, worktree creation, the prompts themselves) |
|
|||
|
|
|
|||
|
|
## Tests (`tests/test_loop_runner.py`)
|
|||
|
|
|
|||
|
|
18 tests across 7 classes; all `subprocess.run` and `_run_json` calls stubbed via monkeypatch so no live LLM calls hit in CI.
|
|||
|
|
|
|||
|
|
- `TestEntrypoint` (2): unknown-loop exits 0; unknown-mode exits 2.
|
|||
|
|
- `TestTickFlow` (5): tick-pass advances iteration_count; skip-when-halted; skip-when-untracked; skip-no-current-task; skip-when-gate-subprocess-fails.
|
|||
|
|
- `TestContextFloor` (1): refuses below floor; halts `human_intervention`; implement harness never invoked.
|
|||
|
|
- `TestVerifierParseFailure` (5): parse-failure halts and **does not** advance iteration_count (idempotence); fenced JSON parses; JSON with line comments parses; missing `pass` key → None; empty text → None.
|
|||
|
|
- `TestScoreHistory` (1): 5 ticks with window=3 → final `score_history` length is 3 and equals `[0.4, 0.4, 0.4]`.
|
|||
|
|
- `TestHarnessSubstitution` (1): custom `harness.command` with `--prompt/--cwd/--out/--artifact` tokens; verify-role invocation sees the implement role's output path as `--artifact <...-implement.json>`.
|
|||
|
|
- `TestDaemonMode` (1): `--max-iterations 3` runs 3 ticks then exits 0; `time.sleep` no-op via monkeypatch.
|
|||
|
|
- `TestOrchestratorOrdering` (1): implement → verify → orchestrate order observed via tagged handlers.
|
|||
|
|
- `TestJsonOutput` (1): `--json` prints structured tick summary as last line; parsed via `lr.main()` + `capsys` (since `subprocess.run` is patched).
|
|||
|
|
|
|||
|
|
## Verification
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
python3 -m py_compile scripts/loop-runner.py
|
|||
|
|
python3 -m pytest tests/test_loop_runner.py -q # 18 passed
|
|||
|
|
python3 -m pytest tests/ -q # 328 passed (was 310 + 18 new)
|
|||
|
|
```
|