Skip to content

Commit ce91ad1

Browse files
mabry1985Josh Mabryclaude
authored
feat: operator-only test-rung route to verify a rung works (v0.29.0) (#65)
Companion to protoAgent#1749 (coder.solve()'s force_rung). Verifying fusion actually works required contriving a task hard enough to fail greedy, best-of-k, AND tree-search first -- impractical for a quick check. Add POST /api/plugins/project_board/features/{id}/test-rung: runs exactly ONE named rung against a feature's real acceptance tests, in a throwaway worktree that is ALWAYS reaped -- never promoted, no PR opened, no board state touched. coder_seam.test_rung() is 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. Deliberately NO @tool wrapper -- the board's own lead agent has no way to call this itself, the same boundary this router already draws around /features/{id}/cancel and DELETE /features/{id} (both operator-only, neither exposed as an agent tool). Extracted loop.py's _resolve_delegate into coder_seam.resolve_delegate (a module-level function) so api.py's new route and loop.py's real dispatch path share one lookup instead of two copies. 269 passed (was 260; +9 in test_coder_seam.py for test_rung's always-reap/pass/fail/exception/fusion-forwarding behavior, +9 in test_api.py for the route's validation gates + happy path + 400-not-500 on a solve failure). Co-authored-by: Josh Mabry <josh@protolabs.studio> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d479f53 commit ce91ad1

8 files changed

Lines changed: 566 additions & 16 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ board to a PR — or fork it as a starting point.
6363
coder/acceptance/test command ⇒ honest degrade to the single shot; missing
6464
`coder_solve_fusion_delegate` ⇒ the ladder simply stops at tree-search — see
6565
`coder_seam.py`.
66+
- **Rung diagnostic — `POST /api/plugins/project_board/features/{id}/test-rung`**
67+
(operator-only, no `@tool` wrapper): runs exactly ONE named rung
68+
(`greedy`/`best-of-k`/`tree-search`/`fusion`) against a feature's real acceptance
69+
tests, in a throwaway worktree that's ALWAYS reaped — never promoted, no PR, no
70+
board state touched. Verifying a specific rung — fusion especially, only
71+
otherwise reached after three cheaper rungs fail — shouldn't require contriving a
72+
task hard enough to fail its way there. `{"rung": "fusion"}` in the body; `coder`
73+
optional (defaults to `project_board.coder`).
6674
- **Planning layer** — two reasoning subagents (`decompose` + `antagonist`) driven by
6775
the `decompose-project` skill: idea → outline → MADR ADRs → epics › milestones ›
6876
features, hardened by an adversary, with a per-epic human gate.
@@ -138,7 +146,9 @@ project_board:
138146
- **HTTP API** (`/plugins/project_board/*`): `epics`, `milestones`, `features`,
139147
`features/{id}/{ready,dep,block,unblock,ci}`, and `/webhook/pr` (the Done edge —
140148
a stable public URL GitHub posts to; ungated so GitHub, which can't send a bearer,
141-
reaches it).
149+
reaches it). `features/{id}/{cancel,test-rung}` and `DELETE features/{id}` are
150+
**operator-only** — no `@tool` wrapper, so the board's own lead agent has no way
151+
to call them.
142152
- **Watch it:** the **Board** console view (left-rail) at
143153
`/plugins/project_board/board` — Kanban + list, live-refreshing, served by the
144154
same router as the API (so the declared view path is genuinely mounted).

api.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,84 @@ async def _delete(fid: str, body: dict = Body(default={})):
242242
cancel to keep a visible, reopenable audit lane; use delete to leave no trace."""
243243
return _guard(lambda: store().delete_feature(fid, str((body or {}).get("reason", ""))))
244244

245+
# ── coder.solve() rung diagnostic (ADR 0064) — OPERATOR ONLY, deliberately no
246+
# @tool wrapper: same boundary this router already draws around cancel/
247+
# block/delete — the board's own lead agent has no tool to reach this.
248+
@router.post("/features/{fid}/test-rung")
249+
async def _test_rung(fid: str, body: dict = Body(...)):
250+
"""Run exactly ONE named rung of coder.solve() against this feature's REAL
251+
acceptance tests, in a throwaway worktree that's ALWAYS reaped — never
252+
promoted, no PR opened, no board state touched. For verifying a rung
253+
actually works (fusion especially — otherwise only reached after three
254+
cheaper rungs fail) without contriving a task that fails its way there.
255+
256+
Body: ``{"rung": "greedy"|"best-of-k"|"tree-search"|"fusion", "coder": "<delegate
257+
name>"}`` (``coder`` optional, defaults to ``project_board.coder``)."""
258+
rung = str(body.get("rung", "")).strip()
259+
if rung not in ("greedy", "best-of-k", "tree-search", "fusion"):
260+
raise HTTPException(400, "rung must be one of: greedy, best-of-k, tree-search, fusion")
261+
262+
f = _guard(lambda: store().get_feature(fid))
263+
if f is None:
264+
raise HTTPException(404, f"unknown feature {fid!r}")
265+
if not str(f.get("acceptance_criteria") or "").strip():
266+
raise HTTPException(400, f"feature {fid!r} has no acceptance_criteria — nothing to verify a rung against")
267+
268+
from . import coder_seam
269+
270+
if coder_seam._import_solve() is None:
271+
raise HTTPException(400, "the `coder` plugin isn't installed/enabled on this host")
272+
273+
test_cmd = (
274+
str((cfg or {}).get("coder_solve_test_cmd") or "").strip()
275+
or str((cfg or {}).get("local_gate_cmd") or "").strip()
276+
)
277+
if not test_cmd:
278+
raise HTTPException(400, "no coder_solve_test_cmd or local_gate_cmd configured — nothing to run tests with")
279+
280+
coder_name = str(body.get("coder") or (cfg or {}).get("coder", "proto"))
281+
coder = coder_seam.resolve_delegate(coder_name, "acp")
282+
if coder is None:
283+
raise HTTPException(400, f"acp delegate {coder_name!r} not found — check `delegates:`")
284+
285+
fusion_delegate = None
286+
if rung == "fusion":
287+
fusion_name = str((cfg or {}).get("coder_solve_fusion_delegate") or "").strip()
288+
if not fusion_name:
289+
raise HTTPException(400, "rung='fusion' requires project_board.coder_solve_fusion_delegate")
290+
fusion_delegate = coder_seam.resolve_delegate(fusion_name, "openai")
291+
if fusion_delegate is None:
292+
raise HTTPException(400, f"openai delegate {fusion_name!r} not found — check `delegates:`")
293+
294+
task = (
295+
f"# {f.get('title', '')}\n\n"
296+
f"## Task\n{f.get('spec', '')}\n\n"
297+
f"## Files to create / modify\n"
298+
+ ("\n".join(f"- {p}" for p in (f.get("files_to_modify") or [])) or "(none listed)")
299+
+ f"\n\n## Acceptance criteria (definition of done)\n{f.get('acceptance_criteria', '')}\n"
300+
)
301+
302+
try:
303+
result = await coder_seam.test_rung(
304+
rung=rung,
305+
task=task,
306+
coder=coder,
307+
repo=(cfg or {}).get("repo", "."),
308+
base=(cfg or {}).get("base_branch", "main"),
309+
root=(cfg or {}).get("worktrees_root", ".worktrees"),
310+
fid=fid,
311+
dispatch_timeout=float((cfg or {}).get("coder_timeout_s", 1800)) or None,
312+
test_cmd=test_cmd,
313+
test_timeout=float((cfg or {}).get("coder_solve_test_timeout_s", 300)),
314+
budget=max(1, int((cfg or {}).get("coder_solve_budget", 6))),
315+
k=max(1, int((cfg or {}).get("coder_solve_k", 3))),
316+
tree_depth=max(0, int((cfg or {}).get("coder_solve_tree_depth", 2))),
317+
fusion_delegate=fusion_delegate,
318+
fusion_k=max(1, int((cfg or {}).get("coder_solve_fusion_k", 2))),
319+
files_to_modify=f.get("files_to_modify") or [],
320+
)
321+
except Exception as exc: # noqa: BLE001 — surface as a 400, not a raw 500
322+
raise HTTPException(400, f"test-rung failed: {exc}") from exc
323+
return result
324+
245325
return router

coder_seam.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,15 @@
4646
context lines is a common failure mode `git apply` doesn't forgive. Only reached
4747
when a ``fusion_delegate`` is configured (an ``openai``-type Delegate, already
4848
resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None``
49-
and stops at tree-search exactly as before (honest degrade, unchanged)."""
49+
and stops at tree-search exactly as before (honest degrade, unchanged).
50+
51+
**``test_rung`` (operator-only diagnostic).** Verifying a specific rung — fusion
52+
especially, only otherwise reached after three cheaper rungs fail — shouldn't
53+
require contriving a task hard enough to fail its way there. ``test_rung`` runs
54+
ONE named rung once against a feature's real acceptance tests in a throwaway
55+
worktree that's ALWAYS reaped, win or lose — never promoted, no PR. Exposed via
56+
api.py's ``test-rung`` route with no ``@tool`` wrapper, so it's operator-only,
57+
not something the board's own lead agent can reach for itself."""
5058

5159
from __future__ import annotations
5260

@@ -61,6 +69,24 @@
6169

6270
log = logging.getLogger("protoagent.plugins.project_board")
6371

72+
73+
def resolve_delegate(name: str, expect_type: str):
74+
"""Look up a live delegate by name from the delegates registry. Returns the
75+
Delegate or None (not configured / wrong type / plugin disabled). Shared by
76+
``loop.py`` (coder/reviewer/fusion resolution in the real dispatch path) and
77+
``api.py`` (the operator-only test-rung route) — one lookup, not two copies."""
78+
try:
79+
from plugins.delegates.registry import DelegateRegistry
80+
from plugins.delegates.store import merged_delegates
81+
82+
d = DelegateRegistry(merged_delegates()).get(name)
83+
except Exception: # noqa: BLE001 — delegates plugin may be disabled
84+
return None
85+
if d is None or d.type != expect_type:
86+
return None
87+
return d
88+
89+
6490
# ``### path/to/file.py`` header, then a fenced block (any/no language hint) holding
6591
# that file's COMPLETE new content. Deliberately simple/strict: a fusion completion
6692
# that doesn't follow the format parses to no files, which just fails verify() like
@@ -475,3 +501,85 @@ async def dispatch(
475501
)
476502
result_text = f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}"
477503
return canon_wt, canon_branch, result_text
504+
505+
506+
async def test_rung(
507+
*,
508+
rung: str,
509+
task: str,
510+
coder,
511+
repo: str,
512+
base: str,
513+
root: str,
514+
fid: str,
515+
dispatch_timeout: float | None,
516+
test_cmd: str,
517+
test_timeout: float,
518+
budget: int = 10,
519+
k: int = 3,
520+
tree_depth: int = 2,
521+
fusion_delegate=None,
522+
fusion_k: int = 2,
523+
files_to_modify: list[str] | None = None,
524+
_solve=None,
525+
_budget_cls=None,
526+
_verdict_cls=None,
527+
_fusion_dispatch=None,
528+
) -> dict:
529+
"""Operator-only diagnostic (ADR 0064): run exactly ONE named rung of
530+
``coder.solve()`` against a feature's REAL acceptance tests, in a throwaway
531+
worktree that is ALWAYS reaped afterward — never promoted, no PR opened, no
532+
board state touched. For verifying a rung actually works (especially fusion,
533+
only otherwise reached after three cheaper rungs fail) without contriving a
534+
task hard enough to fail its way there.
535+
536+
Deliberately separate from ``dispatch()``: that function's contract (promote
537+
the winner, raise ``SolveExhausted`` on exhaustion) is shaped for the board's
538+
real per-feature build — mixing test semantics into it would risk the real
539+
dispatch path. This is exposed to operators only via api.py's ``test-rung``
540+
route, which carries NO ``@tool`` wrapper — the board's own lead agent has no
541+
way to call this itself (see api.py's docstring for the same boundary the
542+
plugin already draws around ``/features/{id}/cancel`` etc.)."""
543+
if _solve is not None:
544+
solve, Budget, Verdict = _solve, _budget_cls, _verdict_cls
545+
else:
546+
from plugins.coder.solve import Budget, Verdict, solve
547+
548+
adapter = _WorktreeSolveAdapter(
549+
repo=repo,
550+
base=base,
551+
root=root,
552+
fid=f"{fid}.test",
553+
coder=coder,
554+
dispatch_timeout=dispatch_timeout,
555+
test_cmd=test_cmd,
556+
test_timeout=test_timeout,
557+
verdict_cls=Verdict,
558+
fusion_delegate=fusion_delegate,
559+
files_to_modify=files_to_modify,
560+
_fusion_dispatch=_fusion_dispatch,
561+
)
562+
try:
563+
result = await solve(
564+
task,
565+
generate=adapter.generate,
566+
verify=adapter.verify,
567+
budget=Budget(budget),
568+
k=k,
569+
tree_depth=tree_depth,
570+
fusion_generate=adapter.generate_fusion if fusion_delegate is not None else None,
571+
fusion_k=fusion_k,
572+
force_rung=rung,
573+
)
574+
finally:
575+
# ALWAYS reap — pass or fail, this is a diagnostic run, never a real build.
576+
for wt, branch in adapter.candidates:
577+
await worktree.remove_worktree(repo, wt, branch)
578+
return {
579+
"rung": result.rung,
580+
"passed": result.passed,
581+
"gens_spent": result.gens_spent,
582+
"candidates_tried": result.candidates_tried,
583+
"note": result.note,
584+
"verdict_output": result.verdict.output if result.verdict else "",
585+
}

loop.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -891,17 +891,10 @@ def _use_coder_solve(self, feature: dict) -> bool:
891891

892892
def _resolve_delegate(self, name: str, expect_type: str):
893893
"""Look up a live delegate by name from the delegates registry. Returns the
894-
Delegate or None (not configured / wrong type / plugin disabled)."""
895-
try:
896-
from plugins.delegates.registry import DelegateRegistry
897-
from plugins.delegates.store import merged_delegates
898-
899-
d = DelegateRegistry(merged_delegates()).get(name)
900-
except Exception: # noqa: BLE001 — delegates plugin may be disabled
901-
return None
902-
if d is None or d.type != expect_type:
903-
return None
904-
return d
894+
Delegate or None (not configured / wrong type / plugin disabled). Thin
895+
wrapper — the real lookup is shared with api.py's test-rung route via
896+
``coder_seam.resolve_delegate``."""
897+
return coder_seam.resolve_delegate(name, expect_type)
905898

906899
async def _run_fixups(self, wt: str) -> None:
907900
"""Run the repo's auto-fix command (``format_cmd``, e.g.

protoagent.plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: project_board
22
name: Project Board (coding orchestration)
3-
version: 0.28.0
3+
version: 0.29.0
44
description: >-
55
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
66
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "project-board"
3-
version = "0.28.0"
3+
version = "0.29.0"
44
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
55
requires-python = ">=3.11"
66

0 commit comments

Comments
 (0)