Skip to content

Commit 5dc7139

Browse files
mabry1985Josh Mabryclaude
authored
fix: board_create_feature refuses a same-title open duplicate (v0.27.0) (#63)
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: Josh Mabry <josh@protolabs.studio> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e4fa579 commit 5dc7139

4 files changed

Lines changed: 157 additions & 4 deletions

File tree

__init__.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,40 @@ def register(registry) -> None:
7979
)
8080

8181

82+
# Board states where a feature is DONE and can't be a live duplicate — a new
83+
# creation of the same title is legitimately re-doing closed work. Everything else
84+
# (backlog/ready/in_progress/in_review/blocked) is "open" and a same-title create
85+
# would stack (mirrors portfolio-plugin's _TERMINAL_LANES/#25 dedup precedent — this
86+
# is the same class of bug one layer down: an agent's OWN reasoning calling
87+
# board_create_feature twice for one task, not just a PM re-dispatching).
88+
_TERMINAL_STATES = {"done", "cancelled"}
89+
90+
91+
def _norm_title(t: str) -> str:
92+
"""Normalize a feature title for duplicate comparison: trimmed, lowercased,
93+
internal whitespace collapsed. Exact-after-normalize only — no fuzzy match (a
94+
false positive silently drops a real task, worse than an occasional missed
95+
near-dup)."""
96+
return " ".join(str(t or "").strip().lower().split())
97+
98+
99+
def _open_duplicate(features: list, title: str) -> dict | None:
100+
"""The first OPEN board feature whose title matches ``title`` (normalized), or
101+
None. A board's own agent (onboarding, or its own reasoning about a dispatched
102+
task) can call board_create_feature more than once for the same piece of work
103+
within a single turn — this catches it at the tool boundary, same as
104+
portfolio_dispatch's dedup guards the PM's re-dispatch one tier up."""
105+
want = _norm_title(title)
106+
if not want:
107+
return None
108+
for f in features or []:
109+
if str(f.get("board_state", "")).lower() in _TERMINAL_STATES:
110+
continue
111+
if _norm_title(f.get("title", "")) == want:
112+
return f
113+
return None
114+
115+
82116
def _board_tools(cfg: dict):
83117
from .store import BoardError, get_store
84118

@@ -107,18 +141,40 @@ def board_create_feature(
107141
difficulty: str = "",
108142
depends_on: str = "",
109143
foundation: bool = False,
144+
force: bool = False,
110145
) -> str:
111146
"""Create a board feature (a bead; starts in `backlog`). To pass the Ready
112147
gate a feature needs a self-sufficient `spec`, testable `acceptance_criteria`,
113148
AND `files_to_modify` (comma-separated paths to create/modify — vague tasks
114149
make a coding agent produce nothing). `parent` is the epic/milestone id;
115150
`difficulty` (small|medium|large) seeds the model tier; `depends_on` is a
116151
comma-separated list of blocking feature ids; set `foundation=True` for a
117-
feature others build on (dependents gate on its merge, never its review)."""
152+
feature others build on (dependents gate on its merge, never its review).
153+
154+
DEDUP: refuses to create when a feature with the same title is already OPEN
155+
on this board (backlog/ready/in_progress/in_review/blocked) — calling this
156+
twice for the same task (e.g. reconsidering mid-turn) stacks a duplicate the
157+
loop then churns on. Pass `force=true` to create a second copy anyway. A
158+
store read failure never blocks creation (better a possible dup than a
159+
stuck board)."""
118160
try:
161+
store = get_store(**store_kw)
162+
if not force:
163+
try:
164+
existing = store.list_features()
165+
except BoardError:
166+
existing = [] # can't check → don't block creation on a read failure
167+
dup = _open_duplicate(existing, title)
168+
if dup is not None:
169+
return (
170+
f"Skipped — a feature titled {title!r} is already open on this board "
171+
f"({dup.get('id', '?')}, {dup.get('board_state', 'open')}). It's likely "
172+
"the same work; re-check the board before creating again, or pass "
173+
"force=true to create a second copy anyway."
174+
)
119175
deps = [d.strip() for d in depends_on.split(",") if d.strip()]
120176
files = [p.strip() for p in files_to_modify.replace("\n", ",").split(",") if p.strip()]
121-
f = get_store(**store_kw).create_feature(
177+
f = store.create_feature(
122178
title,
123179
spec=spec,
124180
acceptance_criteria=acceptance_criteria,

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.26.1
3+
version: 0.27.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.26.1"
3+
version = "0.27.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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""board_create_feature dedup — the same class of bug portfolio_dispatch's #25
2+
dedup fixed one tier up, but here: an agent's OWN reasoning (onboarding, or its
3+
own read of a dispatched task) calling this tool twice in one turn for the same
4+
work, live-caught in dogfooding (two board_create_feature calls, ~9s apart, same
5+
title, no force) with no guard to stop it."""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
11+
import project_board as pb
12+
13+
14+
def test_norm_title_collapses_whitespace_and_case():
15+
assert pb._norm_title(" Fix the Thing ") == "fix the thing"
16+
assert pb._norm_title("Fix the Thing") == pb._norm_title("fix the thing")
17+
18+
19+
def test_open_duplicate_finds_same_title_open_feature():
20+
features = [{"id": "bd-1", "title": "Add X", "board_state": "backlog"}]
21+
dup = pb._open_duplicate(features, "add x")
22+
assert dup is not None and dup["id"] == "bd-1"
23+
24+
25+
def test_open_duplicate_ignores_terminal_states():
26+
features = [
27+
{"id": "bd-1", "title": "Add X", "board_state": "done"},
28+
{"id": "bd-2", "title": "Add X", "board_state": "cancelled"},
29+
]
30+
assert pb._open_duplicate(features, "Add X") is None
31+
32+
33+
def test_open_duplicate_none_when_no_match():
34+
features = [{"id": "bd-1", "title": "Add X", "board_state": "backlog"}]
35+
assert pb._open_duplicate(features, "Add Y") is None
36+
37+
38+
class _FakeStore:
39+
"""Records create_feature calls; list_features returns whatever's been created
40+
so far — enough to exercise board_create_feature's dedup decision without a
41+
real BeadsBoard/br CLI (store.py's own projection is tested in test_store.py)."""
42+
43+
def __init__(self):
44+
self.created: list[dict] = []
45+
self._next = 1
46+
47+
def list_features(self, state=None):
48+
return list(self.created)
49+
50+
def create_feature(self, title, **kw):
51+
fid = f"bd-{self._next}"
52+
self._next += 1
53+
f = {"id": fid, "title": title, "board_state": "backlog", **kw}
54+
self.created.append(f)
55+
return f
56+
57+
58+
def _get_tool(name: str, cfg: dict | None = None):
59+
tools = {t.name: t for t in pb._board_tools(cfg or {})}
60+
return tools[name]
61+
62+
63+
def test_board_create_feature_refuses_a_same_title_open_dup(monkeypatch):
64+
fake = _FakeStore()
65+
monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake)
66+
create = _get_tool("board_create_feature")
67+
68+
first = json.loads(create.invoke({"title": "Rollup surfacing", "spec": "s1"}))
69+
assert first["id"] == "bd-1"
70+
71+
second = create.invoke({"title": "Rollup surfacing", "spec": "s2 — reconsidered"})
72+
assert "Skipped" in second
73+
assert "bd-1" in second
74+
assert len(fake.created) == 1 # the dup was refused, not created
75+
76+
77+
def test_board_create_feature_force_true_creates_a_second_copy(monkeypatch):
78+
fake = _FakeStore()
79+
monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake)
80+
create = _get_tool("board_create_feature")
81+
82+
create.invoke({"title": "Rollup surfacing", "spec": "s1"})
83+
second = create.invoke({"title": "Rollup surfacing", "spec": "s2", "force": True})
84+
assert json.loads(second)["id"] == "bd-2"
85+
assert len(fake.created) == 2
86+
87+
88+
def test_board_create_feature_allows_recreating_after_the_first_is_done(monkeypatch):
89+
fake = _FakeStore()
90+
monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake)
91+
create = _get_tool("board_create_feature")
92+
93+
create.invoke({"title": "Rollup surfacing", "spec": "s1"})
94+
fake.created[0]["board_state"] = "done" # the first shipped — this is legit new work
95+
96+
second = create.invoke({"title": "Rollup surfacing", "spec": "s2"})
97+
assert json.loads(second)["id"] == "bd-2"

0 commit comments

Comments
 (0)