Skip to content

Commit e35c911

Browse files
mabry1985claude
andauthored
fix(store): pin the board to its repo's beads workspace — no walk-up escape (v0.20.1) (#52)
When `project_board.repo` points at a git repo with NO `.beads/` workspace, `br`'s auto-discovery walks UP the tree and silently adopts an ancestor's `.beads/` — so the board reads/writes the wrong (often shared parent) db, with the wrong id prefix, and its state never lives with the intended repo. Especially bad for the federated multi-team model (ADR 0055), where each team-agent is meant to own an isolated board. Add a lazy `_ensure_workspace()` (run once before the first `br` op): • an explicit `db_path` is the hard pin — nothing to do (and never walk up) • a repo that already has `.beads/` resolves locally — nothing to do • a repo with NO `.beads/` → `br init` it (matches the operator's manual workaround), so cwd-discovery resolves to the repo's own workspace instead of escaping upward • if `br init` can't create one → a clear, actionable BoardError ("run `br init` there, or set project_board.db_path") rather than a silent cross-repo bleed Reproduced the escape + verified the fix against real `br` 0.1.23; unit-tested the four branches with subprocess/fs stubbed (the suite needs no real `br`). Closes #48 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 78dd52e commit e35c911

4 files changed

Lines changed: 103 additions & 2 deletions

File tree

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.0
3+
version: 0.20.1
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.0"
3+
version = "0.20.1"
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,46 @@ def __init__(self, db: str | None = None, actor: str = "agent", repo: str = ".",
7575
self.actor = actor
7676
self.repo = repo
7777
self.base_branch = base_branch
78+
self._workspace_ready = False # lazily pinned on first _run (see _ensure_workspace)
79+
80+
# ── workspace pin (ADR 0055 P0, #48) ──────────────────────────────────────
81+
def _ensure_workspace(self) -> None:
82+
"""Pin the board to THIS repo's beads workspace so `br` can't walk UP the tree
83+
and silently adopt a parent/ancestor `.beads/` (the cross-repo bleed of #48).
84+
85+
`br` discovers `.beads/` by walking UP from cwd. With `cwd=self.repo` it stops at
86+
the repo's own `.beads/` when one exists — but a repo with NONE escapes to whatever
87+
ancestor happens to have one, polluting a shared db with the wrong id prefix. So:
88+
an explicit `db` is the hard pin (nothing to do); otherwise, if the repo has no
89+
`.beads/`, run `br init` there to give it its own — after which cwd-discovery
90+
resolves locally and never walks up (matches the operator's manual workaround).
91+
Lazy + idempotent (runs once, guarded by `_workspace_ready`)."""
92+
if self._workspace_ready or self.db:
93+
self._workspace_ready = True
94+
return
95+
repo = self.repo or "."
96+
if not os.path.isdir(os.path.join(repo, ".beads")):
97+
log.warning(
98+
"[project_board] repo %r has no .beads/ workspace — running `br init` to pin the "
99+
"board here (else `br` walks up and adopts a parent db, polluting it with the wrong "
100+
"id prefix; ADR 0055 isolation)",
101+
repo,
102+
)
103+
# NB: a direct subprocess, NOT self._run — that would recurse here, and we want
104+
# a precise error rather than the generic `br … failed` wrapper.
105+
proc = subprocess.run(
106+
[BR, "init", "--actor", self.actor], cwd=repo, capture_output=True, text=True, timeout=30
107+
)
108+
if proc.returncode != 0 and not os.path.isdir(os.path.join(repo, ".beads")):
109+
raise BoardError(
110+
f"repo {repo!r} has no beads workspace and `br init` failed "
111+
f"({proc.stderr.strip()[:200]}) — run `br init` there, or set project_board.db_path"
112+
)
113+
self._workspace_ready = True
78114

79115
# ── br invocation ─────────────────────────────────────────────────────────
80116
def _run(self, *args: str, want_json: bool = False):
117+
self._ensure_workspace() # pin to the repo's own .beads/ before any br op (#48)
81118
cmd = [BR, *args, "--actor", self.actor]
82119
if self.db:
83120
cmd += ["--db", self.db]

tests/test_store.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import types
13+
1214
import pytest
1315

1416
from project_board import store
@@ -71,6 +73,68 @@ def test_escalation_enabled_needs_two_distinct_coders(cfg, expected):
7173
assert escalation_enabled(cfg) is expected
7274

7375

76+
# ── _ensure_workspace: pin to the repo's own .beads/ (no walk-up escape, #48) ────
77+
78+
79+
def _ok():
80+
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
81+
82+
83+
def _board(monkeypatch, *, db=None, repo="/repo"):
84+
"""A BeadsBoard with the `br` PATH check stubbed (so __init__ passes) but the REAL
85+
_ensure_workspace intact — for exercising the workspace-pin logic directly."""
86+
monkeypatch.setattr(store.shutil, "which", lambda *_a, **_k: "/usr/bin/br")
87+
return BeadsBoard(db=db, repo=repo)
88+
89+
90+
def test_ensure_workspace_noop_with_explicit_db(monkeypatch):
91+
"""An explicit db_path is the hard pin — never br-init, never walk up."""
92+
calls = []
93+
monkeypatch.setattr(store.subprocess, "run", lambda *a, **k: calls.append(a) or _ok())
94+
b = _board(monkeypatch, db="/somewhere/.beads/beads.db")
95+
b._ensure_workspace()
96+
assert calls == [] and b._workspace_ready # no init shelled
97+
98+
99+
def test_ensure_workspace_noop_when_repo_has_beads(monkeypatch):
100+
"""Repo already has its own .beads/ → cwd-discovery resolves locally; no init."""
101+
monkeypatch.setattr(store.os.path, "isdir", lambda p: p.endswith(".beads"))
102+
calls = []
103+
monkeypatch.setattr(store.subprocess, "run", lambda *a, **k: calls.append(a) or _ok())
104+
_board(monkeypatch)._ensure_workspace()
105+
assert calls == []
106+
107+
108+
def test_ensure_workspace_br_inits_a_repo_with_no_beads(monkeypatch):
109+
"""Repo with no .beads/ → `br init` it ONCE, then the pin is ready and not re-run."""
110+
state = {"beads": False}
111+
monkeypatch.setattr(store.os.path, "isdir", lambda p: state["beads"] and p.endswith(".beads"))
112+
inits = []
113+
114+
def _run(cmd, **k):
115+
inits.append(cmd)
116+
state["beads"] = True # init created .beads/
117+
return _ok()
118+
119+
monkeypatch.setattr(store.subprocess, "run", _run)
120+
b = _board(monkeypatch, repo="/fresh")
121+
b._ensure_workspace()
122+
assert len(inits) == 1 and inits[0][:2] == [store.BR, "init"] and b._workspace_ready
123+
b._ensure_workspace() # idempotent — guarded by _workspace_ready, no second init
124+
assert len(inits) == 1
125+
126+
127+
def test_ensure_workspace_raises_a_clear_error_when_init_fails(monkeypatch):
128+
"""No .beads/ and `br init` fails (still none) → an actionable BoardError, NOT a
129+
silent escape to a parent db."""
130+
monkeypatch.setattr(store.os.path, "isdir", lambda p: False)
131+
monkeypatch.setattr(
132+
store.subprocess, "run", lambda *a, **k: types.SimpleNamespace(returncode=1, stdout="", stderr="denied")
133+
)
134+
with pytest.raises(BoardError, match="has no beads workspace"):
135+
_board(monkeypatch, repo="/ro")._ensure_workspace()
136+
137+
74138
def test_next_tier_walks_then_stops_at_the_top(make_board):
75139
b = make_board(Br())
76140
# Ladder: smart → reasoning → opus (fast dropped — protolabs/fast too weak).

0 commit comments

Comments
 (0)