diff --git a/__init__.py b/__init__.py index 091727e..bed79fc 100644 --- a/__init__.py +++ b/__init__.py @@ -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()] @@ -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: diff --git a/loop.py b/loop.py index f176038..4f5991e 100644 --- a/loop.py +++ b/loop.py @@ -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 @@ -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"): diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 10d7312..6b75db3 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -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 diff --git a/skills/decompose-project/SKILL.md b/skills/decompose-project/SKILL.md index f4ccfec..8bfcf6e 100644 --- a/skills/decompose-project/SKILL.md +++ b/skills/decompose-project/SKILL.md @@ -65,10 +65,13 @@ to fan out, but keep the propose→attack pairing for each. call `board_create_feature(title, spec=, acceptance_criteria=, files_to_modify="", design=, parent=, priority=…, - difficulty=…, depends_on="")`. **`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="", foundation=)`. + **`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 diff --git a/store.py b/store.py index 2f5609b..3b7c66c 100644 --- a/store.py +++ b/store.py @@ -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"} @@ -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: @@ -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 (): @@ -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: @@ -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, diff --git a/tests/test_loop.py b/tests/test_loop.py index 19ee34f..a482d75 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -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=""): @@ -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() diff --git a/tests/test_store.py b/tests/test_store.py index 256f19f..63f77c9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -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() == []