Skip to content

Commit 0e027cf

Browse files
mabry1985claude
andauthored
fix: coder.solve() PR bodies now carry the coder's own summary, not a diagnostic string (v0.29.2) (#68)
_WorktreeSolveAdapter.generate() dispatched the coder into its worktree but discarded the reply, keeping only the worktree path. Every coder dispatch is promised "your FINAL message becomes the PR description" (loop._build_prompt), but dispatch() had nothing real to report for a solve()-ladder win, so it fell back to its own diagnostic string (e.g. "[coder.solve rung=greedy gens=1] solved 1-shot") — which became the PR body verbatim. Same failure class as #62, on a different path. Worse than cosmetic: _verify_goal's NO_TEST_NEEDED escape hatch reads this same text, so a legitimate no-test-needed change dispatched through the solve() ladder would always look like a missing test, forcing an unnecessary re-dispatch/escalation cycle. Fix: generate() now captures the coder's reply per candidate; dispatch() uses the WINNING candidate's own reply as the result, falling back to the diagnostic string only when there isn't one (a fusion win, which returns file content, not a summary). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f4abd4b commit 0e027cf

4 files changed

Lines changed: 75 additions & 6 deletions

File tree

coder_seam.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,13 @@ def __init__(
338338
# step; the slow part (the coder dispatch) still runs in parallel.
339339
self._wt_lock = asyncio.Lock()
340340
self._n = 0
341+
# worktree_path -> the coder's own final reply (its clean PR summary, per
342+
# `loop._build_prompt`'s "your FINAL message becomes the PR description"
343+
# contract) — captured so `dispatch()` can use the WINNING candidate's real
344+
# summary as the PR body instead of an internal rung/gens diagnostic string.
345+
# Also what `_verify_goal`'s NO_TEST_NEEDED escape hatch reads. Fusion has no
346+
# such reply (a plain completion, not a summary) — absent for fusion wins.
347+
self._replies: dict[str, str] = {}
341348

342349
async def _new_candidate_worktree(self) -> tuple[str, str]:
343350
self._n += 1
@@ -349,7 +356,11 @@ async def _new_candidate_worktree(self) -> tuple[str, str]:
349356

350357
async def generate(self, task: str, *, feedback: str | None = None) -> str:
351358
wt, _branch = await self._new_candidate_worktree()
352-
await worktree.dispatch_coder(self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout)
359+
reply = await worktree.dispatch_coder(
360+
self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout
361+
)
362+
if (reply or "").strip():
363+
self._replies[wt] = reply
353364
return wt
354365

355366
async def generate_fusion(self, task: str, *, feedback: str | None = None) -> str:
@@ -510,7 +521,10 @@ async def dispatch(
510521
511522
Returns ``(worktree, branch, result_text)`` on a passing candidate — the SAME
512523
3-tuple shape ``_dispatch_max_mode`` returns, so the caller's downstream drive
513-
(fixups, local gate, ``open_pr``) is unchanged. Raises :class:`SolveExhausted`
524+
(fixups, local gate, ``open_pr``) is unchanged. ``result_text`` is the WINNING
525+
candidate's own reply (its clean PR summary) when the ladder reached it via an
526+
ACP rung; only a fusion win (no natural-language reply, just file content) falls
527+
back to an internal rung/gens diagnostic string. Raises :class:`SolveExhausted`
514528
(a capability failure) when the budget is spent with no passing candidate, after
515529
reaping every candidate worktree it created.
516530
@@ -628,7 +642,14 @@ async def dispatch(
628642
result.gens_spent,
629643
budget,
630644
)
631-
result_text = f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}"
645+
# The winning candidate's own reply (its clean PR summary, per the "your FINAL
646+
# message becomes the PR description" contract every coder dispatch is given) is
647+
# the real result — `loop.py` uses this verbatim as the PR body, and `_verify_goal`
648+
# reads it for the NO_TEST_NEEDED escape hatch. Only fusion (a plain completion,
649+
# no such reply) or an unexpectedly-empty one falls back to the diagnostic string.
650+
result_text = (
651+
adapter._replies.get(win_wt) or f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}"
652+
)
632653
return canon_wt, canon_branch, result_text
633654

634655

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.29.1
3+
version: 0.29.2
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.29.1"
3+
version = "0.29.2"
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_coder_seam.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,58 @@ async def _fake_solve(task, *, generate, verify, budget, k, tree_depth, fusion_g
143143
assert promoted == [("/wt/feat-bd-1.g2", "feat/bd-1.g2", "bd-1")]
144144
assert removed == ["/wt/feat-bd-1.g1"] # only the loser reaped
145145
assert (wt, branch) == ("/wt/feat-bd-1", "feat/bd-1") # canonical name
146-
assert "best-of-k" in result and "gens=2" in result
146+
# The winning candidate's OWN reply is the result — not an internal rung/gens
147+
# diagnostic string. loop.py uses this verbatim as the PR body; _verify_goal
148+
# reads it for the NO_TEST_NEEDED escape hatch. Losing candidate g1's reply
149+
# must NOT leak through — only g2 (the winner) is used.
150+
assert result == "reply from /wt/feat-bd-1.g2"
147151
assert gens == [2] # cost surfaced exactly once
148152

149153

154+
async def test_dispatch_falls_back_to_a_diagnostic_string_when_the_winner_has_no_reply(monkeypatch, tmp_path):
155+
"""A fusion win (a plain completion, not a summary) — or any candidate whose
156+
reply somehow never got captured — has nothing human-authored to report, so
157+
dispatch() falls back to the rung/gens diagnostic string rather than an empty
158+
PR body."""
159+
_, removed, promoted = _stub_worktree(monkeypatch)
160+
161+
async def _create_in_tmp(repo, base, cid, root):
162+
d = tmp_path / cid
163+
d.mkdir(parents=True, exist_ok=True)
164+
return (str(d), f"feat/{cid}")
165+
166+
monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp)
167+
168+
async def _fake_openai_dispatch(delegate, prompt, *, timeout=None):
169+
return "### x.py\n```\nhi\n```"
170+
171+
async def _fake_solve(task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2):
172+
c0 = await fusion_generate(task, feedback=None) # the REAL adapter.generate_fusion
173+
return _FakeResult(solution=c0, passed=True, rung="fusion", gens_spent=1, candidates_tried=1, note="solved")
174+
175+
_wt, _branch, result = await dispatch(
176+
task="do the thing",
177+
coder=object(),
178+
repo="/repo",
179+
base="main",
180+
root=".worktrees",
181+
fid="bd-2",
182+
dispatch_timeout=None,
183+
test_cmd="pytest -q",
184+
test_timeout=30,
185+
budget=6,
186+
k=3,
187+
tree_depth=2,
188+
fusion_delegate=object(),
189+
record_gens=lambda n: None,
190+
_solve=_fake_solve,
191+
_budget_cls=_FakeBudget,
192+
_verdict_cls=_FakeVerdict,
193+
_fusion_dispatch=_fake_openai_dispatch,
194+
)
195+
assert result == "[coder.solve rung=fusion gens=1] solved"
196+
197+
150198
async def test_dispatch_records_gens_even_on_a_single_greedy_win(monkeypatch):
151199
_stub_worktree(monkeypatch)
152200

0 commit comments

Comments
 (0)