diff --git a/manager/core/.env.example b/manager/core/.env.example index 1a2385e..ca16723 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -124,6 +124,14 @@ BOARD_COMMIT_MOVES= BOARD_SYNC= BOARD_SYNC_INTERVAL=30 +# Seconds between passes of the phase runner: a phase card being run +# (**Type:** Phase, in in-progress/) has its list worked into its own +# `phase/` branch one card at a time. Each pass recomputes what the +# phase needs from the cards and from git and does the one next thing, so +# raising this only makes a phase slower to notice, never wrong. Nothing +# runs at all while no phase is being run. +BOARD_PHASE_INTERVAL=30 + # Seconds between disk polls of the stage directories. BOARD_WATCH_INTERVAL=2 diff --git a/manager/core/board.py b/manager/core/board.py index 556ec46..9bf289d 100755 --- a/manager/core/board.py +++ b/manager/core/board.py @@ -14,6 +14,7 @@ task files, but the tasks work as a plain folder kanban without it. See events.py hook payloads → displayable events, session registry sync.py origin/main as the shared board: push on move, pull on a beat agents.py headless work/review agents: launch, reap, stop, diff + phases.py a phase run: its own branch, its members merged into it watch.py 2s disk poller narrating moves made outside the API httpd.py HTTP routes, SSE stream, the page itself .prompts/ agent prompt templates (read fresh on every launch) @@ -34,6 +35,7 @@ import drive import events import github import httpd +import phases import state import sync import watch @@ -66,6 +68,7 @@ def main() -> None: threading.Thread(target=watch.watcher, daemon=True).start() threading.Thread(target=github.poller, daemon=True).start() threading.Thread(target=github.reconcile, daemon=True).start() + threading.Thread(target=phases.beat, daemon=True).start() if config.SYNC: # Team mode: board commits publish themselves and a beat pulls what # the other boards published. Off, neither thread nor hook exists. diff --git a/manager/core/config.py b/manager/core/config.py index 1563f6d..3e80f24 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -175,6 +175,11 @@ SYNC_INTERVAL = float(setting("BOARD_SYNC_INTERVAL", "30")) # tasks/ stays a hand job. COMMIT_MOVES = flag("BOARD_COMMIT_MOVES") or SYNC +# How often the phase runner takes a pass: recompute what each running +# phase needs and do the one next thing. A beat, not an agent — it costs a +# handful of git commands and nothing at all when no phase is running. +PHASE_INTERVAL = float(setting("BOARD_PHASE_INTERVAL", "30")) + WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2")) EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800")) BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300")) diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 6edd014..379d9e9 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -17,6 +17,7 @@ import config import drive import events import github +import phases import state import sync import taskfiles @@ -34,6 +35,9 @@ def state_payload() -> dict: "sessions": sessions, "agents": agents.list_public(), "prs": github.public_state(), + # what the last pass of the phase runner saw: per running phase + # card, its branch and each member's state + "phases": phases.public_state(), "drive": drive.public(), "hasDriver": config.driver_path() is not None, "branches": github.task_branches(), @@ -190,6 +194,13 @@ class Handler(BaseHTTPRequestHandler): payload = self._read_body() agent = agents.start_pr_fix(payload["file"], payload["stage"]) self._json(200, {"agent": agent}) + elif path == "/api/phase/run": + payload = self._read_body() + # takeover carries the same meaning it does for a launch: + # the deliberate second click on someone else's card + self._json(200, {"phase": phases.start_phase( + payload["file"], payload["stage"], + bool(payload.get("takeover")))}) elif path == "/api/pr/open": payload = self._read_body() self._json(200, {"url": github.open_pr_now(payload["file"])}) diff --git a/manager/core/phases.py b/manager/core/phases.py new file mode 100644 index 0000000..e92b611 --- /dev/null +++ b/manager/core/phases.py @@ -0,0 +1,579 @@ +"""A phase runs itself, on a branch of its own. + +A phase is a card that lists its cards (`taskfiles.weave_phases`). Running +one means working that list into a single integration branch: `phase/` +cut from the newest main, each member branched from the phase's tip, run +headless, merged back when its checks are green, and the next one started. +At the end one PR into `main`, for a human. The board never merges into +`main` — a phase branch is the board's own, and merging into it is +bookkeeping in the same family as committing a move. + +**The runner is a beat, not an agent.** Everything it decides is already +structured state — a card's stage, a PR's CI verdict, whether one branch is +contained in another — so an agent paid to poll would be the wrong tool at +the wrong price. + +**The beat is stateless.** On each pass it recomputes, from disk and from +git, which members are finished, which is first unfinished and what that +one needs. It holds no registry of where a phase "is": a restarted board +resumes a phase by looking, and the same logic answers "what now?" whether +the last event was a launch, a merge or a crash. Two things carry the +memory, and both are durable: + +- **git** — a member is merged when its branch is contained in the phase + branch. That is what makes a restart safe from repeating a merge. +- **the phase card** — a `## Phase log` section the runner appends one line + to per decision (a run started, a member started, a member merged, a + halt). It is the record a person reads, and the only thing that can tell + "the phase already started this member and its run ended badly" from "the + phase has not reached this member yet". Without it a restart would + silently relaunch a run that died. + +**Halt, never skip.** Five conditions stop a phase, each of them already a +visible state on the card: a member that declined (`NOT READY`), a run that +exited non-zero, a clean exit that committed nothing, CI red, and a merge +into the phase branch that is not mechanical. A phase that steps over a +failed card builds the rest on a foundation that never landed. Halting is +recorded in the log and nothing retries by itself; running the phase again +is a person's decision and appends the line that clears the halt. + +**One board runs it.** The actor rule decides, and the phase card's +**Assignee** is where it is written down — the same claim that gates +starting work. A replica renders the phase and advances nothing. +""" + +from __future__ import annotations + +import re +import subprocess +import threading +import time +from pathlib import Path + +import agents +import config +import github +import state +import taskfiles + +LOG_HEADING = "Phase log" + +# One log line: `-