Skip to content

Commit 5f45812

Browse files
mabry1985claude
andauthored
feat(store,api): cancel a feature created in error — the second terminal edge (v0.21.0) (#53)
There was no in-board way to cancel a feature created in error (bad decomposition, duplicate, scope cut): DELETE /features/{fid} → 405, and no store method. The only escape was reaching past the board into `br` directly, which bypasses the board cache and risks board ↔ JSONL desync. A cancel is a SECOND terminal edge, so it's modeled deliberately to preserve the one-Done-edge invariant rather than as a raw delete: • store.cancel_feature(fid, reason) → tag `cancelled` + clear assignee, then `br close -r "cancelled: …"` (audit-preserving — bead + reason survive; not a `br delete` tombstone) • board_state projects a closed+`cancelled` bead as a distinct `cancelled` state, NOT `done` — so the merge/CI reconcilers (which only touch in_review) and the loop-retro (which mines done/blocked) never mistake it for shipped/regressed work • POST /features/{fid}/cancel (mirrors the block/unblock transition style; reason optional) Decision (maintainer was away): chose CANCEL-with-audit (`br close -r`) over a hard delete — lower-risk, reversible-by-reopen, keeps the audit trail. Easy to swap to a hard delete later if the intent turns out to be tombstoning. Cancelled features drop out of the active Kanban (the point of a cancel); a `cancelled` list section rides with #26. Closes #47 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e35c911 commit 5f45812

6 files changed

Lines changed: 81 additions & 3 deletions

File tree

api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,12 @@ async def _block(fid: str, body: dict = Body(...)):
222222
async def _unblock(fid: str):
223223
return _guard(lambda: store().clear_blocked(fid))
224224

225+
@router.post("/features/{fid}/cancel")
226+
async def _cancel(fid: str, body: dict = Body(default={})):
227+
"""Cancel a feature created in error — the second terminal edge (#47). Closes
228+
the bead with an audit reason and tags it `cancelled` (a distinct state, not
229+
`done`), so a bad decomposition/duplicate leaves the board cleanly instead of
230+
being deleted out-of-band (which desyncs the board ↔ JSONL)."""
231+
return _guard(lambda: store().cancel_feature(fid, str((body or {}).get("reason", ""))))
232+
225233
return router

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.20.1
3+
version: 0.21.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.20.1"
3+
version = "0.21.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

store.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
LABEL_READY = "ready"
4848
LABEL_IN_REVIEW = "in-review"
4949
LABEL_BLOCKED = "blocked"
50+
# A SECOND terminal edge (#47): a feature closed because it was created in error
51+
# (bad decomposition, duplicate, scope cut) — closed like `done`, but tagged so the
52+
# projection shows a distinct `cancelled` state and reconcilers/retro never mistake it
53+
# for shipped work. Preserves the one-Done-edge invariant (only record_merge → `done`).
54+
LABEL_CANCELLED = "cancelled"
5055
# A feature others build *on*: dependents gate on its MERGE, never its review (vs a
5156
# non-foundation blocker, which can release dependents at in_review under dep_gate:
5257
# review). Inert under the default dep_gate: merge (then every blocker gates on merge).
@@ -328,6 +333,24 @@ def record_merge(self, *, pr_url: str) -> dict | None:
328333
self._run("close", f["id"], "-r", f"merged: {pr_url}")
329334
return self.get_feature(f["id"])
330335

336+
# ── the second terminal edge: cancel (not merge) ──────────────────────────
337+
def cancel_feature(self, fid: str, reason: str = "") -> dict:
338+
"""Cancel a feature created in error (bad decomposition, duplicate, scope cut).
339+
340+
Modeled DELIBERATELY as a second terminal edge so it doesn't break the
341+
one-Done-edge invariant: it tags the bead `cancelled` and closes it with an
342+
auditable reason (`br close -r`). The `cancelled` label makes the projection show
343+
a distinct `cancelled` state — NOT `done` — so the merge/CI reconcilers (which
344+
only touch `in_review`) and the loop-retro (which mines done/blocked) never
345+
mistake a cancel for shipped or regressed work. Audit-preserving (the bead + its
346+
history survive), vs a hard `br delete` tombstone. Clears the assignee so a
347+
revived id could be re-claimed. Idempotent-ish: re-cancelling a cancelled feature
348+
just re-closes it."""
349+
self._require(fid)
350+
self._run("update", fid, "--add-label", LABEL_CANCELLED, "--assignee", "")
351+
self._run("close", fid, "-r", f"cancelled: {reason}" if reason else "cancelled")
352+
return self.get_feature(fid)
353+
331354
# ── Blocked flag (not a lane) ─────────────────────────────────────────────
332355
def flag_blocked(self, fid: str, reason: str) -> dict:
333356
self._require(fid)
@@ -512,7 +535,9 @@ def board_state(bead: dict) -> str:
512535
labels = set(bead.get("labels") or [])
513536
status = bead.get("status")
514537
if status == "closed":
515-
return "done"
538+
# A closed bead is `done` UNLESS it was cancelled (the second terminal edge):
539+
# a cancel keeps it closed + auditable but distinct from shipped work (#47).
540+
return "cancelled" if LABEL_CANCELLED in labels else "done"
516541
if LABEL_BLOCKED in labels:
517542
return "blocked"
518543
if status == "in_progress":
@@ -552,6 +577,7 @@ def _project(self, bead: dict) -> dict:
552577
"pr_url": bead.get("external_ref", ""),
553578
"assignee": bead.get("assignee", ""),
554579
"blocked": LABEL_BLOCKED in labels,
580+
"cancelled": LABEL_CANCELLED in labels,
555581
"foundation": LABEL_FOUNDATION in labels,
556582
"difficulty": diff,
557583
"attempts": attempts,

tests/test_api.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ def flag_blocked(self, fid, reason):
7171
def clear_blocked(self, fid):
7272
return self._rec("clear_blocked", fid)
7373

74+
def cancel_feature(self, fid, reason=""):
75+
return self._rec("cancel_feature", fid, reason)
76+
7477
def bounce_ci_fail(self, fid, reason):
7578
return self._rec("bounce_ci_fail", fid, reason)
7679

@@ -153,6 +156,20 @@ def test_ready_gate_rejection_surfaces_as_400(monkeypatch):
153156
assert r.status_code == 400 and "Ready gate" in r.json()["detail"]
154157

155158

159+
def test_cancel_route_calls_cancel_feature_with_reason(monkeypatch):
160+
"""POST /features/{fid}/cancel — the second terminal edge (#47). Carries the
161+
optional reason through; works with no body too."""
162+
store = FakeStore()
163+
c = _client(monkeypatch, store)
164+
r = c.post("/api/plugins/project_board/features/bd-7/cancel", json={"reason": "duplicate"})
165+
assert r.status_code == 200
166+
assert ("cancel_feature", ("bd-7", "duplicate"), {}) in store.calls
167+
# No body → cancels with an empty reason (still a valid request, not a 422).
168+
r2 = c.post("/api/plugins/project_board/features/bd-8/cancel")
169+
assert r2.status_code == 200
170+
assert ("cancel_feature", ("bd-8", ""), {}) in store.calls
171+
172+
156173
# ── the single Done edge: the merge webhook ─────────────────────────────────────
157174

158175

tests/test_store.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,13 @@ def cmds(self, name):
4545
({"status": "in_progress", "labels": []}, "in_progress"),
4646
({"status": "in_progress", "labels": ["in-review"]}, "in_review"),
4747
({"status": "closed", "labels": []}, "done"),
48+
({"status": "closed", "labels": ["cancelled"]}, "cancelled"), # the second terminal edge (#47)
4849
({"status": "deferred", "labels": []}, "backlog"),
4950
({"status": "open", "labels": ["blocked"]}, "blocked"),
5051
# precedence: closed beats a stray blocked label; blocked beats in-review.
5152
({"status": "closed", "labels": ["blocked", "ready"]}, "done"),
53+
# a cancelled+closed bead is `cancelled`, not `done`, even with other labels.
54+
({"status": "closed", "labels": ["cancelled", "blocked"]}, "cancelled"),
5255
({"status": "in_progress", "labels": ["blocked", "in-review"]}, "blocked"),
5356
],
5457
)
@@ -226,6 +229,30 @@ def test_mark_ready_rejects_an_underspecced_feature(make_board, monkeypatch, mis
226229
assert br.cmds("update") == [] # nothing mutated on a rejected gate
227230

228231

232+
# ── cancel_feature: the second terminal edge (#47) ──────────────────────────────
233+
234+
235+
def test_cancel_feature_tags_cancelled_and_closes_with_reason(make_board, monkeypatch):
236+
"""Tag `cancelled` + clear the assignee, then close with an audit reason — so the
237+
projection reads `cancelled` (distinct from `done`), audit-preserved (not deleted)."""
238+
br = Br()
239+
b = make_board(br)
240+
monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "cancelled", "cancelled": True})
241+
f = b.cancel_feature("bd-9", "duplicate")
242+
update = next(c for c in br.calls if c[0] == "update")
243+
assert "--add-label" in update and "cancelled" in update and "--assignee" in update
244+
close = next(c for c in br.calls if c[0] == "close")
245+
assert close[:2] == ("close", "bd-9") and "cancelled: duplicate" in close
246+
assert f["board_state"] == "cancelled" and f["cancelled"] is True
247+
248+
249+
def test_cancel_feature_unknown_id_raises(make_board, monkeypatch):
250+
b = make_board(Br())
251+
monkeypatch.setattr(b, "get_feature", lambda fid: None)
252+
with pytest.raises(BoardError, match="unknown feature"):
253+
b.cancel_feature("nope")
254+
255+
229256
def test_mark_ready_rejects_a_feature_already_past_backlog(make_board, monkeypatch):
230257
b = make_board(Br())
231258
monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "in_progress"})

0 commit comments

Comments
 (0)