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
5 changes: 4 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@ def board_create_feature(
priority: int = 2,
difficulty: str = "",
depends_on: str = "",
foundation: 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."""
comma-separated list of blocking feature ids; set `foundation=True` for a
feature others build on (dependents gate on its merge, never its review)."""
try:
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()]
Expand All @@ -124,6 +126,7 @@ def board_create_feature(
priority=priority,
difficulty=difficulty,
depends_on=deps,
foundation=foundation,
)
return json.dumps({"id": f["id"], "state": f["board_state"], "title": f["title"]})
except BoardError as exc:
Expand Down
7 changes: 6 additions & 1 deletion loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ def __init__(self, cfg: dict):
# review, so the loop can't pile up PRs faster than they merge (flooding CI /
# reviewers). 0 = unlimited.
self.max_pending_reviews = int(self.cfg.get("max_pending_reviews", 5))
# Dependency gate: "merge" (default) — a dependent waits for every blocker to
# merge (done); "review" — a NON-foundation blocker releases its dependents at
# in_review (more parallelism, at the risk of building on un-merged code).
# Foundation blockers always gate on merge.
self.relaxed_gate = str(self.cfg.get("dep_gate", "merge")).lower() == "review"
# Stuck-drive watchdog: hard cap on a single coder dispatch (the only
# otherwise-unbounded await in a drive — git/gh calls already self-time-out).
# 0 disables it. A timeout reaps the coder subprocess and is a capability
Expand Down Expand Up @@ -239,7 +244,7 @@ def _spawn_ready(self) -> bool:
return False
spawned = False
busy = set().union(*self._inflight_files.values()) if self._inflight_files else set()
for candidate in store.ready_queue(): # priority order, dep-unblocked
for candidate in store.ready_queue(relaxed=self.relaxed_gate): # priority order, dep-unblocked
if len(self._drives) >= self.max_concurrent:
break
if candidate.get("board_state") != "ready" or candidate.get("blocked"):
Expand Down
6 changes: 6 additions & 0 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ config:
max_pending_reviews: 5 # back-pressure: pause claiming new work while this many
# PRs already await review (don't pile up faster than they
# merge). 0 = unlimited.
dep_gate: merge # how a blocks-dependency gates its dependents: "merge"
# (default, safe) — wait for the blocker to merge (done);
# "review" — a NON-foundation blocker releases dependents at
# in_review (more parallelism, but builds on un-merged code).
# Mark shared-structure features foundation=True; those
# always gate on merge regardless.
merge_poll: true # poll merged PRs as a fallback to the /webhook/pr Done
# edge — for deployments GitHub can't post a webhook to
# (no public URL). The Done edge stays idempotent either
Expand Down
11 changes: 7 additions & 4 deletions skills/decompose-project/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ to fan out, but keep the propose→attack pairing for each.
call `board_create_feature(title, spec=<imperative requirements summary>,
acceptance_criteria=<the EARS list>, files_to_modify="<comma-separated paths to
create/modify>", design=<design summary>, parent=<epic/milestone id>, priority=…,
difficulty=…, depends_on="<blocking feature ids>")`. **`files_to_modify` is
required by the Ready gate** — name the exact paths, and write the spec
imperatively (a vague task makes a coder produce nothing). Foundation edges are
just `depends_on` on the foundation feature.
difficulty=…, depends_on="<blocking feature ids>", foundation=<true|false>)`.
**`files_to_modify` is required by the Ready gate** — name the exact paths, and
write the spec imperatively (a vague task makes a coder produce nothing).
Foundation edges are just `depends_on` on the foundation feature; set
`foundation=True` on a shared-structure feature so dependents always gate on its
**merge** (under `dep_gate: review`, non-foundation blockers release dependents at
in_review — foundations never do).
4. **HUMAN GATE (per epic):** summarize the epic's features (titles, acceptance
criteria, deps, which are foundations) and call `request_user_input` to ask the
operator to approve, amend, or reject **before any feature goes `ready`**. This is
Expand Down
48 changes: 45 additions & 3 deletions store.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
LABEL_READY = "ready"
LABEL_IN_REVIEW = "in-review"
LABEL_BLOCKED = "blocked"
# A feature others build *on*: dependents gate on its MERGE, never its review (vs a
# non-foundation blocker, which can release dependents at in_review under dep_gate:
# review). Inert under the default dep_gate: merge (then every blocker gates on merge).
LABEL_FOUNDATION = "foundation"

# difficulty → initial model tier (the escalation ladder's first rung, D10).
DIFFICULTY_TIER = {"small": "fast", "medium": "smart", "large": "reasoning", "architectural": "reasoning"}
Expand Down Expand Up @@ -129,11 +133,13 @@ def create_feature(
priority: int = 2,
difficulty: str = "",
depends_on=(),
foundation: bool = False,
) -> dict:
"""Create a feature bead (starts in `backlog`). Provide a self-sufficient
spec + acceptance_criteria + the explicit files to create/modify so it can
pass the Ready gate (ProtoMaker's spec-quality discipline — vague tasks make
a coder produce nothing)."""
a coder produce nothing). Mark `foundation=True` for a feature others build on
(dependents gate on its merge, never its review)."""
fid = self._create(title, itype="feature", parent=parent, priority=priority, description=spec)
upd = []
if acceptance_criteria:
Expand All @@ -145,6 +151,8 @@ def create_feature(
upd += ["--notes", "\n".join(str(p).strip() for p in files_to_modify if str(p).strip())]
if difficulty:
upd += ["--add-label", f"diff:{difficulty.strip().lower()}"]
if foundation:
upd += ["--add-label", LABEL_FOUNDATION]
if upd:
self._run("update", fid, *upd)
for dep in depends_on or ():
Expand Down Expand Up @@ -363,9 +371,42 @@ def list_features(self, state: str | None = None) -> list[dict]:
out.sort(key=lambda f: (f["priority"], f["id"]))
return out

def ready_queue(self) -> list[dict]:
def ready_queue(self, relaxed: bool = False) -> list[dict]:
"""Board-`ready`, dep-unblocked **features** (priority order) — the puller's
queue. `br ready` already excludes a feature with any OPEN `blocks` dep, so by
default a dependent waits for its blockers to **close** (merge). With
``relaxed`` (``dep_gate: review``) also release a dep-blocked feature whose
every still-open blocker is a NON-foundation feature already at ``in_review``
— build on code that's in review, not merged. Foundation blockers always gate
on merge."""
ready = self._run("ready", "--label", LABEL_READY, want_json=True) or []
return [self._project(b) for b in ready if b.get("issue_type") == "feature"]
out = [self._project(b) for b in ready if b.get("issue_type") == "feature"]
if not relaxed:
return out
have = {f["id"] for f in out}
by_id = {f["id"]: f for f in self.list_features()}
for fid, f in by_id.items():
if fid in have or f["board_state"] != "ready" or f["blocked"]:
continue
blockers = [by_id.get(d) for d in self._open_blockers(fid)]
if blockers and all(
b is not None and not b["foundation"] and b["board_state"] == "in_review" for b in blockers
):
out.append(f)
return out

def _open_blockers(self, fid: str) -> list[str]:
"""The ids of `fid`'s still-open `blocks` dependencies (`br list` omits deps,
so this needs `br show`). A closed blocker has merged → it no longer gates."""
rows = self._run("show", fid, want_json=True)
if not rows:
return []
bead = rows[0] if isinstance(rows, list) else rows
return [
d["id"]
for d in (bead.get("dependencies") or [])
if d.get("dependency_type") == "blocks" and d.get("status") != "closed"
]

# ── helpers ───────────────────────────────────────────────────────────────
def _comment(self, fid: str, text: str) -> None:
Expand Down Expand Up @@ -431,6 +472,7 @@ def _project(self, bead: dict) -> dict:
"pr_url": bead.get("external_ref", ""),
"assignee": bead.get("assignee", ""),
"blocked": LABEL_BLOCKED in labels,
"foundation": LABEL_FOUNDATION in labels,
"difficulty": diff,
"attempts": attempts,
"labels": labels,
Expand Down
25 changes: 24 additions & 1 deletion tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,10 @@ def __init__(self, features, in_review=0):
self._features = [dict(f) for f in features]
self._in_review = in_review
self.claimed = []
self.last_relaxed = None

def ready_queue(self):
def ready_queue(self, relaxed=False):
self.last_relaxed = relaxed
return [f for f in self._features if f["id"] not in self.claimed]

def claim(self, fid, assignee=""):
Expand Down Expand Up @@ -511,3 +513,24 @@ async def test_sweep_off_when_interval_zero(monkeypatch):
monkeypatch.setattr(loop, "_sweep", lambda: called.append(1))
await loop._maybe_sweep()
assert called == [] # disabled → never sweeps


# ── dependency gate (merge vs review) ───────────────────────────────────────────


def test_dep_gate_config_defaults_to_merge():
assert BoardLoop({}).relaxed_gate is False
assert BoardLoop({"dep_gate": "merge"}).relaxed_gate is False
assert BoardLoop({"dep_gate": "review"}).relaxed_gate is True


async def test_spawn_ready_passes_the_dep_gate_to_ready_queue(monkeypatch):
store = _ClaimStore([_ready("bd-1", ["a.py"])])
monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store)
loop = BoardLoop({"dep_gate": "review", "max_concurrent": 1})
finish = await _hold_drives(loop, monkeypatch)
try:
loop._spawn_ready()
assert store.last_relaxed is True # the relaxed gate reaches ready_queue
finally:
await finish()
62 changes: 62 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,65 @@ def test_record_merge_does_not_reclose_a_done_feature(make_board, monkeypatch):
monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "done"})
b.record_merge(pr_url=url)
assert br.cmds("close") == [] # already done → idempotent, no second close


# ── foundation flag + the relaxed (review) dependency gate ───────────────────────


def test_project_exposes_the_foundation_flag(make_board):
b = make_board(Br())
assert b._project({"id": "x", "status": "open", "labels": ["foundation"]})["foundation"] is True
assert b._project({"id": "y", "status": "open", "labels": []})["foundation"] is False


def test_create_feature_labels_foundation(make_board):
br = Br({"create": "bd-1", "show": [{"id": "bd-1", "status": "open", "labels": ["foundation"]}]})
b = make_board(br)
f = b.create_feature("t", spec="s", acceptance_criteria="a", files_to_modify=["x.py"], foundation=True)
assert f["foundation"] is True
assert any(c[0] == "update" and "foundation" in c for c in br.calls)


def test_open_blockers_keeps_open_blocks_drops_closed_and_nonblocks(make_board):
bead = {
"id": "bd-1",
"dependencies": [
{"id": "a", "dependency_type": "blocks", "status": "in_progress"},
{"id": "b", "dependency_type": "blocks", "status": "closed"}, # merged → no longer gates
{"id": "c", "dependency_type": "parent-child", "status": "open"}, # not a blocks edge
],
}
b = make_board(Br({"show": [bead]}))
assert b._open_blockers("bd-1") == ["a"]


def test_ready_queue_relaxed_releases_only_nonfoundation_in_review_blockers(make_board):
# Three dependents, each blocked by a different kind of blocker.
all_features = [
{"id": "bd-f", "issue_type": "feature", "status": "in_progress", "labels": ["in-review"]},
{"id": "bd-found", "issue_type": "feature", "status": "in_progress", "labels": ["in-review", "foundation"]},
{"id": "bd-ip", "issue_type": "feature", "status": "in_progress", "labels": []},
{"id": "bd-dep1", "issue_type": "feature", "status": "open", "labels": ["ready"]},
{"id": "bd-dep2", "issue_type": "feature", "status": "open", "labels": ["ready"]},
{"id": "bd-dep3", "issue_type": "feature", "status": "open", "labels": ["ready"]},
]
show = {
"bd-dep1": [
{"id": "bd-dep1", "dependencies": [{"id": "bd-f", "dependency_type": "blocks", "status": "in_progress"}]}
],
"bd-dep2": [
{
"id": "bd-dep2",
"dependencies": [{"id": "bd-found", "dependency_type": "blocks", "status": "in_progress"}],
}
],
"bd-dep3": [
{"id": "bd-dep3", "dependencies": [{"id": "bd-ip", "dependency_type": "blocks", "status": "in_progress"}]}
],
}
b = make_board(Br({"ready": [], "list": all_features, "show": lambda args: show.get(args[1], [])}))
# relaxed: only bd-dep1 releases (blocker non-foundation AND in_review).
assert {f["id"] for f in b.ready_queue(relaxed=True)} == {"bd-dep1"}
# bd-dep2 (foundation blocker) and bd-dep3 (blocker only in_progress) stay gated.
# The default gate adds nothing beyond `br ready` (empty here).
assert b.ready_queue() == []
Loading