From ecde8abb538ddb1e8f82cb0cd2549f75e9a5a8d5 Mon Sep 17 00:00:00 2001 From: istos Date: Sat, 1 Aug 2026 09:43:35 +0200 Subject: [PATCH 1/5] A member of a phase branches from the phase, not from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces the runner needs, in the modules that own them. taskfiles grows a third door: append_to_section, one line under one heading. The phase log is a running record, and append_to_task would scatter its lines through the file as other sections landed between them — the record would stop being readable in the one place a person looks. It commits like every other board-made write. agents learns where a phase member starts. That is the whole reason a phase has a branch: related cards run one after another, so card two branched from main could not see card one's work while card one sat unmerged in review/ — it would conflict, or quietly build the same thing twice. A card in no phase, or one whose phase has not been started, takes the ordinary fresh branch point, and the ticker names the unusual base as it already does. claim_for_launch loses its underscore: a phase run claims its card the same way starting work on one does, from another module. Co-Authored-By: Claude Opus 5 --- manager/core/agents.py | 54 +++++++++++++++++++++++++++++++++++---- manager/core/taskfiles.py | 34 ++++++++++++++++++++++++ tests/test_actor_acts.py | 10 ++++---- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/manager/core/agents.py b/manager/core/agents.py index 09d7f21..3c6e1ad 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -22,8 +22,17 @@ import config import events import reports import state -from taskfiles import (actor_name, append_to_task, find_stage_of, move_task, - read_task, set_assignee) +from taskfiles import (actor_name, append_to_task, collect, find_stage_of, + move_task, read_task, set_assignee) + +# A phase runs on an integration branch of its own — phases.py owns the +# behaviour, the name lives here because this is where a launch decides +# what to branch from. +PHASE_BRANCH_PREFIX = "phase/" + + +def phase_branch(phase_file: str) -> str: + return PHASE_BRANCH_PREFIX + phase_file[:-3] def _report_of(record: dict, text: str | None = None) -> str: @@ -201,7 +210,38 @@ def _fresh_branch_point() -> tuple[str | None, str | None]: return "origin/main", None -def _claim_for_launch(filename: str, stage: str, takeover: bool) -> None: +def phase_branch_point(filename: str) -> tuple[str | None, str | None]: + """Where a *member of a running phase* branches from: the phase's own + branch, not main. + + That is the whole reason a phase has a branch. Related cards run one + after another, so card two branched from main could not see card one's + work while card one sat unmerged in review/ — it would conflict, or + quietly build the same thing twice. Branching from the phase tip is + what makes the list add up. + + (None, None) for a card in no phase, or one whose phase has not been + started — then the ordinary fresh branch point applies. + """ + phase = None + try: + for stage in collect()["stages"]: + for task in stage["tasks"]: + if task["file"] == filename: + phase = task.get("phase") + except OSError: + return None, None + if not phase: + return None, None + branch = phase_branch(phase["file"]) + exists = subprocess.run(["git", "-C", str(config.REPO), "rev-parse", + "--verify", "--quiet", branch], capture_output=True) + if exists.returncode != 0: + return None, None + return branch, f"branched from {branch}, the phase's own branch" + + +def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None: """One agent per task is a board-memory rule; across machines the card file is the only thing every board can see, so the claim is what gates a launch here. @@ -238,7 +278,7 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: # Moving a card to in-progress is the commitment; only then does work start. _validate(filename, stage, {"in-progress"}, "work starts from in-progress/ — move the card there first") - _claim_for_launch(filename, stage, takeover) + claim_for_launch(filename, stage, takeover) stem = filename[:-3] branch = f"task/{stem}" @@ -267,7 +307,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: base = _git("merge-base", "main", branch).stdout.strip() result = _git("worktree", "add", str(worktree), branch) else: - point, base_note = _fresh_branch_point() + # A phase member starts from its phase's tip; everything else + # from the newest main this checkout can see. + point, base_note = phase_branch_point(filename) + if point is None: + point, base_note = _fresh_branch_point() if point: base = _git("rev-parse", point).stdout.strip() result = _git("worktree", "add", "--no-track", "-b", branch, diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index 0ffbf64..cd29c71 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -450,6 +450,40 @@ def append_to_task(filename: str, stage: str, text: str, what: str) -> bool: return True +def append_to_section(filename: str, stage: str, heading: str, line: str, + what: str) -> bool: + """Add one line under `## `, creating the section at the end of + the card when it is not there yet. + + The third door, and the narrowest: a running record where every entry is + one line and belongs under one heading — the phase log. `append_to_task` + would scatter those lines through the file as other sections (a work + report, a review) landed between them, and the record would stop being + readable in the one place a person looks. It commits like every other + board-made write. + """ + path = config.TASKS / stage / filename + try: + text = path.read_text(encoding="utf-8") + except OSError: + return False + section = re.compile(rf"^##\s+{re.escape(heading)}\s*$(.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL).search(text) + if section: + body = section.group(1).strip("\n") + updated = f"## {heading}\n\n{body}\n{line}\n\n" if body else \ + f"## {heading}\n\n{line}\n\n" + text = text[:section.start()] + updated + text[section.end():] + else: + text = text.rstrip("\n") + f"\n\n## {heading}\n\n{line}\n" + try: + path.write_text(text, encoding="utf-8") + except OSError: + return False + commit_edit(filename, stage, what) + return True + + def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict: """Move a task file between stage directories and fix its Status line. diff --git a/tests/test_actor_acts.py b/tests/test_actor_acts.py index b7745aa..1512b93 100644 --- a/tests/test_actor_acts.py +++ b/tests/test_actor_acts.py @@ -430,7 +430,7 @@ class ClaimsGateLaunches(Boards): BRANCH).returncode, 1) def test_the_deliberate_takeover_reassigns_the_card(self): - agents._claim_for_launch(FILENAME, "in-progress", True) + agents.claim_for_launch(FILENAME, "in-progress", True) self.assertIn("**Assignee:** elena", self.text(self.elena)) self.assertEqual(self.text(self.elena).count("**Assignee:**"), 1) @@ -440,7 +440,7 @@ class ClaimsGateLaunches(Boards): for s in self.summaries())) def test_the_takeover_reaches_the_other_board(self): - agents._claim_for_launch(FILENAME, "in-progress", True) + agents.claim_for_launch(FILENAME, "in-progress", True) self.assertEqual(sync.push_now(), "ok") self.use(self.ada) @@ -450,7 +450,7 @@ class ClaimsGateLaunches(Boards): def test_an_unclaimed_card_claims_on_launch(self): self.place(self.elena, "in-progress", card("In Progress"), commit=False) - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertIn("**Assignee:** elena", self.text(self.elena)) self.assertTrue(any("claimed" in s for s in self.summaries())) @@ -460,7 +460,7 @@ class ClaimsGateLaunches(Boards): commit=False) head = git(self.elena, "rev-parse", "HEAD").stdout - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertEqual(git(self.elena, "rev-parse", "HEAD").stdout, head, "nothing to record: it was already yours") @@ -470,7 +470,7 @@ class ClaimsGateLaunches(Boards): a lock — a hand-written line stays decoration.""" self.patch(SYNC=False, COMMIT_MOVES=False) - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertIn("**Assignee:** ada", self.text(self.elena)) From af24a3fb397937bea61898c1009afdebae4ef5af Mon Sep 17 00:00:00 2001 From: istos Date: Sat, 1 Aug 2026 09:43:45 +0200 Subject: [PATCH 2/5] A card's branch may be a phase's, and a member's PR is based on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github asked one question in four places by writing task/ out each time. It asks branch_of() now, which answers phase/ for a phase card — so from review/ onwards a phase is an ordinary card: its PR opens, its worktree is driven, and merge & clean up finds the branch it is meant to take apart. A phase member's PR is opened against its phase's branch rather than main. Its branch was cut from there, so that is the only base whose diff is the member's own work — and a PR into main carrying a whole phase would invite exactly the merge this design refuses to make. The base is published first, so the remote has something to open against; the main-is-ahead guard still stands in front of every PR into main. A phase's own PR says what is in it: the member list, in run order. Co-Authored-By: Claude Opus 5 --- manager/core/github.py | 104 ++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/manager/core/github.py b/manager/core/github.py index f498b82..26f7fdf 100644 --- a/manager/core/github.py +++ b/manager/core/github.py @@ -27,7 +27,8 @@ import config import drive as drive_mod import reports import state -from taskfiles import STATUS_RE, commit_edit, find_stage_of, move_task, read_task +from taskfiles import (STATUS_RE, collect, commit_edit, find_stage_of, + move_task, read_task) PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts} _OPENING: set[str] = set() # filenames with a PR-open in flight @@ -52,6 +53,59 @@ def _branch_exists(branch: str) -> bool: return _run(["git", "rev-parse", "--verify", "--quiet", branch]).returncode == 0 +def branch_of(filename: str) -> str: + """The branch a card's work lives on. Ordinary cards get `task/`; + a phase runs on `phase/`, its own integration branch (phases.py), + and from review/ onwards it is reviewed, driven and completed through + the same apparatus as any other card.""" + phase = f"phase/{filename[:-3]}" + return phase if _branch_exists(phase) else f"task/{filename[:-3]}" + + +def _woven(filename: str, stage: str) -> dict: + """The card as the board shows it. A card's phase is *derived* across + the whole board (`taskfiles.weave_phases`), and both what a PR is based + on and what a phase's PR says depend on that reading, so a lone + `read_task` is not enough here.""" + for group in collect()["stages"]: + for task in group["tasks"]: + if task["file"] == filename: + return task + return read_task(config.TASKS / stage / filename, stage) + + +def _pr_base(task: dict) -> str: + """What a PR is opened against. A phase member's branch was cut from + its phase's branch, so that is the only base whose diff is the member's + own work — a PR into main would carry the whole phase, and invite a + merge into main that this board exists not to make. Everything else, + the phase card included, goes into main.""" + phase = task.get("phase") + if phase: + branch = f"phase/{phase['file'][:-3]}" + if _branch_exists(branch): + return branch + return "main" + + +def _pr_body(filename: str, task: dict) -> str: + """The PR's body. A phase's PR is the one a whole run produces, so it + says what is in it: the member list, in the order it ran.""" + if task.get("isPhase"): + cards = "\n".join(f"- {m['number']} — {m['title']}" + for m in task.get("members") or []) + return (f"Phase: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" + f"Opened by the board when every card in the phase had been " + f"merged into its branch.\n\n## Cards in this phase\n\n" + f"{cards or '_none_'}\n") + body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" + f"Opened by the board when the card moved to review.") + summary = _agent_report(filename) + if summary: + body += f"\n\n## Agent summary\n\n{summary}" + return body + + def _write_pr_line(filename: str, url: str) -> None: """The url joins the header — and in team mode commits itself, so the gate that stops a second board opening a second PR travels to the other @@ -115,14 +169,14 @@ def open_pr_now(filename: str) -> str: def _open_pr(filename: str) -> str: - branch = f"task/{filename[:-3]}" + branch = branch_of(filename) if not _branch_exists(branch): # nothing to publish — a hand-moved card without agent work raise _Quiet(f"{filename} has no {branch} branch — nothing to open a PR from") stage = find_stage_of(filename) if stage != "review": raise _Quiet(f"{filename} is not in review/ — PRs open from there") - task = read_task(config.TASKS / stage / filename, stage) + task = _woven(filename, stage) if task.get("pr"): raise _Quiet(f"{filename} already has a PR: {task['pr']}") @@ -132,24 +186,26 @@ def _open_pr(filename: str) -> str: ("no git remote configured" if rname is None else "gh is not installed")) - # The PR's diff is computed against the remote main — refuse to open one - # that would drag unpushed main commits along with it. - _run(["git", "fetch", rname, "main"], timeout=120) - ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip() - if ahead.isdigit() and int(ahead) > 0: - raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits " - f"ahead of {rname} — push main first, then move the card again") + base = _pr_base(task) + if base == "main": + # The PR's diff is computed against the remote main — refuse to open one + # that would drag unpushed main commits along with it. + _run(["git", "fetch", rname, "main"], timeout=120) + ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip() + if ahead.isdigit() and int(ahead) > 0: + raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits " + f"ahead of {rname} — push main first, then move the card again") + else: + # A phase branch is the board's own: publish it so the member's PR + # has a base on the remote to be opened against. + _run(["git", "push", "-u", rname, base], timeout=180) push = _run(["git", "push", "-u", rname, branch], timeout=180) if push.returncode != 0: raise ValueError(f"push failed for {branch}: {push.stderr.strip()[:140]}") - body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n" - f"Opened by the board when the card moved to review.") - summary = _agent_report(filename) - if summary: - body += f"\n\n## Agent summary\n\n{summary}" - result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main", + body = _pr_body(filename, task) + result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", base, "--title", task["title"], "--body", body], timeout=120) if result.returncode != 0: # The rare double-fire: two attempts crossed and GitHub already has @@ -417,7 +473,7 @@ def complete_task(filename: str, stage: str) -> dict: def _complete(filename: str, stage: str) -> dict: """The steps themselves, run under the claim complete_task holds.""" stem = filename[:-3] - branch = f"task/{stem}" + branch = branch_of(filename) # a phase's own branch, or task/ # 1. the app must not keep running code that is about to be merged away d = drive_mod.DRIVE @@ -512,11 +568,17 @@ def _merge_on_origin(filename: str, stage: str, branch: str) -> None: def task_branches() -> list[str]: - """Stems of all task/* branches — the UI uses this to say honestly - whether a review card has work attached.""" + """Stems of all task/* and phase/* branches — the UI uses this to say + honestly whether a card has work attached, and a phase card's work is + its own integration branch.""" result = _run(["git", "for-each-ref", "--format=%(refname:short)", - "refs/heads/task/"]) - return [ref[len("task/"):] for ref in result.stdout.split() if ref.startswith("task/")] + "refs/heads/task/", "refs/heads/phase/"]) + stems = [] + for ref in result.stdout.split(): + for prefix in ("task/", "phase/"): + if ref.startswith(prefix): + stems.append(ref[len(prefix):]) + return stems def reconcile() -> None: From da8984d0e64333207ebedff2abb36d9cc5d71330 Mon Sep 17 00:00:00 2001 From: istos Date: Sat, 1 Aug 2026 09:43:57 +0200 Subject: [PATCH 3/5] A phase runs itself, on a branch of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a phase cuts phase/ from the newest main it can see and works the list into it: each member branched from the phase's tip, run headless, merged back when its checks are green, the next one started. At the end one PR into main, for a human. The human gate moves from every card to the phase boundary, and the promise survives: the board merges into a branch it created, inside a scope you opened. The runner is a beat, not an agent — everything it decides is already structured state, and an agent paid to poll would be the wrong tool at the wrong price. It holds no registry of where a phase is. Two durable things carry the memory, and the board already writes both: git, where a member is finished when its branch is contained in the phase branch, and the card, which grows a ## Phase log the runner adds one line to per decision. The log is what tells "this member has run and it ended badly" from "the phase has not reached it yet" — without it a restarted board would relaunch a run that died. Containment alone is not enough to call a member merged: a clean exit that committed nothing leaves an empty branch that is contained. The card has to have settled into review/ too, or a broken launch would hide exactly where it always tries to. Five conditions halt, each already a visible state on the card, and a halt is written once and then held. Running the phase again is the person's decision and is what clears it — the run is scoped to its own log line, so a member whose run died is launchable again. A dependency that has not landed is a wait, not a halt. Merges are additive throughout: main into the phase branch on every beat so a long run does not drift into one enormous conflict, members into it as they go green, nothing rebased and nothing force-pushed. A conflict aborts, leaves the branch as it was, and halts naming the files that collided. The actor rule decides who runs it, written where it already lives: the phase card's assignee. A replica renders the phase and advances nothing. Reachable through /api/phase/run and the ticker; the header chip and the card actions are a separate card. Co-Authored-By: Claude Opus 5 --- manager/core/.env.example | 8 + manager/core/board.py | 3 + manager/core/config.py | 5 + manager/core/httpd.py | 11 + manager/core/phases.py | 579 +++++++++++++++++++++++++ tests/test_phase_runs.py | 891 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 1497 insertions(+) create mode 100644 manager/core/phases.py create mode 100644 tests/test_phase_runs.py 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: `-