Skip to content

Commit 416c88b

Browse files
mabry1985GitHub CIclaude
authored
feat(loop): execution-grounded Max-Mode candidate selection (ADR 0064) (#59)
Max-Mode picked the winning candidate with an LLM judge of the diffs — the exact judge-of-code ceiling protoAgent ADR 0064 targets (a judge rewards plausible-looking code; only running it discriminates). New `_select_candidate`: when a pre-PR gate (`local_gate_cmd`) is configured, prefer candidates whose gate actually PASSES — run the candidates, don't just judge them. The existing `_judge_candidates` then only breaks ties among the PASSING set (quality among the correct), or decides when no gate is configured / none pass. With no gate, behavior is unchanged (straight to the judge). This is the board-side (P2) realization of protoAgent ADR 0064's coder: the board's own worktree + `local_gate_cmd` are the verifier substrate, so no cross-plugin import / substrate mismatch (board candidates are worktree diffs, not module strings). - `_select_candidate` + `_candidate_diff_indices`; `_dispatch_max_mode` calls it. - 5 tests (prefer passing gate; judge only among passing; fall back when none pass; no-gate uses judge + never runs the gate; None when no diff). 201 pass. - Version 0.24.0 → 0.25.0 (lockstep). Implements protoAgent#1440 P2. Co-authored-by: GitHub CI <ci@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c4a2133 commit 416c88b

5 files changed

Lines changed: 157 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ project_board:
9595
max_concurrent: 1 # >1 builds features in parallel (each its own worktree)
9696
merge_poll: true # poll merged PRs as a fallback to the webhook Done edge
9797
goal_verify: false # flip true: verify the coder's diff vs acceptance_criteria before opening a PR
98-
max_mode_n: 1 # >1 = best-of-N "Max-Mode": N coders per feature, a judge keeps the best diff (#21, WIP)
98+
max_mode_n: 1 # >1 = best-of-N "Max-Mode": N coders per feature, keep the best diff
99+
# With local_gate_cmd set, Max-Mode is EXECUTION-GROUNDED (ADR 0064): the winner is
100+
# picked from candidates whose gate actually PASSES; the LLM judge only breaks ties
101+
# among the passing set (or decides when no gate is set / none pass).
99102
# webhook_secret: "..." # set before exposing /webhook/pr publicly
100103
```
101104

loop.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,59 @@ async def _judge_candidates(self, feature: dict, base: str, worktrees: list[str]
951951
return idx
952952
return nonempty[0] # judge unclear → first non-empty candidate
953953

954+
async def _candidate_diff_indices(self, base: str, worktrees: list[str]) -> list[int]:
955+
"""Indices of candidates that produced a non-empty staged diff vs ``origin/<base>``.
956+
Cheap name-only check; best-effort (a candidate we can't stage/diff is skipped)."""
957+
out: list[int] = []
958+
for i, wt in enumerate(worktrees):
959+
try:
960+
await worktree.stage_all(wt)
961+
_rc, names, _err = await worktree._git(wt, "diff", "--cached", "--name-only", f"origin/{base}")
962+
except Exception: # noqa: BLE001 — best-effort, like _judge_candidates
963+
names = ""
964+
if (names or "").strip():
965+
out.append(i)
966+
return out
967+
968+
async def _select_candidate(self, feature: dict, base: str, worktrees: list[str]) -> int | None:
969+
"""Pick the winning Max-Mode candidate — EXECUTION-GROUNDED (ADR 0064).
970+
971+
When a pre-PR gate (``local_gate_cmd``) is configured, PREFER candidates whose
972+
gate actually PASSES: run the candidates, don't just judge their diffs. An LLM
973+
judge of code rewards plausible-looking diffs and can't catch subtle wrongness —
974+
only running the tests discriminates. The judge (``_judge_candidates``) then only
975+
breaks ties among the PASSING set (quality among the correct), or decides when no
976+
gate is configured / none pass. With no gate this is exactly the old behavior.
977+
978+
Returns the winning index, or ``None`` when no candidate produced a diff."""
979+
# No oracle → judge exactly as before (it does its own emptiness handling and
980+
# returns None when every candidate is empty). Avoids a redundant diff pass.
981+
if not self.local_gate_cmd:
982+
return await self._judge_candidates(feature, base, worktrees)
983+
984+
nonempty = await self._candidate_diff_indices(base, worktrees)
985+
if not nonempty:
986+
return None
987+
if len(nonempty) == 1:
988+
return nonempty[0]
989+
990+
fid = feature.get("id")
991+
gates = await asyncio.gather(*(self._run_local_gate(worktrees[i]) for i in nonempty))
992+
passing = [i for i, gap in zip(nonempty, gates) if gap is None]
993+
if not passing:
994+
log.info(
995+
"[project_board] %s execution-select: 0/%d candidates pass the gate — judging diffs", fid, len(nonempty)
996+
)
997+
return await self._judge_candidates(feature, base, worktrees)
998+
log.info(
999+
"[project_board] %s execution-select: %d/%d candidates pass the gate", fid, len(passing), len(nonempty)
1000+
)
1001+
if len(passing) == 1:
1002+
return passing[0]
1003+
# Tie-break among the PASSING (correct) candidates by quality, via the judge.
1004+
j = await self._judge_candidates(feature, base, [worktrees[i] for i in passing])
1005+
return passing[j] if j is not None else passing[0]
1006+
9541007
async def _dispatch_max_mode(
9551008
self, feature: dict, coder, prompt: str, repo: str, base: str, fid: str
9561009
) -> tuple[str, str, str]:
@@ -960,9 +1013,10 @@ async def _dispatch_max_mode(
9601013
``feat-<id>.c<k>`` so none collides with the canonical name), dispatches the coder
9611014
into ALL of them concurrently — each keeps its own ``coder_timeout`` watchdog +
9621015
``finally`` subprocess teardown (``dispatch_coder``), and ``return_exceptions``
963-
means one candidate timing out / erroring leaves an empty tree the judge skips
964-
rather than sinking the batch. ``_judge_candidates`` (the best-of-N judge that
965-
landed in #22) picks the winning index; the winner is PROMOTED into the canonical
1016+
means one candidate timing out / erroring leaves an empty tree the selector skips
1017+
rather than sinking the batch. ``_select_candidate`` picks the winning index —
1018+
EXECUTION-GROUNDED when a pre-PR gate is configured (prefer candidates whose tests
1019+
pass; ADR 0064), else the best-of-N LLM judge; the winner is PROMOTED into the canonical
9661020
``feat-<id>`` worktree / ``feat/<id>`` branch (so the rest of the lifecycle is
9671021
unchanged) and the losers are reaped. All-empty → ``NoChangesError``, which
9681022
``_drive`` escalates/blocks exactly like a single coder that produced nothing.
@@ -984,7 +1038,7 @@ async def _dispatch_max_mode(
9841038
for i, r in enumerate(results):
9851039
if isinstance(r, Exception):
9861040
log.info("[project_board] %s max-mode candidate %d failed (skipped): %s", fid, i, r)
987-
idx = await self._judge_candidates(feature, base, [wt for wt, _b in cands])
1041+
idx = await self._select_candidate(feature, base, [wt for wt, _b in cands])
9881042
if idx is None:
9891043
for cid in cand_ids:
9901044
await worktree.reap_feature_worktree(repo, self.root, cid)

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.24.0
3+
version: 0.25.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.24.0"
3+
version = "0.25.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

tests/test_loop.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,3 +1349,96 @@ async def _err(prompt, *, system=None, model_name=None):
13491349
monkeypatch.setattr("graph.sdk.complete", _err)
13501350
# both candidates non-empty → first non-empty index wins when the judge dies
13511351
assert await loop._judge_candidates(FEATURE, "main", ["/wt/a", "/wt/b"]) == 0
1352+
1353+
1354+
# ── execution-grounded candidate selection (ADR 0064) ────────────────────────────
1355+
1356+
1357+
def _git_nonempty_for(nonempty_wts):
1358+
"""A worktree._git stub: name-only diff is non-empty only for the given worktrees."""
1359+
1360+
async def _git(wt, *args, timeout=60):
1361+
if args and args[0] == "diff":
1362+
return (0, ("solution.py" if wt in nonempty_wts else ""), "")
1363+
return (0, "", "")
1364+
1365+
return _git
1366+
1367+
1368+
async def test_select_candidate_prefers_passing_gate(monkeypatch):
1369+
"""With a gate, the candidate whose gate PASSES wins even if the judge would pick another."""
1370+
loop = BoardLoop({"local_gate_cmd": "pytest", "max_mode_n": 3})
1371+
wts = ["/c0", "/c1", "/c2"]
1372+
monkeypatch.setattr(worktree, "_git", _git_nonempty_for(set(wts))) # all have a diff
1373+
1374+
async def gate(wt):
1375+
return None if wt == "/c2" else "boom" # only c2 passes
1376+
1377+
async def judge(*a, **k):
1378+
return 0 # the judge would (wrongly) pick c0 — must be overridden
1379+
1380+
monkeypatch.setattr(loop, "_run_local_gate", gate)
1381+
monkeypatch.setattr(loop, "_judge_candidates", judge)
1382+
assert await loop._select_candidate(FEATURE, "main", wts) == 2
1383+
1384+
1385+
async def test_select_candidate_judges_only_among_passing(monkeypatch):
1386+
"""Multiple candidates pass → the judge breaks the tie among the PASSING set only."""
1387+
loop = BoardLoop({"local_gate_cmd": "pytest", "max_mode_n": 3})
1388+
wts = ["/c0", "/c1", "/c2"]
1389+
monkeypatch.setattr(worktree, "_git", _git_nonempty_for(set(wts)))
1390+
1391+
async def gate(wt):
1392+
return None if wt in ("/c0", "/c2") else "boom" # c0 + c2 pass, c1 fails
1393+
1394+
async def judge(feature, base, sub):
1395+
assert sub == ["/c0", "/c2"] # judge sees only the passing candidates
1396+
return 1 # picks the 2nd of the sublist → original index 2
1397+
1398+
monkeypatch.setattr(loop, "_run_local_gate", gate)
1399+
monkeypatch.setattr(loop, "_judge_candidates", judge)
1400+
assert await loop._select_candidate(FEATURE, "main", wts) == 2
1401+
1402+
1403+
async def test_select_candidate_falls_back_to_judge_when_none_pass(monkeypatch):
1404+
loop = BoardLoop({"local_gate_cmd": "pytest", "max_mode_n": 2})
1405+
wts = ["/c0", "/c1"]
1406+
monkeypatch.setattr(worktree, "_git", _git_nonempty_for(set(wts)))
1407+
1408+
async def gate(wt):
1409+
return "boom" # none pass
1410+
1411+
async def judge(feature, base, sub):
1412+
assert sub == wts # judges over ALL candidates
1413+
return 1
1414+
1415+
monkeypatch.setattr(loop, "_run_local_gate", gate)
1416+
monkeypatch.setattr(loop, "_judge_candidates", judge)
1417+
assert await loop._select_candidate(FEATURE, "main", wts) == 1
1418+
1419+
1420+
async def test_select_candidate_no_gate_uses_judge_and_never_runs_gate(monkeypatch):
1421+
loop = BoardLoop({"max_mode_n": 2}) # no local_gate_cmd
1422+
wts = ["/c0", "/c1"]
1423+
monkeypatch.setattr(worktree, "_git", _git_nonempty_for(set(wts)))
1424+
1425+
async def gate(wt):
1426+
raise AssertionError("the gate must not run when local_gate_cmd is unset")
1427+
1428+
async def judge(*a, **k):
1429+
return 0
1430+
1431+
monkeypatch.setattr(loop, "_run_local_gate", gate)
1432+
monkeypatch.setattr(loop, "_judge_candidates", judge)
1433+
assert await loop._select_candidate(FEATURE, "main", wts) == 0
1434+
1435+
1436+
async def test_select_candidate_none_when_no_diff(monkeypatch):
1437+
loop = BoardLoop({"local_gate_cmd": "pytest", "max_mode_n": 2})
1438+
monkeypatch.setattr(worktree, "_git", _git_nonempty_for(set())) # all empty
1439+
1440+
async def gate(wt):
1441+
raise AssertionError("no diffs → nothing to gate")
1442+
1443+
monkeypatch.setattr(loop, "_run_local_gate", gate)
1444+
assert await loop._select_candidate(FEATURE, "main", ["/c0", "/c1"]) is None

0 commit comments

Comments
 (0)