diff --git a/README.md b/README.md index 8aae312..47e97fc 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ board to a PR — or fork it as a starting point. coder/acceptance/test command ⇒ honest degrade to the single shot; missing `coder_solve_fusion_delegate` ⇒ the ladder simply stops at tree-search — see `coder_seam.py`. +- **Rung diagnostic — `POST /api/plugins/project_board/features/{id}/test-rung`** + (operator-only, no `@tool` wrapper): runs exactly ONE named rung + (`greedy`/`best-of-k`/`tree-search`/`fusion`) against a feature's real acceptance + tests, in a throwaway worktree that's ALWAYS reaped — never promoted, no PR, no + board state touched. Verifying a specific rung — fusion especially, only + otherwise reached after three cheaper rungs fail — shouldn't require contriving a + task hard enough to fail its way there. `{"rung": "fusion"}` in the body; `coder` + optional (defaults to `project_board.coder`). - **Planning layer** — two reasoning subagents (`decompose` + `antagonist`) driven by the `decompose-project` skill: idea → outline → MADR ADRs → epics › milestones › features, hardened by an adversary, with a per-epic human gate. @@ -138,7 +146,9 @@ project_board: - **HTTP API** (`/plugins/project_board/*`): `epics`, `milestones`, `features`, `features/{id}/{ready,dep,block,unblock,ci}`, and `/webhook/pr` (the Done edge — a stable public URL GitHub posts to; ungated so GitHub, which can't send a bearer, - reaches it). + reaches it). `features/{id}/{cancel,test-rung}` and `DELETE features/{id}` are + **operator-only** — no `@tool` wrapper, so the board's own lead agent has no way + to call them. - **Watch it:** the **Board** console view (left-rail) at `/plugins/project_board/board` — Kanban + list, live-refreshing, served by the same router as the API (so the declared view path is genuinely mounted). diff --git a/api.py b/api.py index 345d894..dbc8f03 100644 --- a/api.py +++ b/api.py @@ -242,4 +242,84 @@ async def _delete(fid: str, body: dict = Body(default={})): cancel to keep a visible, reopenable audit lane; use delete to leave no trace.""" return _guard(lambda: store().delete_feature(fid, str((body or {}).get("reason", "")))) + # ── coder.solve() rung diagnostic (ADR 0064) — OPERATOR ONLY, deliberately no + # @tool wrapper: same boundary this router already draws around cancel/ + # block/delete — the board's own lead agent has no tool to reach this. + @router.post("/features/{fid}/test-rung") + async def _test_rung(fid: str, body: dict = Body(...)): + """Run exactly ONE named rung of coder.solve() against this feature's REAL + acceptance tests, in a throwaway worktree that's ALWAYS reaped — never + promoted, no PR opened, no board state touched. For verifying a rung + actually works (fusion especially — otherwise only reached after three + cheaper rungs fail) without contriving a task that fails its way there. + + Body: ``{"rung": "greedy"|"best-of-k"|"tree-search"|"fusion", "coder": ""}`` (``coder`` optional, defaults to ``project_board.coder``).""" + rung = str(body.get("rung", "")).strip() + if rung not in ("greedy", "best-of-k", "tree-search", "fusion"): + raise HTTPException(400, "rung must be one of: greedy, best-of-k, tree-search, fusion") + + f = _guard(lambda: store().get_feature(fid)) + if f is None: + raise HTTPException(404, f"unknown feature {fid!r}") + if not str(f.get("acceptance_criteria") or "").strip(): + raise HTTPException(400, f"feature {fid!r} has no acceptance_criteria — nothing to verify a rung against") + + from . import coder_seam + + if coder_seam._import_solve() is None: + raise HTTPException(400, "the `coder` plugin isn't installed/enabled on this host") + + test_cmd = ( + str((cfg or {}).get("coder_solve_test_cmd") or "").strip() + or str((cfg or {}).get("local_gate_cmd") or "").strip() + ) + if not test_cmd: + raise HTTPException(400, "no coder_solve_test_cmd or local_gate_cmd configured — nothing to run tests with") + + coder_name = str(body.get("coder") or (cfg or {}).get("coder", "proto")) + coder = coder_seam.resolve_delegate(coder_name, "acp") + if coder is None: + raise HTTPException(400, f"acp delegate {coder_name!r} not found — check `delegates:`") + + fusion_delegate = None + if rung == "fusion": + fusion_name = str((cfg or {}).get("coder_solve_fusion_delegate") or "").strip() + if not fusion_name: + raise HTTPException(400, "rung='fusion' requires project_board.coder_solve_fusion_delegate") + fusion_delegate = coder_seam.resolve_delegate(fusion_name, "openai") + if fusion_delegate is None: + raise HTTPException(400, f"openai delegate {fusion_name!r} not found — check `delegates:`") + + task = ( + f"# {f.get('title', '')}\n\n" + f"## Task\n{f.get('spec', '')}\n\n" + f"## Files to create / modify\n" + + ("\n".join(f"- {p}" for p in (f.get("files_to_modify") or [])) or "(none listed)") + + f"\n\n## Acceptance criteria (definition of done)\n{f.get('acceptance_criteria', '')}\n" + ) + + try: + result = await coder_seam.test_rung( + rung=rung, + task=task, + coder=coder, + repo=(cfg or {}).get("repo", "."), + base=(cfg or {}).get("base_branch", "main"), + root=(cfg or {}).get("worktrees_root", ".worktrees"), + fid=fid, + dispatch_timeout=float((cfg or {}).get("coder_timeout_s", 1800)) or None, + test_cmd=test_cmd, + test_timeout=float((cfg or {}).get("coder_solve_test_timeout_s", 300)), + budget=max(1, int((cfg or {}).get("coder_solve_budget", 6))), + k=max(1, int((cfg or {}).get("coder_solve_k", 3))), + tree_depth=max(0, int((cfg or {}).get("coder_solve_tree_depth", 2))), + fusion_delegate=fusion_delegate, + fusion_k=max(1, int((cfg or {}).get("coder_solve_fusion_k", 2))), + files_to_modify=f.get("files_to_modify") or [], + ) + except Exception as exc: # noqa: BLE001 — surface as a 400, not a raw 500 + raise HTTPException(400, f"test-rung failed: {exc}") from exc + return result + return router diff --git a/coder_seam.py b/coder_seam.py index a8d4c6d..f4199c8 100644 --- a/coder_seam.py +++ b/coder_seam.py @@ -46,7 +46,15 @@ context lines is a common failure mode `git apply` doesn't forgive. Only reached when a ``fusion_delegate`` is configured (an ``openai``-type Delegate, already resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None`` -and stops at tree-search exactly as before (honest degrade, unchanged).""" +and stops at tree-search exactly as before (honest degrade, unchanged). + +**``test_rung`` (operator-only diagnostic).** Verifying a specific rung — fusion +especially, only otherwise reached after three cheaper rungs fail — shouldn't +require contriving a task hard enough to fail its way there. ``test_rung`` runs +ONE named rung once against a feature's real acceptance tests in a throwaway +worktree that's ALWAYS reaped, win or lose — never promoted, no PR. Exposed via +api.py's ``test-rung`` route with no ``@tool`` wrapper, so it's operator-only, +not something the board's own lead agent can reach for itself.""" from __future__ import annotations @@ -61,6 +69,24 @@ log = logging.getLogger("protoagent.plugins.project_board") + +def resolve_delegate(name: str, expect_type: str): + """Look up a live delegate by name from the delegates registry. Returns the + Delegate or None (not configured / wrong type / plugin disabled). Shared by + ``loop.py`` (coder/reviewer/fusion resolution in the real dispatch path) and + ``api.py`` (the operator-only test-rung route) — one lookup, not two copies.""" + try: + from plugins.delegates.registry import DelegateRegistry + from plugins.delegates.store import merged_delegates + + d = DelegateRegistry(merged_delegates()).get(name) + except Exception: # noqa: BLE001 — delegates plugin may be disabled + return None + if d is None or d.type != expect_type: + return None + return d + + # ``### path/to/file.py`` header, then a fenced block (any/no language hint) holding # that file's COMPLETE new content. Deliberately simple/strict: a fusion completion # that doesn't follow the format parses to no files, which just fails verify() like @@ -475,3 +501,85 @@ async def dispatch( ) result_text = f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}" return canon_wt, canon_branch, result_text + + +async def test_rung( + *, + rung: str, + task: str, + coder, + repo: str, + base: str, + root: str, + fid: str, + dispatch_timeout: float | None, + test_cmd: str, + test_timeout: float, + budget: int = 10, + k: int = 3, + tree_depth: int = 2, + fusion_delegate=None, + fusion_k: int = 2, + files_to_modify: list[str] | None = None, + _solve=None, + _budget_cls=None, + _verdict_cls=None, + _fusion_dispatch=None, +) -> dict: + """Operator-only diagnostic (ADR 0064): run exactly ONE named rung of + ``coder.solve()`` against a feature's REAL acceptance tests, in a throwaway + worktree that is ALWAYS reaped afterward — never promoted, no PR opened, no + board state touched. For verifying a rung actually works (especially fusion, + only otherwise reached after three cheaper rungs fail) without contriving a + task hard enough to fail its way there. + + Deliberately separate from ``dispatch()``: that function's contract (promote + the winner, raise ``SolveExhausted`` on exhaustion) is shaped for the board's + real per-feature build — mixing test semantics into it would risk the real + dispatch path. This is exposed to operators only via api.py's ``test-rung`` + route, which carries NO ``@tool`` wrapper — the board's own lead agent has no + way to call this itself (see api.py's docstring for the same boundary the + plugin already draws around ``/features/{id}/cancel`` etc.).""" + if _solve is not None: + solve, Budget, Verdict = _solve, _budget_cls, _verdict_cls + else: + from plugins.coder.solve import Budget, Verdict, solve + + adapter = _WorktreeSolveAdapter( + repo=repo, + base=base, + root=root, + fid=f"{fid}.test", + coder=coder, + dispatch_timeout=dispatch_timeout, + test_cmd=test_cmd, + test_timeout=test_timeout, + verdict_cls=Verdict, + fusion_delegate=fusion_delegate, + files_to_modify=files_to_modify, + _fusion_dispatch=_fusion_dispatch, + ) + try: + result = await solve( + task, + generate=adapter.generate, + verify=adapter.verify, + budget=Budget(budget), + k=k, + tree_depth=tree_depth, + fusion_generate=adapter.generate_fusion if fusion_delegate is not None else None, + fusion_k=fusion_k, + force_rung=rung, + ) + finally: + # ALWAYS reap — pass or fail, this is a diagnostic run, never a real build. + for wt, branch in adapter.candidates: + await worktree.remove_worktree(repo, wt, branch) + return { + "rung": result.rung, + "passed": result.passed, + "gens_spent": result.gens_spent, + "candidates_tried": result.candidates_tried, + "note": result.note, + "verdict_output": result.verdict.output if result.verdict else "", + } diff --git a/loop.py b/loop.py index 9b1d82c..1656e35 100644 --- a/loop.py +++ b/loop.py @@ -891,17 +891,10 @@ def _use_coder_solve(self, feature: dict) -> bool: def _resolve_delegate(self, name: str, expect_type: str): """Look up a live delegate by name from the delegates registry. Returns the - Delegate or None (not configured / wrong type / plugin disabled).""" - try: - from plugins.delegates.registry import DelegateRegistry - from plugins.delegates.store import merged_delegates - - d = DelegateRegistry(merged_delegates()).get(name) - except Exception: # noqa: BLE001 — delegates plugin may be disabled - return None - if d is None or d.type != expect_type: - return None - return d + Delegate or None (not configured / wrong type / plugin disabled). Thin + wrapper — the real lookup is shared with api.py's test-rung route via + ``coder_seam.resolve_delegate``.""" + return coder_seam.resolve_delegate(name, expect_type) async def _run_fixups(self, wt: str) -> None: """Run the repo's auto-fix command (``format_cmd``, e.g. diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 48668ee..59e4793 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.28.0 +version: 0.29.0 description: >- A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready → in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an diff --git a/pyproject.toml b/pyproject.toml index 62d084a..c013993 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.28.0" +version = "0.29.0" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/tests/test_api.py b/tests/test_api.py index 0bcee67..2d1c616 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,7 +20,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from project_board import api +from project_board import api, coder_seam from project_board.store import BoardError ROOT = Path(__file__).resolve().parent.parent @@ -302,3 +302,151 @@ def test_ci_fail_with_a_single_coder_bounces_to_in_progress(monkeypatch): body = r.json() assert body["escalated"] is False and body["requeued"] is False assert any(call[0] == "bounce_ci_fail" for call in store.calls) + + +# ── /features/{fid}/test-rung — operator-only diagnostic (ADR 0064) ───────────── +# No @tool wrapper anywhere in coder_seam.py/api.py exposes this to the board's +# own lead agent — these tests only exercise the HTTP route directly, mirroring +# how an operator (console/curl) would reach it. + + +def _feature_with_ac(fid="bd-7", files=None): + return { + "id": fid, + "title": "T", + "spec": "do the thing", + "acceptance_criteria": "WHEN x THE SYSTEM SHALL y", + "files_to_modify": files or ["a.py"], + "board_state": "ready", + } + + +def test_test_rung_rejects_an_unknown_rung_name(monkeypatch): + store = FakeStore() + c = _client(monkeypatch, store) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "nonsense"}) + assert r.status_code == 400 + assert "rung must be one of" in r.json()["detail"] + + +def test_test_rung_404s_on_an_unknown_feature(monkeypatch): + store = FakeStore() + c = _client(monkeypatch, store) + r = c.post("/api/plugins/project_board/features/missing/test-rung", json={"rung": "greedy"}) + assert r.status_code == 404 + + +def test_test_rung_400s_without_acceptance_criteria(monkeypatch): + store = FakeStore() # get_feature returns {"id": fid, "board_state": "ready"} — no AC + c = _client(monkeypatch, store) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 400 + assert "acceptance_criteria" in r.json()["detail"] + + +def test_test_rung_400s_when_coder_plugin_unavailable(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: None) + c = _client(monkeypatch, store) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 400 + assert "coder` plugin" in r.json()["detail"] + + +def test_test_rung_400s_without_a_test_command(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + c = _client(monkeypatch, store, cfg={}) # no coder_solve_test_cmd, no local_gate_cmd + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 400 + assert "test_cmd" in r.json()["detail"] or "gate_cmd" in r.json()["detail"] + + +def test_test_rung_400s_when_the_coder_delegate_is_missing(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "resolve_delegate", lambda name, t: None) + c = _client(monkeypatch, store, cfg={"coder_solve_test_cmd": "pytest -q"}) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 400 + assert "acp delegate" in r.json()["detail"] + + +def test_test_rung_fusion_400s_without_a_configured_fusion_delegate(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "resolve_delegate", lambda name, t: object()) + c = _client(monkeypatch, store, cfg={"coder_solve_test_cmd": "pytest -q"}) # no coder_solve_fusion_delegate + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "fusion"}) + assert r.status_code == 400 + assert "coder_solve_fusion_delegate" in r.json()["detail"] + + +def test_test_rung_happy_path_calls_coder_seam_test_rung_and_returns_its_result(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + + resolved = {} + + def _resolve(name, expect_type): + resolved[expect_type] = name + return object() + + monkeypatch.setattr(coder_seam, "resolve_delegate", _resolve) + + seen_kwargs = {} + + async def _fake_test_rung(**kwargs): + seen_kwargs.update(kwargs) + return { + "rung": "greedy", + "passed": True, + "gens_spent": 1, + "candidates_tried": 1, + "note": "ok", + "verdict_output": "", + } + + monkeypatch.setattr(coder_seam, "test_rung", _fake_test_rung) + + c = _client( + monkeypatch, + store, + cfg={"coder_solve_test_cmd": "pytest -q", "coder": "proto", "repo": "/repo", "base_branch": "main"}, + ) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 200 + assert r.json() == { + "rung": "greedy", + "passed": True, + "gens_spent": 1, + "candidates_tried": 1, + "note": "ok", + "verdict_output": "", + } + assert resolved == {"acp": "proto"} + assert seen_kwargs["rung"] == "greedy" + assert seen_kwargs["repo"] == "/repo" + assert "WHEN x THE SYSTEM SHALL y" in seen_kwargs["task"] + assert seen_kwargs["files_to_modify"] == ["a.py"] + + +def test_test_rung_surfaces_a_solve_failure_as_400_not_500(monkeypatch): + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "resolve_delegate", lambda name, t: object()) + + async def _boom(**kwargs): + raise RuntimeError("worktree op failed") + + monkeypatch.setattr(coder_seam, "test_rung", _boom) + c = _client(monkeypatch, store, cfg={"coder_solve_test_cmd": "pytest -q"}) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "greedy"}) + assert r.status_code == 400 + assert "test-rung failed" in r.json()["detail"] diff --git a/tests/test_coder_seam.py b/tests/test_coder_seam.py index 7169337..c9775c0 100644 --- a/tests/test_coder_seam.py +++ b/tests/test_coder_seam.py @@ -849,3 +849,214 @@ async def _fake_solve(task, *, generate, verify, budget, k, tree_depth, fusion_g _verdict_cls=_FakeVerdict, ) assert seen["fusion_generate"] is None + + +# ── test_rung(): operator-only diagnostic — always reaps, never promotes ──────── + + +async def test_test_rung_always_reaps_even_on_a_pass(monkeypatch, tmp_path): + """A passing test_rung() candidate must still be reaped — this is a diagnostic + dry-run, never a real dispatch. (dispatch() PROMOTES a winner; test_rung() must + not, or a 'just checking fusion works' call would silently ship a feature.)""" + removed = [] + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + async def _dispatch(coder, wt, prompt, *, timeout=None): + return "reply" + + async def _remove(repo, wt, branch=""): + removed.append(wt) + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + monkeypatch.setattr(worktree, "dispatch_coder", _dispatch) + monkeypatch.setattr(worktree, "remove_worktree", _remove) + + async def _fake_solve( + task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2, force_rung=None + ): + assert force_rung == "greedy" # test_rung must pass force_rung through + c = await generate(task, feedback=None) + return _FakeResult(solution=c, passed=True, rung="greedy", gens_spent=1, candidates_tried=1) + + result = await coder_seam.test_rung( + rung="greedy", + task="do the thing", + coder=object(), + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + _solve=_fake_solve, + _budget_cls=_FakeBudget, + _verdict_cls=_FakeVerdict, + ) + assert result == { + "rung": "greedy", + "passed": True, + "gens_spent": 1, + "candidates_tried": 1, + "note": "", + "verdict_output": "", + } + assert len(removed) == 1 # the winning candidate was reaped, NOT promoted + + +async def test_test_rung_reaps_on_a_fail_too(monkeypatch, tmp_path): + removed = [] + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + async def _dispatch(coder, wt, prompt, *, timeout=None): + return "reply" + + async def _remove(repo, wt, branch=""): + removed.append(wt) + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + monkeypatch.setattr(worktree, "dispatch_coder", _dispatch) + monkeypatch.setattr(worktree, "remove_worktree", _remove) + + async def _fake_solve( + task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2, force_rung=None + ): + c = await generate(task, feedback=None) + v = _FakeVerdict(passed=False, total=2, failed=1, output="1 failed") + return _FakeResult( + solution=c, + passed=False, + rung="greedy", + gens_spent=1, + candidates_tried=1, + verdict=v, + note="forced greedy (test) — 1/2 failing", + ) + + result = await coder_seam.test_rung( + rung="greedy", + task="do the thing", + coder=object(), + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + _solve=_fake_solve, + _budget_cls=_FakeBudget, + _verdict_cls=_FakeVerdict, + ) + assert result["passed"] is False + assert result["verdict_output"] == "1 failed" + assert len(removed) == 1 # still reaped despite the fail + + +async def test_test_rung_reaps_even_if_solve_raises(monkeypatch, tmp_path): + removed = [] + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + async def _dispatch(coder, wt, prompt, *, timeout=None): + return "reply" + + async def _remove(repo, wt, branch=""): + removed.append(wt) + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + monkeypatch.setattr(worktree, "dispatch_coder", _dispatch) + monkeypatch.setattr(worktree, "remove_worktree", _remove) + + async def _fake_solve( + task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2, force_rung=None + ): + await generate(task, feedback=None) + raise worktree.CoderTimeout("boom") + + try: + await coder_seam.test_rung( + rung="greedy", + task="t", + coder=object(), + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + _solve=_fake_solve, + _budget_cls=_FakeBudget, + _verdict_cls=_FakeVerdict, + ) + raise AssertionError("expected CoderTimeout to propagate") + except worktree.CoderTimeout: + pass + assert len(removed) == 1 # reaped even though solve() raised + + +async def test_test_rung_forwards_fusion_and_files_to_modify(monkeypatch, tmp_path): + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + monkeypatch.setattr(worktree, "remove_worktree", lambda *a, **k: _noop()) + + async def _noop(): + return None + + seen = {} + + async def _fake_solve( + task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2, force_rung=None + ): + seen["force_rung"] = force_rung + seen["fusion_generate_is_none"] = fusion_generate is None + seen["fusion_k"] = fusion_k + return _FakeResult(solution="x", passed=True, rung="fusion", gens_spent=1, candidates_tried=1) + + await coder_seam.test_rung( + rung="fusion", + task="t", + coder=object(), + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + fusion_delegate=object(), + fusion_k=5, + files_to_modify=["a.py"], + _solve=_fake_solve, + _budget_cls=_FakeBudget, + _verdict_cls=_FakeVerdict, + _fusion_dispatch=lambda *a, **k: _noop(), + ) + assert seen["force_rung"] == "fusion" + assert seen["fusion_generate_is_none"] is False # fusion_delegate given → wired through + assert seen["fusion_k"] == 5 + + +# ── resolve_delegate: shared by loop.py and api.py's test-rung route ───────────── + + +def test_resolve_delegate_returns_none_when_delegates_plugin_absent(): + """`plugins.delegates` is genuinely absent in this standalone test env — the + honest-degrade case in production too when the plugin's disabled.""" + assert coder_seam.resolve_delegate("anything", "acp") is None