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
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.29.2
version: 0.29.3
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.29.2"
version = "0.29.3"
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
requires-python = ">=3.11"

Expand Down
50 changes: 50 additions & 0 deletions tests/test_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,56 @@ async def teardown(self, scoped):
assert order == ["forget", "dispatch", "teardown"]


async def test_dispatch_coder_force_disables_managed_git(monkeypatch):
"""The board owns the git lifecycle (worktree/branch/commit/push/PR). A delegate
configured `manage_git: true` (ADR 0076) dispatched by the loop must reach the
adapter with it OFF — otherwise the adapter runs a second full git lifecycle on
top of the board's and every feature yields duplicate branches/PRs."""

@dataclasses.dataclass
class _ManagedCoder:
workdir: str = ""
manage_git: bool = True

seen = []

class _Acp:
async def forget_session(self, scoped):
return True

async def dispatch(self, scoped, prompt, *, timeout=None):
seen.append(scoped)
return "built it"

async def teardown(self, scoped):
pass

_inject_fake_delegates(monkeypatch, _Acp())
out = await worktree.dispatch_coder(_ManagedCoder(), "/wt", "do it", timeout=5)
assert out == "built it"
assert seen[0].manage_git is False # the scoped copy, not the registry delegate
assert seen[0].workdir == "/wt"


async def test_dispatch_coder_tolerates_delegates_without_manage_git(monkeypatch):
"""Hosts predating ADR 0076 have no `manage_git` field on Delegate — the
force-disable must be guarded, not a blind dataclasses.replace kwarg."""

class _Acp:
async def forget_session(self, scoped):
return True

async def dispatch(self, scoped, prompt, *, timeout=None):
return "built it"

async def teardown(self, scoped):
pass

_inject_fake_delegates(monkeypatch, _Acp())
out = await worktree.dispatch_coder(_Coder(), "/wt", "do it", timeout=5)
assert out == "built it"


# ── link_node_modules: share the main repo's deps with the worktree ────────────────


Expand Down
14 changes: 12 additions & 2 deletions worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,21 @@ async def dispatch_coder(coder, worktree: str, prompt: str, *, timeout: float |
recovery) would ``session/load``-resume a thread whose memory references a diff
the wiped tree no longer has — the coder thinks it's already done (→ no diff) or
edits against stale assumptions. Forgetting the session first keeps its memory in
step with the empty tree. (A first attempt has no session to forget → no-op.)"""
step with the empty tree. (A first attempt has no session to forget → no-op.)

The BOARD owns the git lifecycle for scoped dispatches — worktree, branch,
commit, push, PR (this module). A delegate configured with ``manage_git: true``
(ADR 0076's harness-owned lifecycle for direct ``delegate_to`` dispatches) must
NOT keep it here: the adapter would run a second branch/commit/push/PR on top of
the board's, yielding duplicate PRs. Force-disable it on the scoped copy
(guarded, so hosts predating the field still work)."""
from plugins.delegates.adapters import ADAPTERS, DelegateError

adapter = ADAPTERS["acp"]
scoped = dataclasses.replace(coder, workdir=worktree)
overrides: dict = {"workdir": worktree}
if any(f.name == "manage_git" for f in dataclasses.fields(coder)):
overrides["manage_git"] = False
scoped = dataclasses.replace(coder, **overrides)
try:
await adapter.forget_session(scoped)
except Exception: # noqa: BLE001 — best-effort; a stale session must not block the build
Expand Down
Loading