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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <it>`; a Makefile/justfile ci
# target → `make/just <it>`; 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).
Expand Down
189 changes: 188 additions & 1 deletion loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import asyncio
import json
import logging
import os
import re
import time

Expand All @@ -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 <name>``
# 2. Makefile / justfile ci / check target → ``make <name>`` / ``just <name>``
# 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.
Expand Down Expand Up @@ -226,10 +289,30 @@ 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)))
# 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 →
Expand Down Expand Up @@ -455,6 +538,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")
Expand All @@ -477,6 +561,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:
Expand Down Expand Up @@ -1234,6 +1323,104 @@ 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)."""
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:
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
Expand Down
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.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
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.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"

Expand Down
Loading
Loading