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
60 changes: 58 additions & 2 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -107,18 +141,40 @@ 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`,
AND `files_to_modify` (comma-separated paths to create/modify — vague tasks
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,
Expand Down
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
97 changes: 97 additions & 0 deletions tests/test_board_create_feature_dedup.py
Original file line number Diff line number Diff line change
@@ -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"
Loading