From 1d168a8f3fc620e6f86370f6b8c2f2a1a5b72481 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 13:10:10 -0700 Subject: [PATCH] fix: board_create_feature refuses a same-title open duplicate (v0.27.0) Live-caught in dogfooding: a freshly spun-up team's own agent called board_create_feature TWICE, ~9s apart, for the same title (both its onboarding task and a dispatched task) -- no guard stopped it, so the board ended up with two beads for one piece of work. portfolio-plugin's portfolio_dispatch got this exact dedup in v0.14.6 (#25), but only guards the PM's re-dispatch -- it can't catch a board's OWN agent (onboarding, or its own reasoning about a dispatched task) calling this tool twice within one turn. Add the same guard one tier down, at the tool board_create_feature itself, so it protects EVERY caller: refuse when a same-title feature is already open (backlog/ready/in_progress/in_review/blocked); `force=true` creates a second copy anyway; a store read failure never blocks creation (better a possible dup than a stuck board). Co-Authored-By: Claude Opus 4.8 --- __init__.py | 60 ++++++++++++++- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- tests/test_board_create_feature_dedup.py | 97 ++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 tests/test_board_create_feature_dedup.py diff --git a/__init__.py b/__init__.py index 0e77f3a..f6aa346 100644 --- a/__init__.py +++ b/__init__.py @@ -79,6 +79,40 @@ def register(registry) -> None: ) +# Board states where a feature is DONE and can't be a live duplicate — a new +# creation of the same title is legitimately re-doing closed work. Everything else +# (backlog/ready/in_progress/in_review/blocked) is "open" and a same-title create +# would stack (mirrors portfolio-plugin's _TERMINAL_LANES/#25 dedup precedent — this +# is the same class of bug one layer down: an agent's OWN reasoning calling +# board_create_feature twice for one task, not just a PM re-dispatching). +_TERMINAL_STATES = {"done", "cancelled"} + + +def _norm_title(t: str) -> str: + """Normalize a feature title for duplicate comparison: trimmed, lowercased, + internal whitespace collapsed. Exact-after-normalize only — no fuzzy match (a + false positive silently drops a real task, worse than an occasional missed + near-dup).""" + return " ".join(str(t or "").strip().lower().split()) + + +def _open_duplicate(features: list, title: str) -> dict | None: + """The first OPEN board feature whose title matches ``title`` (normalized), or + None. A board's own agent (onboarding, or its own reasoning about a dispatched + task) can call board_create_feature more than once for the same piece of work + within a single turn — this catches it at the tool boundary, same as + portfolio_dispatch's dedup guards the PM's re-dispatch one tier up.""" + want = _norm_title(title) + if not want: + return None + for f in features or []: + if str(f.get("board_state", "")).lower() in _TERMINAL_STATES: + continue + if _norm_title(f.get("title", "")) == want: + return f + return None + + def _board_tools(cfg: dict): from .store import BoardError, get_store @@ -107,6 +141,7 @@ def board_create_feature( difficulty: str = "", depends_on: str = "", foundation: bool = False, + force: bool = False, ) -> str: """Create a board feature (a bead; starts in `backlog`). To pass the Ready gate a feature needs a self-sufficient `spec`, testable `acceptance_criteria`, @@ -114,11 +149,32 @@ def board_create_feature( make a coding agent produce nothing). `parent` is the epic/milestone id; `difficulty` (small|medium|large) seeds the model tier; `depends_on` is a comma-separated list of blocking feature ids; set `foundation=True` for a - feature others build on (dependents gate on its merge, never its review).""" + feature others build on (dependents gate on its merge, never its review). + + DEDUP: refuses to create when a feature with the same title is already OPEN + on this board (backlog/ready/in_progress/in_review/blocked) — calling this + twice for the same task (e.g. reconsidering mid-turn) stacks a duplicate the + loop then churns on. Pass `force=true` to create a second copy anyway. A + store read failure never blocks creation (better a possible dup than a + stuck board).""" try: + store = get_store(**store_kw) + if not force: + try: + existing = store.list_features() + except BoardError: + existing = [] # can't check → don't block creation on a read failure + dup = _open_duplicate(existing, title) + if dup is not None: + return ( + f"Skipped — a feature titled {title!r} is already open on this board " + f"({dup.get('id', '?')}, {dup.get('board_state', 'open')}). It's likely " + "the same work; re-check the board before creating again, or pass " + "force=true to create a second copy anyway." + ) deps = [d.strip() for d in depends_on.split(",") if d.strip()] files = [p.strip() for p in files_to_modify.replace("\n", ",").split(",") if p.strip()] - f = get_store(**store_kw).create_feature( + f = store.create_feature( title, spec=spec, acceptance_criteria=acceptance_criteria, diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 0b0cafb..73a5918 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.26.1 +version: 0.27.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 29d4408..74670d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.26.1" +version = "0.27.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_board_create_feature_dedup.py b/tests/test_board_create_feature_dedup.py new file mode 100644 index 0000000..07fe8db --- /dev/null +++ b/tests/test_board_create_feature_dedup.py @@ -0,0 +1,97 @@ +"""board_create_feature dedup — the same class of bug portfolio_dispatch's #25 +dedup fixed one tier up, but here: an agent's OWN reasoning (onboarding, or its +own read of a dispatched task) calling this tool twice in one turn for the same +work, live-caught in dogfooding (two board_create_feature calls, ~9s apart, same +title, no force) with no guard to stop it.""" + +from __future__ import annotations + +import json + +import project_board as pb + + +def test_norm_title_collapses_whitespace_and_case(): + assert pb._norm_title(" Fix the Thing ") == "fix the thing" + assert pb._norm_title("Fix the Thing") == pb._norm_title("fix the thing") + + +def test_open_duplicate_finds_same_title_open_feature(): + features = [{"id": "bd-1", "title": "Add X", "board_state": "backlog"}] + dup = pb._open_duplicate(features, "add x") + assert dup is not None and dup["id"] == "bd-1" + + +def test_open_duplicate_ignores_terminal_states(): + features = [ + {"id": "bd-1", "title": "Add X", "board_state": "done"}, + {"id": "bd-2", "title": "Add X", "board_state": "cancelled"}, + ] + assert pb._open_duplicate(features, "Add X") is None + + +def test_open_duplicate_none_when_no_match(): + features = [{"id": "bd-1", "title": "Add X", "board_state": "backlog"}] + assert pb._open_duplicate(features, "Add Y") is None + + +class _FakeStore: + """Records create_feature calls; list_features returns whatever's been created + so far — enough to exercise board_create_feature's dedup decision without a + real BeadsBoard/br CLI (store.py's own projection is tested in test_store.py).""" + + def __init__(self): + self.created: list[dict] = [] + self._next = 1 + + def list_features(self, state=None): + return list(self.created) + + def create_feature(self, title, **kw): + fid = f"bd-{self._next}" + self._next += 1 + f = {"id": fid, "title": title, "board_state": "backlog", **kw} + self.created.append(f) + return f + + +def _get_tool(name: str, cfg: dict | None = None): + tools = {t.name: t for t in pb._board_tools(cfg or {})} + return tools[name] + + +def test_board_create_feature_refuses_a_same_title_open_dup(monkeypatch): + fake = _FakeStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + create = _get_tool("board_create_feature") + + first = json.loads(create.invoke({"title": "Rollup surfacing", "spec": "s1"})) + assert first["id"] == "bd-1" + + second = create.invoke({"title": "Rollup surfacing", "spec": "s2 — reconsidered"}) + assert "Skipped" in second + assert "bd-1" in second + assert len(fake.created) == 1 # the dup was refused, not created + + +def test_board_create_feature_force_true_creates_a_second_copy(monkeypatch): + fake = _FakeStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + create = _get_tool("board_create_feature") + + create.invoke({"title": "Rollup surfacing", "spec": "s1"}) + second = create.invoke({"title": "Rollup surfacing", "spec": "s2", "force": True}) + assert json.loads(second)["id"] == "bd-2" + assert len(fake.created) == 2 + + +def test_board_create_feature_allows_recreating_after_the_first_is_done(monkeypatch): + fake = _FakeStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + create = _get_tool("board_create_feature") + + create.invoke({"title": "Rollup surfacing", "spec": "s1"}) + fake.created[0]["board_state"] = "done" # the first shipped — this is legit new work + + second = create.invoke({"title": "Rollup surfacing", "spec": "s2"}) + assert json.loads(second)["id"] == "bd-2"