From 13c8f43381c8f985d4a7e586031e619d513f5c6d Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Mon, 6 Jul 2026 19:27:13 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(loop):=20fail-closed=20gate=20prefligh?= =?UTF-8?q?t=20=E2=80=94=20never=20start=20work=20a=20broken=20gate=20can'?= =?UTF-8?q?t=20accept=20(v0.33.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board loop's per-PR local gate (_run_local_gate) fails OPEN: a gate that can't run degrades to 'pass' so a flaky/misconfigured gate never blocks good work (CI is the real backstop). That's correct for the per-PR edge, but it means a coder will happily generate against a gate that CANNOT EXECUTE — e.g. the build tool isn't installed — burning generations on work no gate could ever accept. Add the fail-CLOSED complement: before dispatching ANY work, smoke-run local_gate_cmd on the CLEAN base checkout (the main repo — coders only touch worktrees, so it stays at base). - gate exits 0 → runnable; dispatch normally (cached for the run) - gate exits non-zero → base doesn't pass; HOLD all ready work - gate can't launch → broken env (missing tool); HOLD all ready work - gate times out → indeterminate; ALLOW (never wedge on a slow gate) Held features are flag_blocked with the reason so the stall is visible on the board, not silent; the preflight re-checks each cycle (throttled) and releases the holds the moment the gate goes green. Default on when a gate is configured (a healthy repo passes instantly — no behavior change); opt out with preflight:false. Surfaced dogfooding the roxy→protoContent delivery loop: a coder burned 5 gens producing test files against a 'pnpm -r build' gate whose pnpm wasn't installed. This makes that a one-line board hold with a clear reason instead. Co-Authored-By: Claude Opus 4.8 --- loop.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_loop.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) diff --git a/loop.py b/loop.py index 3adf093..6d1cb6c 100644 --- a/loop.py +++ b/loop.py @@ -230,6 +230,22 @@ def __init__(self, cfg: dict): self.local_gate_max = max(0, int(self.cfg.get("local_gate_max", 2))) self.local_gate_timeout = float(self.cfg.get("local_gate_timeout_s", 600)) self.local_gate_output_chars = max(500, int(self.cfg.get("local_gate_output_chars", 4000))) + # Gate PREFLIGHT (fail-CLOSED; default on when a gate is configured). Before + # dispatching ANY work, smoke-run ``local_gate_cmd`` on the CLEAN base checkout. + # If the gate can't launch (missing tool) or fails on the untouched base, the + # coder environment is broken — HOLD all ready work (flag it blocked, with the + # reason, so the stall is visible on the board) rather than burn generations on a + # gate no coder could pass, and re-check each cycle so work resumes the moment + # it's fixed. This is the fail-CLOSED complement to ``_run_local_gate``'s per-PR + # fail-OPEN: a flaky gate must never block good work, but an UNRUNNABLE gate must + # never start bad work. A healthy repo passes instantly — nothing changes. A + # preflight timeout is treated as indeterminate → allow (never wedge on a slow + # gate). Opt out with ``preflight: false``. + self.preflight = bool(self.cfg.get("preflight", True)) + self.preflight_timeout = float(self.cfg.get("preflight_timeout_s", self.local_gate_timeout)) + self._preflight_state: bool | str | None = None # None=unchecked, True=runnable, str=reason + self._last_preflight = 0.0 + self._preflight_held: set[str] = set() # ── coder.solve() board seam (ADR 0064 P2, opt-in) ───────────────────────── # Route a FRESH build (not a keep-worktree/CI-bounce re-dispatch) through the # `coder` plugin's execution-grounded solve() ladder (greedy → best-of-k → @@ -455,6 +471,7 @@ async def _run(self): try: await self._maybe_reconcile() await self._maybe_sweep() + await self._maybe_preflight() # fail-closed: hold work if the gate can't run spawned = self._spawn_ready() except Exception: # noqa: BLE001 — a bad tick must never kill the loop log.exception("[project_board] loop tick failed") @@ -477,6 +494,11 @@ def _spawn_ready(self) -> bool: least one drive (so the runner stays hot).""" if len(self._drives) >= self.max_concurrent: return False + # Fail-closed gate preflight: if the gate can't run on clean base, HOLD all work + # (surfaced on the board) rather than dispatch coders that can never pass it. + if isinstance(self._preflight_state, str): + self._hold_ready_for_preflight() + return False store = self._store() # Review-queue WIP limit — don't claim new work while the review queue is full. if self.max_pending_reviews and len(store.list_features(state="in_review")) >= self.max_pending_reviews: @@ -1234,6 +1256,99 @@ async def _run_local_gate(self, wt: str) -> str | None: log.info("[project_board] pre-PR gate failed to run (treating as pass — CI still gates): %s", exc) return None + # ── gate preflight (fail-closed: never start work a broken gate can't accept) ── + async def _maybe_preflight(self) -> None: + """Re-run the gate preflight while it hasn't passed, throttled. Once it passes it + stays passed for the run (a healthy env doesn't spontaneously lose its toolchain; + a per-PR gate failure is handled in the drive, not here).""" + if not self.preflight or not self.local_gate_cmd: + self._preflight_state = True + return + if self._preflight_state is True: + return + now = time.monotonic() + # First check runs immediately (state is None); re-checks of a KNOWN-failed + # preflight are throttled so a slow gate isn't hammered every tick. + if self._preflight_state is not None and (now - self._last_preflight) < max(self.interval, 60.0): + return + self._last_preflight = now + await self._preflight() + + async def _preflight(self) -> None: + """Smoke-run ``local_gate_cmd`` on the CLEAN base checkout (the main repo — coders + only ever touch worktrees, so it stays at base). Sets ``self._preflight_state``: + ``True`` when the gate exits 0 (runnable), a reason string on a CLEAN non-zero exit + or a launch failure (broken environment → hold work). A TIMEOUT is indeterminate → + allow (a slow gate must not wedge the board). Releases any holds on recovery.""" + repo = self._store_kw["repo"] + log.info("[project_board] preflight: smoking the gate on clean base — %s", self.local_gate_cmd) + try: + proc = await asyncio.create_subprocess_shell( + self.local_gate_cmd, + cwd=repo, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=self.preflight_timeout) + except asyncio.TimeoutError: + try: + proc.kill() + except ProcessLookupError: + pass + log.warning( + "[project_board] preflight timed out (%ss) — indeterminate, allowing dispatch", + self.preflight_timeout, + ) + self._preflight_state = True + return + if proc.returncode == 0: + if isinstance(self._preflight_state, str): + log.info("[project_board] preflight RECOVERED — gate runnable again, releasing held work") + self._preflight_state = True + self._release_preflight_holds() + return + text = (out or b"").decode("utf-8", "replace").strip() + if len(text) > self.local_gate_output_chars: + text = "…(truncated)…\n" + text[-self.local_gate_output_chars :] + self._preflight_state = text or f"gate exited {proc.returncode} with no output" + log.error( + "[project_board] PREFLIGHT FAILED — the gate does not pass on clean base; " + "HOLDING all work until the environment is fixed:\n%s", + self._preflight_state, + ) + except Exception as exc: # noqa: BLE001 — a gate that CANNOT LAUNCH is the broken-env case we must catch + self._preflight_state = f"gate command could not run: {exc}" + log.error("[project_board] PREFLIGHT FAILED — %s; HOLDING all work until fixed.", self._preflight_state) + + def _hold_ready_for_preflight(self) -> None: + """Flag every ready, not-already-held feature blocked with the preflight reason, so + the hold shows on the board instead of being a silent stall.""" + reason = self._preflight_state if isinstance(self._preflight_state, str) else "gate preflight failed" + short = "gate preflight failed — the coder environment can't run the gate: " + reason.splitlines()[-1][:200] + store = self._store() + for f in store.list_features(state="ready"): + fid = f["id"] + if fid in self._preflight_held or f.get("blocked"): + continue + try: + store.flag_blocked(fid, short) + self._preflight_held.add(fid) + log.info("[project_board] preflight hold: flagged %s blocked (gate not runnable)", fid) + except Exception: # noqa: BLE001 — a hold that can't be recorded must not kill the tick + log.warning("[project_board] preflight hold: flag_blocked failed for %s", fid, exc_info=True) + + def _release_preflight_holds(self) -> None: + """Clear the blocks this loop placed for a failed preflight (only those — never + clobber a feature blocked for another reason).""" + store = self._store() + for fid in list(self._preflight_held): + try: + store.clear_blocked(fid) + except Exception: # noqa: BLE001 + log.warning("[project_board] preflight release: clear_blocked failed for %s", fid, exc_info=True) + self._preflight_held.clear() + async def _verify_goal(self, feature: dict, wt: str, base: str, coder_reply: str = "") -> str | None: """Pre-PR gate — DETERMINISTIC: no LLM, no diff dump. The one thing it adds over CI is requiring a test to EXIST for a code change (CI runs tests but can't require diff --git a/pyproject.toml b/pyproject.toml index aeb8d13..bbd01e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.32.0" +version = "0.33.0" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/tests/test_loop.py b/tests/test_loop.py index 624760f..a72f869 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2012,3 +2012,118 @@ async def _diff(pr_url, cwd="."): await loop._review_gate(store, "bd-1", "https://github.com/o/r/pull/9", "/repo") assert "prior_findings" in seen_inputs[1] assert "drops data" in seen_inputs[1]["prior_findings"] + + +# ── gate preflight (fail-closed: never start work a broken gate can't accept) ───── + + +class _PreflightStore(FakeLoopStore): + """FakeLoopStore + the ready-list and clear_blocked the preflight hold/release use.""" + + def __init__(self, ready): + super().__init__() + self._ready = [{"id": f, "blocked": False} for f in ready] + + def list_features(self, state=None): + return list(self._ready) if state == "ready" else [] + + def clear_blocked(self, fid): + self.calls.append(("clear_blocked", fid)) + return {"id": fid} + + +class _FakeProc: + def __init__(self, rc, out=b""): + self.returncode = rc + self._out = out + + async def communicate(self): + return self._out, b"" + + def kill(self): + pass + + +def test_preflight_config_defaults(): + assert BoardLoop({}).preflight is True # on by default + assert BoardLoop({})._preflight_state is None + assert BoardLoop({"preflight": False}).preflight is False + + +async def test_preflight_noop_when_no_gate(): + # No local_gate_cmd → nothing to smoke → treated as runnable, never shells out. + lp = BoardLoop({"preflight": True}) + await lp._maybe_preflight() + assert lp._preflight_state is True + + +async def test_preflight_passes_when_gate_exits_zero(monkeypatch): + lp = BoardLoop({"local_gate_cmd": "pnpm -r build"}) + lp._store_kw = {"repo": "/repo"} + + async def _shell(*a, **k): + return _FakeProc(0) + + monkeypatch.setattr("asyncio.create_subprocess_shell", _shell) + await lp._maybe_preflight() + assert lp._preflight_state is True + + +async def test_preflight_fails_closed_on_nonzero(monkeypatch): + lp = BoardLoop({"local_gate_cmd": "pnpm -r build"}) + lp._store_kw = {"repo": "/repo"} + + async def _shell(*a, **k): + return _FakeProc(1, b"apps/x build: sh: 1: tsc: not found") + + monkeypatch.setattr("asyncio.create_subprocess_shell", _shell) + await lp._maybe_preflight() + assert isinstance(lp._preflight_state, str) + assert "tsc: not found" in lp._preflight_state + + +async def test_preflight_fails_closed_when_gate_cannot_launch(monkeypatch): + # The exact case this exists for: the gate binary isn't installed. + lp = BoardLoop({"local_gate_cmd": "pnpm -r build"}) + lp._store_kw = {"repo": "/repo"} + + async def _shell(*a, **k): + raise FileNotFoundError("pnpm") + + monkeypatch.setattr("asyncio.create_subprocess_shell", _shell) + await lp._maybe_preflight() + assert isinstance(lp._preflight_state, str) + assert "could not run" in lp._preflight_state + + +def test_spawn_ready_holds_all_work_when_preflight_failed(monkeypatch): + lp = BoardLoop({"local_gate_cmd": "pnpm -r build"}) + lp._preflight_state = "gate exited 1: tsc: not found" # simulate a failed preflight + store = _PreflightStore(ready=["bd-1", "bd-2"]) + monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store) + + spawned = lp._spawn_ready() + + assert spawned is False # dispatched nothing + blocked = {c[1]: c[2] for c in store.calls if c[0] == "flag_blocked"} + assert set(blocked) == {"bd-1", "bd-2"} # both held, visibly + assert all("preflight" in reason.lower() for reason in blocked.values()) + + +async def test_preflight_recovery_releases_holds(monkeypatch): + lp = BoardLoop({"local_gate_cmd": "pnpm -r build"}) + lp._store_kw = {"repo": "/repo"} + lp._preflight_state = "gate exited 1" # previously failed + lp._preflight_held = {"bd-1", "bd-2"} # and it held these + store = _PreflightStore(ready=[]) + monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store) + + async def _shell(*a, **k): + return _FakeProc(0) # gate now passes + + monkeypatch.setattr("asyncio.create_subprocess_shell", _shell) + await lp._maybe_preflight() + + assert lp._preflight_state is True + assert lp._preflight_held == set() + assert {c[1] for c in store.calls if c[0] == "clear_blocked"} == {"bd-1", "bd-2"} From 732ccdb4dd6ac1bcee38780259818ff07ea2e930 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Mon, 6 Jul 2026 20:08:15 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(loop):=20local=5Fgate=5Fcmd=20"auto"?= =?UTF-8?q?=20=E2=80=94=20discover=20the=20gate=20from=20the=20bound=20rep?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-coding one repo's check steps into the orchestrator (or the operator's dispatch) is a footgun: the repo's CI changes and the transcription silently goes stale (green-locally / red-in-CI), or the team is pointed at a different repo and the gate is just wrong. "auto" resolves the pre-PR gate from the checkout instead, favoring a single repo-DECLARED entrypoint (package.json ci/check/verify → `pnpm run `; Makefile/justfile ci|check target → `make/just `) so a repo whose OWN CI calls that same target keeps local == CI by construction. Falls back to the `pnpm -r --if-present typecheck build test` superset for an undeclared node repo, and to "" (gateless, fail-open, warns) when nothing is recognized. Resolved once at construction, so every downstream reader (coder_solve_test_cmd, _run_local_gate, _preflight, candidate preference) sees the concrete command with no further plumbing. Explicit command passes through; blank still = no gate (auto is opt-in, never inferred from blank). Composes with the fail-closed preflight: auto-resolve the gate, then smoke it on clean base before dispatch. 9 resolver unit tests; full suite 312 green. Co-Authored-By: Claude Opus 4.8 --- README.md | 15 ++++++++++ loop.py | 69 +++++++++++++++++++++++++++++++++++++++++- tests/test_loop.py | 74 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index db4e4b6..1c91b1f 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ project_board: merge_poll: true # poll merged PRs as a fallback to the webhook Done edge goal_verify: false # flip true: verify the coder's diff vs acceptance_criteria before opening a PR max_mode_n: 1 # >1 = best-of-N "Max-Mode": N coders per feature, keep the best diff + local_gate_cmd: "auto" # pre-PR gate, run in each worktree before a PR opens. "auto" + # = DISCOVER it from the bound repo (a package.json ci/check/ + # verify script → `pnpm run `; a Makefile/justfile ci + # target → `make/just `; else the `pnpm -r --if-present + # typecheck build test` superset). Prefer a repo-DECLARED + # entrypoint whose OWN CI calls the same target, so local == CI + # and can't drift. Give an explicit command to override; blank + # = no gate. NOTE: `auto` resolves at construction — the repo + # must be cloned before the loop starts. + preflight: true # fail-CLOSED smoke of local_gate_cmd on the clean base before + # dispatching ANY work: an UNRUNNABLE gate (missing tool, base + # broken) HOLDS all ready work (visible on the board) instead of + # burning generations no coder could pass. Re-checks each cycle, + # releases on recovery. A slow gate times out → indeterminate → + # allow (never wedge the board). Set false to skip. # With local_gate_cmd set, Max-Mode is EXECUTION-GROUNDED (ADR 0064): the winner is # picked from candidates whose gate actually PASSES; the LLM judge only breaks ties # among the passing set (or decides when no gate is set / none pass). diff --git a/loop.py b/loop.py index 6d1cb6c..94fb778 100644 --- a/loop.py +++ b/loop.py @@ -38,6 +38,7 @@ import asyncio import json import logging +import os import re import time @@ -53,6 +54,68 @@ log = logging.getLogger("protoagent.plugins.project_board") + +# ── auto gate resolution ──────────────────────────────────────────────────────── +# The pre-PR gate is repo-specific, and hard-coding one repo's check steps into the +# orchestrator (or the operator's dispatch) rots two ways: the repo's CI changes and +# the transcription silently goes stale (green-locally / red-in-CI), or the same team +# is pointed at a DIFFERENT repo and the gate is simply wrong. So ``local_gate_cmd: +# "auto"`` asks the loop to DISCOVER the gate from the bound checkout. Precedence +# favors a single repo-DECLARED entrypoint — the repo owns its gate, and its own CI +# should invoke that SAME target, so local == CI by construction and can't drift — +# and only falls back to ecosystem conventions: +# 1. package.json script ci / check / verify → ``pnpm run `` +# 2. Makefile / justfile ci / check target → ``make `` / ``just `` +# 3. package.json present, none declared → ``pnpm -r --if-present typecheck build test`` +# 4. nothing recognized → "" (no gate; fail-open, warns) +# An explicit command always passes through unchanged; blank still means "no gate". +# Resolved once at construction (the coder only ever touches worktrees, so the bound +# checkout is a stable base); the deployment clones the repo before the loop starts. +_PNPM_INSTALL = "pnpm install --frozen-lockfile --prefer-offline" + + +def _resolve_gate_cmd(raw: str, repo_path: str) -> str: + """Resolve ``local_gate_cmd``. Only the sentinel ``"auto"`` triggers discovery; + an explicit command (or blank = no gate) is returned unchanged.""" + raw = (raw or "").strip() + if raw != "auto": + return raw + pkg = os.path.join(repo_path, "package.json") + if os.path.isfile(pkg): + try: + with open(pkg, encoding="utf-8") as fh: + scripts = (json.load(fh) or {}).get("scripts", {}) or {} + except (OSError, ValueError): + scripts = {} + for name in ("ci", "check", "verify"): + if name in scripts: + return f"{_PNPM_INSTALL} && pnpm run {name}" + # No declared entrypoint — run the standard checks any workspace exposes. + # ``-r --if-present`` self-skips workspaces missing the script, so this is a + # safe superset: a repo with only tests runs only tests. + return ( + f"{_PNPM_INSTALL} && pnpm -r --if-present typecheck " + "&& pnpm -r --if-present build && pnpm -r --if-present test" + ) + for fname, runner in (("Makefile", "make"), ("makefile", "make"), ("justfile", "just"), ("Justfile", "just")): + fpath = os.path.join(repo_path, fname) + if os.path.isfile(fpath): + try: + with open(fpath, encoding="utf-8") as fh: + body = fh.read() + except OSError: + body = "" + for target in ("ci", "check"): + if re.search(rf"(?m)^{target}:", body): + return f"{runner} {target}" + log.warning( + "[project_board] local_gate_cmd=auto but no gate could be discovered in %s " + "(no package.json ci/check script, no Makefile/justfile ci target) — running gateless", + repo_path, + ) + return "" + + # Deterministic test-coverage gate (path-based — no LLM, no diff). A code change must # ship a test; checking the changed-file LIST is instant and immune to the truncation # that made the old LLM-eyeballs-the-diff verifier false-reject tests it couldn't see. @@ -226,7 +289,11 @@ def __init__(self, cfg: dict): # opens already-green. Best-effort early filter: if it can't pass within # local_gate_max same-tier tries, the PR opens anyway (CI + the ci-fix budget # stay the backstop) — a flaky/misconfigured gate never blocks good work. Empty = off. - self.local_gate_cmd = str(self.cfg.get("local_gate_cmd", "")).strip() + # ``auto`` ⇒ discover the gate from the bound repo (see _resolve_gate_cmd); + # an explicit command or blank (= no gate) passes through. Resolved here once, + # so every downstream reader (coder_solve_test_cmd, _run_local_gate, _preflight, + # candidate preference) sees the concrete command with no further plumbing. + self.local_gate_cmd = _resolve_gate_cmd(str(self.cfg.get("local_gate_cmd", "")), str(self.cfg.get("repo", "."))) self.local_gate_max = max(0, int(self.cfg.get("local_gate_max", 2))) self.local_gate_timeout = float(self.cfg.get("local_gate_timeout_s", 600)) self.local_gate_output_chars = max(500, int(self.cfg.get("local_gate_output_chars", 4000))) diff --git a/tests/test_loop.py b/tests/test_loop.py index a72f869..283c95e 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -13,7 +13,7 @@ import asyncio from project_board import worktree -from project_board.loop import BoardLoop, _ci_failure_reason +from project_board.loop import BoardLoop, _ci_failure_reason, _resolve_gate_cmd class FakeLoopStore: @@ -2127,3 +2127,75 @@ async def _shell(*a, **k): assert lp._preflight_state is True assert lp._preflight_held == set() assert {c[1] for c in store.calls if c[0] == "clear_blocked"} == {"bd-1", "bd-2"} + + +# ── auto gate resolution (_resolve_gate_cmd) ──────────────────────────────────── + + +def _write(p, name, body): + f = p / name + f.write_text(body) + return f + + +def test_resolve_gate_explicit_command_passes_through(tmp_path): + # An explicit gate is never rewritten, even if the repo declares a ci script. + _write(tmp_path, "package.json", '{"scripts": {"ci": "pnpm test"}}') + assert _resolve_gate_cmd("pytest -q", str(tmp_path)) == "pytest -q" + + +def test_resolve_gate_blank_stays_gateless(tmp_path): + # Blank still means "no gate" — auto must be opt-in, never inferred from blank. + _write(tmp_path, "package.json", '{"scripts": {"ci": "pnpm test"}}') + assert _resolve_gate_cmd("", str(tmp_path)) == "" + assert _resolve_gate_cmd(" ", str(tmp_path)) == "" + + +def test_resolve_gate_auto_prefers_declared_ci_script(tmp_path): + _write(tmp_path, "package.json", '{"scripts": {"ci": "pnpm typecheck && pnpm -r test", "test": "x"}}') + assert _resolve_gate_cmd("auto", str(tmp_path)) == f"{loop_install()} && pnpm run ci" + + +def test_resolve_gate_auto_falls_to_check_then_verify(tmp_path): + _write(tmp_path, "package.json", '{"scripts": {"check": "x", "verify": "y"}}') + assert _resolve_gate_cmd("auto", str(tmp_path)) == f"{loop_install()} && pnpm run check" + _write(tmp_path, "package.json", '{"scripts": {"verify": "y"}}') + assert _resolve_gate_cmd("auto", str(tmp_path)) == f"{loop_install()} && pnpm run verify" + + +def test_resolve_gate_auto_convention_fallback_when_no_entrypoint(tmp_path): + # A node repo with no ci/check/verify → the --if-present standard-checks superset. + _write(tmp_path, "package.json", '{"scripts": {"test": "vitest run"}}') + got = _resolve_gate_cmd("auto", str(tmp_path)) + assert got == ( + f"{loop_install()} && pnpm -r --if-present typecheck && pnpm -r --if-present build && pnpm -r --if-present test" + ) + + +def test_resolve_gate_auto_reads_makefile_ci_target(tmp_path): + _write(tmp_path, "Makefile", "build:\n\tgo build ./...\nci:\n\tgo test ./...\n") + assert _resolve_gate_cmd("auto", str(tmp_path)) == "make ci" + + +def test_resolve_gate_auto_justfile_check_target(tmp_path): + _write(tmp_path, "justfile", "default:\n\techo hi\ncheck:\n\tcargo test\n") + assert _resolve_gate_cmd("auto", str(tmp_path)) == "just check" + + +def test_resolve_gate_auto_unrecognized_repo_is_gateless(tmp_path): + # Nothing recognized → "" (fail-open, gateless) rather than a wrong guess. + _write(tmp_path, "README.md", "# a repo with no known toolchain") + assert _resolve_gate_cmd("auto", str(tmp_path)) == "" + + +def test_resolve_gate_auto_malformed_package_json_falls_through(tmp_path): + # Broken package.json must not crash construction — treat as no scripts → fallback. + _write(tmp_path, "package.json", "{ not valid json ") + got = _resolve_gate_cmd("auto", str(tmp_path)) + assert got.startswith(loop_install()) and "--if-present" in got + + +def loop_install(): + from project_board.loop import _PNPM_INSTALL + + return _PNPM_INSTALL From d55d00b1d2d9111fecb955e06817ae3e80c2b749 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Mon, 6 Jul 2026 20:16:25 -0700 Subject: [PATCH 3/4] fix(loop): preflight must not touch the store on a clean pass; bump manifest to 0.33.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI-only failures on this branch: - _release_preflight_holds() built the store (get_store → needs the `br` beads CLI) even with zero holds to release, so a PASSING preflight called it, and on a host without `br` the error was swallowed by _preflight's outer except and masqueraded as a gate failure (_preflight_state = "gate command could not run: beads CLI 'br' not on PATH"). It passed locally only because the dev host has `br`. Short-circuit before building the store: a clean preflight (the common path) never touches it. - protoagent.plugin.yaml still said 0.32.0 while pyproject moved to 0.33.0 (test_manifest_and_pyproject_versions_agree). Bump to 0.33.0. Co-Authored-By: Claude Opus 4.8 --- loop.py | 5 +++++ protoagent.plugin.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/loop.py b/loop.py index 94fb778..c2d1aca 100644 --- a/loop.py +++ b/loop.py @@ -1408,6 +1408,11 @@ def _hold_ready_for_preflight(self) -> None: def _release_preflight_holds(self) -> None: """Clear the blocks this loop placed for a failed preflight (only those — never clobber a feature blocked for another reason).""" + if not self._preflight_held: + return # nothing to release — don't build the store (it may need a CLI/DB + # that isn't present) just to iterate an empty set. A clean preflight (the + # common path) must never touch the store: the resulting error would be + # caught by _preflight's outer except and masquerade as a gate failure. store = self._store() for fid in list(self._preflight_held): try: diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 165f8f0..4e8e481 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.32.0 +version: 0.33.0 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 From 98858a51f86f7ee14aa7649e075feccfdb3a8805 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Mon, 6 Jul 2026 20:21:44 -0700 Subject: [PATCH 4/4] test(loop): make preflight recovery test clock-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_preflight_recovery_releases_holds pre-set a failed _preflight_state but left _last_preflight=0.0, so whether the throttled recheck fired depended on time.monotonic()'s absolute value — large on a dev host (passed), small in a fresh CI container (< the 60s throttle → recheck skipped → stale state, flaky fail). Anchor _last_preflight well in the past so the recheck always fires. Co-Authored-By: Claude Opus 4.8 --- tests/test_loop.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_loop.py b/tests/test_loop.py index 283c95e..32f58bd 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2115,6 +2115,10 @@ async def test_preflight_recovery_releases_holds(monkeypatch): lp._store_kw = {"repo": "/repo"} lp._preflight_state = "gate exited 1" # previously failed lp._preflight_held = {"bd-1", "bd-2"} # and it held these + # A recheck of a KNOWN-failed preflight is throttled by (monotonic() - _last_preflight); + # put the last check far enough back that the recheck fires regardless of the absolute + # monotonic value (a fresh CI container's clock can be < the 60s throttle window). + lp._last_preflight = -10_000.0 store = _PreflightStore(ready=[]) monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store)