Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,43 @@ def board_update_feature(
except BoardError as exc:
return f"Error: {exc}"

@tool
def board_get_feature(feature_id: str) -> str:
"""Read a single feature's FULL detail as JSON — `title`, `spec`,
`acceptance_criteria`, `design`, `state`, `labels`, `pr_url`, `difficulty`,
`files_to_modify`, `foundation`, `priority`, `source_issue`, plus two
dependency views: `depends_on` (EVERY blocking edge — the historical ledger,
including already-merged blockers) and `open_depends_on` (only the edges
whose blocker is still OPEN — the live, actionable "what's blocking me now"
signal). The READ half of a read-modify-write: fetch the current criteria/
spec, revise them, then `board_update_feature` — no operator round-trip. Returns
`Error: unknown feature …` for an id that isn't on the board."""
try:
f = get_store(**store_kw).get_feature(feature_id)
except BoardError as exc:
return f"Error: {exc}"
if f is None:
return f"Error: unknown feature {feature_id!r}"
return json.dumps(
{
"id": f["id"],
"title": f["title"],
"spec": f["spec"],
"acceptance_criteria": f["acceptance_criteria"],
"design": f["design"],
"state": f["board_state"],
"labels": f.get("labels", []),
"pr_url": f["pr_url"],
"difficulty": f["difficulty"],
"files_to_modify": f.get("files_to_modify", []),
"foundation": f.get("foundation", False),
"priority": f.get("priority", 2),
"source_issue": f.get("source_issue", ""),
"depends_on": f.get("depends_on", []),
"open_depends_on": f.get("open_depends_on", []),
}
)

@tool
def board_mark_ready(feature_id: str) -> str:
"""Promote a feature backlog → ready. Fails if it lacks a spec +
Expand Down Expand Up @@ -347,4 +384,12 @@ def board_retro() -> str:
indent=2,
)

return [board_create_epic, board_create_feature, board_update_feature, board_mark_ready, board_list, board_retro]
return [
board_create_epic,
board_create_feature,
board_update_feature,
board_get_feature,
board_mark_ready,
board_list,
board_retro,
]
40 changes: 40 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,46 @@ def _handle():

return _guard(_handle)

@router.post("/features/{fid}/review")
async def _review(fid: str, body: dict = Body(...)):
"""Adverse code-review bounce for the feature's open PR — the review sibling
of ``/ci`` fail. Records the ``findings`` as a DISTINCT review-bounce comment
on the bead (≠ ci-fail), feeds them into the next dispatch prompt (the same
``_ci_feedback`` lever the in-loop review gate uses), and **requeues onto the
SAME open PR** (``pr_url`` preserved via external_ref). Works from
``in_review``.

Body: ``{findings: str, escalate: bool=false}``. ``escalate=true`` climbs the
model ladder (like ``/ci``); the default keeps the same tier. With escalation
enabled and the ladder already at the top, an escalated bounce → Blocked
(never a silent re-loop)."""
findings = str(body.get("findings", ""))
escalate = bool(body.get("escalate", False))

def _handle():
s = store()
# Distinct review-bounce comment on the bead (enforces in_review), then hand
# the findings to the loop so its next dispatch prompt LEADS with them — the
# external sibling of the in-loop review gate's _ci_feedback write.
s.record_review_bounce(fid, findings)
from .loop import queue_review_feedback

queue_review_feedback(fid, findings)
if escalate and escalate_on:
nxt = s.escalate(fid, f"review-fail: {findings}" if findings else "review-fail")
if nxt is None:
return {
"requeued": False,
"escalated": True,
"exhausted": True,
"feature": s.block_from_review(fid, f"review-fail: {findings}"),
}
return {"requeued": True, "escalated": True, "next_tier": nxt, "feature": s.requeue(fid)}
# escalate=false (or no ladder configured): requeue at the SAME tier.
return {"requeued": True, "escalated": False, "feature": s.requeue(fid)}

return _guard(_handle)

# ── the ONE Done edge: merge webhook ──────────────────────────────────────
@router.post("/webhook/pr")
async def _webhook_pr(request: Request):
Expand Down
31 changes: 31 additions & 0 deletions loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@
log = logging.getLogger("protoagent.plugins.project_board")


# ── external re-dispatch feedback bridge (the /review route → the loop) ──────────
# An adverse-review bounce POSTed to /features/{fid}/review is handled in the API
# router — a DIFFERENT object from the running loop (register() mounts both, so they
# share a process but not an instance). This module-level dict is the seam between
# them: the router stashes the findings here and the loop drains them into its
# per-run ``_ci_feedback`` the next time it builds a dispatch prompt — the same
# lever the in-loop review gate writes directly. Keyed by feature id; last write wins.
_PENDING_FEEDBACK: dict[str, str] = {}


def queue_review_feedback(fid: str, findings: str) -> None:
"""Stash an adverse-review bounce's ``findings`` so the loop leads ``fid``'s next
dispatch prompt with them — the cross-instance sibling of the in-loop review
gate's ``_ci_feedback`` write (``POST /features/{fid}/review`` calls this). Blank
findings are a no-op (nothing to carry back)."""
text = str(findings or "").strip()
if not text:
return
_PENDING_FEEDBACK[fid] = (
"An adverse code review REQUESTED CHANGES on your PR. Fix every finding "
"below in the existing branch (the PR updates on push) — do not rewrite "
"unrelated code.\n\n" + text
)


# ── auto gate resolution ────────────────────────────────────────────────────────
# The pre-PR gate is repo-specific, and hard-coding one repo's check steps into the
# orchestrator (or the operator's dispatch) rots two ways: the repo's CI changes and
Expand Down Expand Up @@ -1927,6 +1952,12 @@ def _build_prompt(self, feature: dict, lessons: str = "") -> str:
# checks itself — edit-only). Also widen scope: the fix may touch tests/files
# the original `files_to_modify` didn't list (the #1053 lesson).
fid = feature.get("id", "")
# Drain any externally-queued review-bounce feedback (the /review route stashed
# it via queue_review_feedback) into THIS run's _ci_feedback, so an operator/CI
# review bounce rides the exact same prompt path as an in-loop CI/review bounce.
pending = _PENDING_FEEDBACK.pop(fid, None)
if pending:
self._ci_feedback[fid] = pending
ci = self._ci_feedback.get(fid)
prior = self._ci_prior_diff.get(fid)
prior_block = (
Expand Down
28 changes: 24 additions & 4 deletions store.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,18 @@ def bounce_ci_fail(self, fid: str, reason: str = "") -> dict:
self._comment(fid, f"CI failed: {reason}")
return self.get_feature(fid)

def record_review_bounce(self, fid: str, findings: str = "") -> dict:
"""Record an adverse code-review bounce as a DISTINCT comment on the bead —
the review sibling of ``bounce_ci_fail``'s ``CI failed:`` note, kept separate
from the requeue so the review history survives on the bead even though the
same open PR is reused. Expects ``in_review`` — the state an adverse review
lands from; the caller then ``requeue``s onto the same PR (pr_url preserved)."""
f = self._require(fid)
if f["board_state"] != "in_review":
raise BoardError(f"review bounce expects in_review, got {f['board_state']!r}")
self._comment(fid, f"review requested changes: {findings}" if findings else "review requested changes")
return f

def requeue(self, fid: str) -> dict:
"""Put a feature back to `ready` for re-dispatch (keeps its open PR via
external_ref). The puller re-claims it and the loop re-dispatches — at the
Expand Down Expand Up @@ -1133,10 +1145,16 @@ def _project(self, bead: dict) -> dict:
# the puller won't claim it. Only `br show` carries dependencies (`br list`
# doesn't); list_features patches this by cross-referencing the puller.
state = self.board_state(bead)
dag_blocked = state == "ready" and any(
d.get("dependency_type") == "blocks" and d.get("status") != "closed"
for d in (bead.get("dependencies") or [])
)
blocks_edges = [d for d in (bead.get("dependencies") or []) if d.get("dependency_type") == "blocks"]
dag_blocked = state == "ready" and any(d.get("status") != "closed" for d in blocks_edges)
# The `blocks` dependency ledger vs. its live subset. `br show` carries
# dependencies (`br list` omits them, so BOTH are [] in a list projection —
# read a single feature via get_feature for the real edges). `depends_on` is
# EVERY blocking edge — the historical ledger, including already-merged
# (closed) blockers; `open_depends_on` keeps only the edges whose blocker is
# still open — the live "what is actually blocking me right now" signal.
depends_on = [d["id"] for d in blocks_edges if d.get("id")]
open_depends_on = [d["id"] for d in blocks_edges if d.get("id") and d.get("status") != "closed"]
return {
"id": bead.get("id"),
"title": bead.get("title", ""),
Expand All @@ -1156,6 +1174,8 @@ def _project(self, bead: dict) -> dict:
"cancelled": LABEL_CANCELLED in labels,
"foundation": LABEL_FOUNDATION in labels,
"difficulty": diff,
"depends_on": depends_on,
"open_depends_on": open_depends_on,
"attempts": attempts,
"gens_spent": gens_spent,
"verified_sha": verified_sha,
Expand Down
86 changes: 86 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ def delete_feature(self, fid, reason=""):
def bounce_ci_fail(self, fid, reason):
return self._rec("bounce_ci_fail", fid, reason)

def record_review_bounce(self, fid, findings=""):
return self._rec("record_review_bounce", fid, findings)

def escalate(self, fid, reason):
self.calls.append(("escalate", (fid, reason), {}))
return self._escalate_to
Expand Down Expand Up @@ -390,6 +393,89 @@ def test_ci_fail_with_a_single_coder_bounces_to_in_progress(monkeypatch):
assert any(call[0] == "bounce_ci_fail" for call in store.calls)


# ── /features/{fid}/review — the adverse-review bounce (bd-171) ─────────────────
# The review sibling of /ci fail: record a distinct review-bounce comment, feed the
# findings into the loop's re-dispatch prompt (the _ci_feedback bridge), and requeue
# onto the SAME open PR. escalate=false keeps the tier; escalate=true climbs.


def test_review_bounce_requeues_and_records_a_distinct_comment(monkeypatch):
from project_board import loop as loop_mod

loop_mod._PENDING_FEEDBACK.clear()
store = FakeStore()
c = _client(monkeypatch, store, cfg={}) # no ladder → default keeps the same tier
r = c.post(
"/plugins/project_board/features/bd-1/review",
json={"findings": "auth check missing a null guard"},
)
assert r.status_code == 200
body = r.json()
assert body["requeued"] is True and body["escalated"] is False
names = [call[0] for call in store.calls]
assert "record_review_bounce" in names # a distinct review-bounce comment on the bead
assert "requeue" in names # same open PR reused (store.requeue keeps external_ref)
assert "escalate" not in names # default keeps the same tier


def test_review_findings_reach_the_loop_feedback_bridge(monkeypatch):
"""AC: the findings text crosses into the loop's re-dispatch path (the same
_ci_feedback lever the in-loop review gate writes), via queue_review_feedback."""
from project_board import loop as loop_mod

loop_mod._PENDING_FEEDBACK.clear()
c = _client(monkeypatch, FakeStore())
c.post("/plugins/project_board/features/bd-1/review", json={"findings": "missing a null guard"})
assert "missing a null guard" in loop_mod._PENDING_FEEDBACK.get("bd-1", "")


def test_review_escalate_true_climbs_the_ladder(monkeypatch):
store = FakeStore(escalate_to="smart")
c = _client(monkeypatch, store, cfg=ESCALATION_CFG)
r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": True})
body = r.json()
assert body["escalated"] is True and body["next_tier"] == "smart" and body["requeued"] is True
assert any(call[0] == "escalate" for call in store.calls)
assert any(call[0] == "requeue" for call in store.calls)


def test_review_escalate_false_keeps_the_same_tier(monkeypatch):
store = FakeStore(escalate_to="smart")
c = _client(monkeypatch, store, cfg=ESCALATION_CFG) # a ladder exists…
r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": False})
body = r.json()
assert body["escalated"] is False and body["requeued"] is True
assert not any(call[0] == "escalate" for call in store.calls) # …but the default doesn't climb it
assert any(call[0] == "requeue" for call in store.calls)


def test_review_escalate_exhausted_blocks(monkeypatch):
store = FakeStore(escalate_to=None) # ladder already at the top
c = _client(monkeypatch, store, cfg=ESCALATION_CFG)
r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": True})
body = r.json()
assert body["exhausted"] is True and body["requeued"] is False
assert any(call[0] == "block_from_review" for call in store.calls)


def test_review_is_public_not_operator_gated(monkeypatch):
c = _client(monkeypatch, FakeStore())
# served on the public prefix (a review-infra edge, like /ci + /webhook)…
assert c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x"}).status_code == 200
# …and NOT on the gated /api prefix.
assert c.post("/api/plugins/project_board/features/bd-1/review", json={"findings": "x"}).status_code == 404


def test_review_from_a_non_in_review_state_surfaces_as_400(monkeypatch):
class WrongState(FakeStore):
def record_review_bounce(self, fid, findings=""):
raise BoardError("review bounce expects in_review, got 'in_progress'")

c = _client(monkeypatch, WrongState())
r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x"})
assert r.status_code == 400 and "in_review" in r.json()["detail"]


# ── /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
Expand Down
24 changes: 24 additions & 0 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,30 @@ def test_build_prompt_asks_for_a_clean_pr_summary_not_raw_reasoning():
assert "do not narrate your process" in prompt.lower()


def test_queue_review_feedback_reaches_the_next_prompt():
"""AC (bd-171): a /review bounce stashed via queue_review_feedback rides the SAME
prompt path as an in-loop CI/review bounce — _build_prompt drains it into
_ci_feedback, leads the prompt with it, and clears the one-shot pending entry."""
from project_board.loop import _PENDING_FEEDBACK, queue_review_feedback

_PENDING_FEEDBACK.clear()
loop = BoardLoop({})
queue_review_feedback("bd-1", "the auth check is missing a null guard") # FEATURE id is bd-1
prompt = loop._build_prompt(FEATURE)
assert "REJECTED" in prompt # the previous-attempt-rejected block fires
assert "null guard" in prompt # the findings text reached the dispatch prompt
assert "bd-1" not in _PENDING_FEEDBACK # drained one-shot
assert loop._ci_feedback.get("bd-1") # promoted into the per-run feedback lever


def test_queue_review_feedback_ignores_blank_findings():
from project_board.loop import _PENDING_FEEDBACK, queue_review_feedback

_PENDING_FEEDBACK.clear()
queue_review_feedback("bd-9", " ")
assert "bd-9" not in _PENDING_FEEDBACK # nothing to carry back


def test_is_test_path_classification():
"""The deterministic gate's path classifier — what counts as a test vs code."""
from project_board.loop import _is_code_path, _is_test_path
Expand Down
Loading
Loading