Three points from the PR #41 Copilot review, each a robustness gap on a path the happy case never takes: - phases._mine() gated the beat on the assignee even when this checkout has no git name — but agents.claim_for_launch() cannot gate a launch there and lets it through, so a phase could start (branch cut, run recorded) and then advance nowhere. _mine() now treats "no local identity" as the lone actor, matching the launch it mirrors. - phases._record() ignored whether the log line landed. The log is the durable memory a restart reads to tell "already started" from "not reached yet"; a launch or merge with no line behind it is what a restart repeats. _record() now raises _Halt when the write fails — before the action it was meant to precede — split from a best-effort _write_log() the halt path and _start() use so recording a halt can never itself raise. - github._pr_base() switched a member PR's base to the phase branch only when it existed locally. A board that did not run the phase knows it only through the remote (sync fetches origin/main and nothing else), so _pr_base() now also honours a phase branch the remote carries, and _open_pr() only pushes the base when it is a local branch. Four new tests in tests/test_phase_runs.py cover each. Full suite green (740). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
940 lines
36 KiB
Python
940 lines
36 KiB
Python
"""A phase runs itself, on a branch of its own (task 49).
|
|
|
|
The runner is a beat, not an agent: `phases.advance_all()` is one pass, and
|
|
these cases drive it by hand rather than waiting on a thread. Everything
|
|
under it is real — a real git repo, real worktrees, real headless launches
|
|
through a real (stub) adapter — because what this card is about is the
|
|
arithmetic of branches, and mocking git would be mocking the subject.
|
|
|
|
python3 -m unittest discover -s tests -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO / "manager" / "core"))
|
|
|
|
import agents # noqa: E402
|
|
import config # noqa: E402
|
|
import github # noqa: E402
|
|
import phases # noqa: E402
|
|
import state # noqa: E402
|
|
import taskfiles # noqa: E402
|
|
|
|
PHASE = "40-ship-the-site.md"
|
|
PHASE_BRANCH = "phase/40-ship-the-site"
|
|
ONE = "31-stand-up-site.md"
|
|
TWO = "32-serve-it.md"
|
|
PR_URL = "https://github.com/acme/widget/pull/7"
|
|
|
|
# An agent that does the job: one file, one commit, a clean exit.
|
|
WORKS = """#!/usr/bin/env python3
|
|
import os, subprocess
|
|
cwd = os.environ["AGENT_CWD"]
|
|
name = os.environ["BOARD_TASK"].split("-")[0]
|
|
open(os.path.join(cwd, name + ".txt"), "w").write("card " + name + "\\n")
|
|
subprocess.run(["git", "-C", cwd, "add", "-A"], check=True)
|
|
subprocess.run(["git", "-C", cwd, "-c", "user.email=a@b", "-c", "user.name=stub",
|
|
"commit", "-q", "-m", "card " + name], check=True)
|
|
print("WORK REPORT: card", name, "built")
|
|
"""
|
|
|
|
# The same, but it rewrites a file main also owns — the collision a phase
|
|
# branch has to refuse rather than guess at.
|
|
COLLIDES = """#!/usr/bin/env python3
|
|
import os, subprocess
|
|
cwd = os.environ["AGENT_CWD"]
|
|
open(os.path.join(cwd, "code.txt"), "w").write("the member's line\\n")
|
|
subprocess.run(["git", "-C", cwd, "add", "-A"], check=True)
|
|
subprocess.run(["git", "-C", cwd, "-c", "user.email=a@b", "-c", "user.name=stub",
|
|
"commit", "-q", "-m", "collide"], check=True)
|
|
print("WORK REPORT: done")
|
|
"""
|
|
|
|
DECLINES = """#!/usr/bin/env python3
|
|
print("NOT READY: the card does not say which store to write to")
|
|
"""
|
|
|
|
DIES = """#!/usr/bin/env python3
|
|
import sys
|
|
print("API Error: 500 overloaded")
|
|
sys.exit(1)
|
|
"""
|
|
|
|
COMMITS_NOTHING = """#!/usr/bin/env python3
|
|
print("WORK REPORT: I read a lot and wrote nothing")
|
|
"""
|
|
|
|
FAKE_GH = f'''#!/usr/bin/env python3
|
|
import json, os, sys
|
|
args = sys.argv[1:]
|
|
with open(os.environ["GH_LOG"], "a") as fh:
|
|
fh.write(json.dumps(args) + "\\n")
|
|
if args[:2] == ["pr", "create"]:
|
|
print("{PR_URL}")
|
|
elif args[:2] == ["pr", "view"] and "--jq" in args:
|
|
print("{PR_URL}")
|
|
elif args[:2] == ["pr", "view"]:
|
|
print(json.dumps({{"reviews": [], "reviewRequests": [],
|
|
"statusCheckRollup": [], "state": "OPEN",
|
|
"mergeable": "MERGEABLE"}}))
|
|
else:
|
|
sys.exit(0)
|
|
'''
|
|
|
|
|
|
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["git", "-C", str(cwd), *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def wait_for(pred, timeout: float = 30.0) -> bool:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if pred():
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
|
|
def card(title: str, *, status: str = "Backlog", kind: str | None = None,
|
|
depends: str | None = None, cards: str | None = None) -> str:
|
|
text = f"# {title}\n\n**Status:** {status}\n**Priority:** High\n"
|
|
if kind:
|
|
text += f"**Type:** {kind}\n"
|
|
if depends:
|
|
text += f"**Depends on:** {depends}\n"
|
|
text += "\nWhat this card is for, at enough length to be a brief.\n"
|
|
if cards is not None:
|
|
text += f"\n## Cards\n\n{cards}\n"
|
|
return text
|
|
|
|
|
|
class PhaseCase(unittest.TestCase):
|
|
"""One repo, one phase card, two members — and an adapter whose
|
|
behaviour each test writes."""
|
|
|
|
REMOTE = False
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp(prefix="bench-phase-run-")).resolve()
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.repo = self.tmp / "repo"
|
|
|
|
if self.REMOTE:
|
|
self.origin = self.tmp / "origin.git"
|
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main",
|
|
str(self.origin)], check=True, capture_output=True)
|
|
subprocess.run(["git", "clone", "-q", str(self.origin), str(self.repo)],
|
|
check=True, capture_output=True)
|
|
else:
|
|
self.repo.mkdir()
|
|
git(self.repo, "init", "-q", "-b", "main")
|
|
git(self.repo, "config", "user.email", "t@t")
|
|
git(self.repo, "config", "user.name", "tester")
|
|
(self.repo / "code.txt").write_text("shipped\n", encoding="utf-8")
|
|
|
|
self.tasks = self.repo / "tasks"
|
|
for slug in config.STAGE_DIRS:
|
|
(self.tasks / slug).mkdir(parents=True)
|
|
git(self.repo, "add", "-A")
|
|
git(self.repo, "commit", "-q", "-m", "root")
|
|
if self.REMOTE:
|
|
git(self.repo, "push", "-q", "origin", "main")
|
|
|
|
local = self.tmp / "local"
|
|
(local / "adapters" / config.ADAPTER).mkdir(parents=True)
|
|
self.adapter = local / "adapters" / config.ADAPTER / "run"
|
|
self.adapter_is(WORKS)
|
|
|
|
gh = self.tmp / "gh"
|
|
gh.write_text(FAKE_GH, encoding="utf-8")
|
|
gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
|
|
self.gh_log = self.tmp / "gh.log"
|
|
os.environ["GH_LOG"] = str(self.gh_log)
|
|
self.addCleanup(os.environ.pop, "GH_LOG", None)
|
|
|
|
self.patch(REPO=self.repo, TASKS=self.tasks, TM_ROOT=self.repo,
|
|
LOCAL=local, WORKTREES=self.tmp / "worktrees",
|
|
AGENT_DIR=self.tmp / "agent", SESSIONS_DIR=self.tmp / "sessions",
|
|
GH_BIN=str(gh), COMMIT_MOVES=False, SYNC=False,
|
|
FETCH_TIMEOUT=10.0)
|
|
|
|
for registry in (state.AGENTS, state.BOARD_EVENTS, state.EXPECTED_MOVES,
|
|
github.PR_STATE, phases.SNAPSHOTS):
|
|
registry.clear()
|
|
self.addCleanup(registry.clear)
|
|
|
|
self.sent: list[dict] = []
|
|
self.addCleanup(setattr, state, "broadcast", state.broadcast)
|
|
state.broadcast = self.sent.append
|
|
|
|
self.write_cards()
|
|
|
|
def write_cards(self, *, depends: str | None = None,
|
|
listed: str = "- 31 — Stand up site/\n- 32 — Serve it\n") -> None:
|
|
self.write(ONE, card("31 — Stand up site/"))
|
|
self.write(TWO, card("32 — Serve it", depends=depends))
|
|
self.write(PHASE, card("40 — Ship the site", status="In Progress",
|
|
kind="Phase", cards=listed), "in-progress")
|
|
|
|
def write(self, filename: str, text: str, stage: str = "backlog") -> None:
|
|
(self.tasks / stage / filename).write_text(text, encoding="utf-8")
|
|
|
|
def patch(self, **values) -> None:
|
|
for attr, value in values.items():
|
|
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
|
setattr(config, attr, value)
|
|
|
|
def adapter_is(self, script: str) -> None:
|
|
self.adapter.write_text(script, encoding="utf-8")
|
|
self.adapter.chmod(self.adapter.stat().st_mode | stat.S_IEXEC)
|
|
|
|
# — driving the beat —
|
|
|
|
def start(self) -> dict:
|
|
snapshot = phases.start_phase(PHASE, "in-progress")
|
|
self.settle()
|
|
return snapshot
|
|
|
|
def advance(self) -> dict:
|
|
snapshots = phases.advance_all()
|
|
self.settle()
|
|
return snapshots.get(PHASE, {})
|
|
|
|
def settle(self) -> None:
|
|
"""Wait for any agent the pass launched to be fully reaped — the
|
|
reaper's last act is the agents broadcast, so that is the honest
|
|
signal that the card has landed wherever it lands."""
|
|
self.assertTrue(
|
|
wait_for(lambda: not any(r["status"] == "running"
|
|
for r in state.AGENTS.values())),
|
|
"an agent the phase launched never ended")
|
|
running = [r for r in state.AGENTS.values() if r.get("mode") == "work"]
|
|
if running:
|
|
self.assertTrue(wait_for(
|
|
lambda: self.sent.count({"type": "agents"}) >= len(running)),
|
|
"the reaper never announced the ending")
|
|
|
|
# — reading the world back —
|
|
|
|
def stage_of(self, filename: str) -> str | None:
|
|
for slug in config.STAGE_DIRS:
|
|
if (self.tasks / slug / filename).is_file():
|
|
return slug
|
|
return None
|
|
|
|
def text(self, filename: str) -> str:
|
|
return (self.tasks / self.stage_of(filename) / filename).read_text(
|
|
encoding="utf-8")
|
|
|
|
def log(self) -> list[str]:
|
|
return phases.log_entries(self.text(PHASE))
|
|
|
|
def branch_exists(self, branch: str) -> bool:
|
|
return git(self.repo, "rev-parse", "--verify", "--quiet",
|
|
branch).returncode == 0
|
|
|
|
def tip(self, ref: str) -> str:
|
|
return git(self.repo, "rev-parse", ref).stdout.strip()
|
|
|
|
def merges_on(self, branch: str) -> list[str]:
|
|
return git(self.repo, "log", "--merges", "--format=%s",
|
|
branch).stdout.strip().splitlines()
|
|
|
|
def summaries(self) -> list[str]:
|
|
return [e["summary"] for e in state.BOARD_EVENTS]
|
|
|
|
def gh_calls(self) -> list[list[str]]:
|
|
if not self.gh_log.is_file():
|
|
return []
|
|
return [json.loads(line) for line in
|
|
self.gh_log.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
|
|
class StartingAPhase(PhaseCase):
|
|
def test_the_branch_is_cut_from_main_and_member_one_starts_on_it(self):
|
|
main = self.tip("main")
|
|
|
|
self.start()
|
|
|
|
self.assertTrue(self.branch_exists(PHASE_BRANCH))
|
|
self.assertEqual(git(self.repo, "merge-base", PHASE_BRANCH,
|
|
"main").stdout.strip(), main,
|
|
"the phase branch starts at the newest main")
|
|
self.assertEqual(self.stage_of(ONE), "review",
|
|
"member one ran and reached review/")
|
|
self.assertEqual(git(self.repo, "merge-base", f"task/{ONE[:-3]}",
|
|
PHASE_BRANCH).stdout.strip(), main,
|
|
"member one branched from the phase's tip")
|
|
|
|
def test_the_phase_gets_a_worktree_of_its_own(self):
|
|
self.start()
|
|
worktree = config.WORKTREES / PHASE[:-3]
|
|
self.assertTrue(worktree.is_dir())
|
|
self.assertEqual(git(worktree, "branch", "--show-current").stdout.strip(),
|
|
PHASE_BRANCH)
|
|
|
|
def test_the_run_is_recorded_on_the_card(self):
|
|
self.start()
|
|
self.assertIn(f"run started on {PHASE_BRANCH}", self.log())
|
|
self.assertIn("31 started", self.log())
|
|
self.assertIn("## Phase log", self.text(PHASE))
|
|
|
|
def test_only_the_first_member_is_launched(self):
|
|
self.start()
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
"sequential: member two waits for member one")
|
|
self.assertEqual(self.stage_of(TWO), "backlog")
|
|
|
|
def test_an_empty_phase_refuses_rather_than_leaving_a_branch(self):
|
|
self.write(PHASE, card("40 — Ship the site", status="In Progress",
|
|
kind="Phase", cards=""), "in-progress")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
self.assertIn("lists no cards", str(caught.exception))
|
|
self.assertFalse(self.branch_exists(PHASE_BRANCH))
|
|
self.assertFalse((config.WORKTREES / PHASE[:-3]).exists())
|
|
|
|
def test_a_list_that_does_not_resolve_refuses(self):
|
|
"""Drift is an authoring mistake; a runner must not act it out."""
|
|
self.write_cards(listed="- 31 — Stand up site/\n- 99 — nothing here\n")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
self.assertIn("99", str(caught.exception))
|
|
self.assertFalse(self.branch_exists(PHASE_BRANCH))
|
|
|
|
def test_an_ordinary_card_is_not_a_phase(self):
|
|
self.write(PHASE, card("40 — Just a card", status="In Progress"),
|
|
"in-progress")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
self.assertIn("not a phase", str(caught.exception))
|
|
|
|
def test_a_phase_runs_from_in_progress_only(self):
|
|
self.write(PHASE, card("40 — Ship the site", kind="Phase",
|
|
cards="- 31 — Stand up site/\n"), "to-do")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
phases.start_phase(PHASE, "to-do")
|
|
|
|
self.assertIn("in-progress", str(caught.exception))
|
|
|
|
|
|
class AdvancingOnGreen(PhaseCase):
|
|
def test_member_twos_worktree_holds_member_ones_work(self):
|
|
"""The whole point of the branch: card two can see card one."""
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
worktree = config.WORKTREES / TWO[:-3]
|
|
self.assertTrue((worktree / "31.txt").is_file(),
|
|
"member two started from a tip that already had 31")
|
|
self.assertTrue((worktree / "32.txt").is_file())
|
|
|
|
def test_the_merge_lands_on_the_phase_branch_and_is_recorded(self):
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(
|
|
git(self.repo, "merge-base", "--is-ancestor",
|
|
f"task/{ONE[:-3]}", PHASE_BRANCH).returncode, 0)
|
|
self.assertEqual(self.merges_on(PHASE_BRANCH),
|
|
[f"phase: merge task/{ONE[:-3]} into {PHASE_BRANCH}"])
|
|
self.assertIn(f"31 merged into {PHASE_BRANCH}", self.log())
|
|
self.assertTrue(any("merged 31" in s for s in self.summaries()))
|
|
|
|
def test_the_phase_never_rebases_or_moves_a_members_branch(self):
|
|
self.start()
|
|
before = self.tip(f"task/{ONE[:-3]}")
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(self.tip(f"task/{ONE[:-3]}"), before,
|
|
"merging is additive: the member's branch is untouched")
|
|
|
|
def test_a_finished_phase_opens_one_pr_and_moves_to_review(self):
|
|
self.start()
|
|
self.advance() # merge 31, launch 32
|
|
self.advance() # merge 32, finish
|
|
|
|
self.assertEqual(self.stage_of(PHASE), "review")
|
|
self.assertEqual(len(self.merges_on(PHASE_BRANCH)), 2)
|
|
self.assertIn("every card merged — opening the phase PR", self.log())
|
|
|
|
def test_the_run_is_over_and_the_beat_leaves_it_alone(self):
|
|
self.start()
|
|
self.advance()
|
|
self.advance()
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
phases.advance_all()
|
|
|
|
self.assertEqual(self.summaries(), [],
|
|
"a phase card out of in-progress/ is not run any more")
|
|
|
|
|
|
class DependenciesGuard(PhaseCase):
|
|
def test_a_member_waiting_on_an_unfinished_card_is_not_launched(self):
|
|
self.write_cards(depends="99")
|
|
self.write("99-something-else.md", card("99 — Something else"))
|
|
self.start()
|
|
|
|
snapshot = self.advance()
|
|
|
|
self.assertEqual(snapshot["waitingOn"], ["99"])
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
self.assertEqual(self.stage_of(TWO), "backlog")
|
|
self.assertIsNone(snapshot["halted"], "a guard is a wait, not a halt")
|
|
|
|
def test_the_same_member_launches_once_the_dependency_is_done(self):
|
|
self.write_cards(depends="99")
|
|
self.write("99-something-else.md",
|
|
card("99 — Something else", status="Done"), "done")
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
|
|
def test_a_padded_number_is_the_same_card_everywhere(self):
|
|
"""`07`, `7` and `#007` are one card — in the list, in the log and
|
|
in a dependency alike, or none of them match."""
|
|
self.write("07-early.md", card("07 — An early card"))
|
|
self.write(TWO, card("32 — Serve it", depends="07"))
|
|
self.write(PHASE, card("40 — Ship the site", status="In Progress",
|
|
kind="Phase",
|
|
cards="- 07 — An early card\n- 32 — Serve it\n"),
|
|
"in-progress")
|
|
|
|
self.start()
|
|
self.advance()
|
|
|
|
self.assertIn("7 started", self.log())
|
|
self.assertTrue(self.branch_exists("task/07-early"))
|
|
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
"the dependency on 07 resolved against member 7")
|
|
|
|
def test_a_dependency_inside_the_phase_counts_as_finished_when_merged(self):
|
|
self.write_cards(depends="31")
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
"31 is merged into the phase branch — that is finished")
|
|
|
|
|
|
class HaltNeverSkip(PhaseCase):
|
|
def halted(self) -> str:
|
|
return phases._halt_reason(self.log()) or ""
|
|
|
|
def test_a_member_that_declines_halts_the_phase(self):
|
|
self.adapter_is(DECLINES)
|
|
self.start()
|
|
|
|
self.assertEqual(self.stage_of(ONE), "to-do", "the card walks back")
|
|
snapshot = self.advance()
|
|
|
|
self.assertIn("31:", self.halted())
|
|
self.assertIn("declined", self.halted())
|
|
self.assertIsNotNone(snapshot["halted"])
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
"no further member starts")
|
|
|
|
def test_a_run_that_dies_halts_the_phase(self):
|
|
self.adapter_is(DIES)
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(self.stage_of(ONE), "in-progress")
|
|
self.assertIn("without reaching review/", self.halted())
|
|
self.assertIn("API Error: 500", self.halted())
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
|
|
def test_a_clean_run_that_committed_nothing_halts_the_phase(self):
|
|
self.adapter_is(COMMITS_NOTHING)
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(self.stage_of(ONE), "in-progress")
|
|
self.assertIn("without reaching review/", self.halted())
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
|
|
def test_red_ci_halts_the_phase(self):
|
|
self.start()
|
|
github.PR_STATE[ONE] = {"verdict": "red", "ci": "fail", "url": PR_URL}
|
|
|
|
self.advance()
|
|
|
|
self.assertIn("CI is red", self.halted())
|
|
self.assertEqual(self.merges_on(PHASE_BRANCH), [],
|
|
"nothing is merged on a red member")
|
|
|
|
def test_checks_still_running_is_a_wait_not_a_halt(self):
|
|
self.start()
|
|
github.PR_STATE[ONE] = {"verdict": "pending", "ci": "running", "url": PR_URL}
|
|
|
|
snapshot = self.advance()
|
|
|
|
self.assertIsNone(snapshot["halted"])
|
|
self.assertEqual(self.merges_on(PHASE_BRANCH), [])
|
|
|
|
def test_a_halt_is_said_once_and_then_held(self):
|
|
self.adapter_is(DIES)
|
|
self.start()
|
|
self.advance()
|
|
state.BOARD_EVENTS.clear()
|
|
lines = len(self.log())
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(self.summaries(), [], "a halted phase is quiet")
|
|
self.assertEqual(len(self.log()), lines, "and writes nothing more")
|
|
|
|
def test_running_it_again_clears_the_halt_and_carries_on(self):
|
|
self.adapter_is(DIES)
|
|
self.start()
|
|
self.advance()
|
|
self.assertTrue(self.halted())
|
|
self.adapter_is(WORKS)
|
|
|
|
phases.start_phase(PHASE, "in-progress")
|
|
self.settle()
|
|
|
|
self.assertEqual(self.halted(), "",
|
|
"the person's decision to run it again clears the halt")
|
|
self.assertEqual(self.stage_of(ONE), "review")
|
|
|
|
|
|
class ConflictsAreRefused(PhaseCase):
|
|
"""The third place in this codebase that merges branches: it aborts
|
|
cleanly, leaves no half-merged branch, and names the collision."""
|
|
|
|
def test_a_member_that_collides_with_main_halts_and_leaves_no_mess(self):
|
|
self.adapter_is(COLLIDES)
|
|
self.start()
|
|
(self.repo / "code.txt").write_text("main moved on\n", encoding="utf-8")
|
|
git(self.repo, "commit", "-q", "-am", "main moved on")
|
|
|
|
self.advance()
|
|
|
|
self.assertIn("conflicts", phases._halt_reason(self.log()) or "")
|
|
self.assertIn("code.txt", phases._halt_reason(self.log()) or "")
|
|
worktree = config.WORKTREES / PHASE[:-3]
|
|
merge_head = git(worktree, "rev-parse", "--git-path", "MERGE_HEAD").stdout.strip()
|
|
self.assertFalse((worktree / merge_head).is_file() or
|
|
Path(merge_head).is_file(),
|
|
"no merge is left in progress")
|
|
self.assertEqual(git(worktree, "status", "--porcelain").stdout, "",
|
|
"the phase worktree is left clean")
|
|
self.assertEqual(
|
|
git(self.repo, "merge-base", "--is-ancestor",
|
|
f"task/{ONE[:-3]}", PHASE_BRANCH).returncode, 1,
|
|
"nothing half-merged: the member's branch did not land")
|
|
|
|
|
|
class TheBranchIsKeptFresh(PhaseCase):
|
|
def test_main_is_merged_into_the_phase_branch_on_the_beat(self):
|
|
self.start()
|
|
(self.repo / "later.txt").write_text("landed on main\n", encoding="utf-8")
|
|
git(self.repo, "add", "-A")
|
|
git(self.repo, "commit", "-q", "-m", "landed on main")
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(
|
|
git(self.repo, "merge-base", "--is-ancestor", "main",
|
|
PHASE_BRANCH).returncode, 0,
|
|
"a phase that runs for hours does not drift")
|
|
self.assertTrue(any("merged main into" in s for s in self.summaries()))
|
|
|
|
def test_an_unchanged_main_is_merged_nowhere(self):
|
|
self.start()
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual([s for s in self.summaries() if "merged main" in s], [])
|
|
|
|
|
|
class ARestartResumesByLooking(PhaseCase):
|
|
"""The runner holds no registry: git and the card carry the memory."""
|
|
|
|
def restart(self) -> None:
|
|
"""What a board restart costs the runner: everything it held in
|
|
memory about running agents and past passes."""
|
|
state.AGENTS.clear()
|
|
phases.SNAPSHOTS.clear()
|
|
|
|
def test_a_merged_member_is_not_merged_twice(self):
|
|
self.start()
|
|
self.advance() # merge 31, launch 32
|
|
self.restart()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(len([m for m in self.merges_on(PHASE_BRANCH)
|
|
if ONE[:-3] in m]), 1)
|
|
self.assertEqual(len([e for e in self.log() if e.startswith("31 merged")]), 1)
|
|
|
|
def test_a_started_member_is_not_started_twice(self):
|
|
"""The log is what tells "already run, and it ended badly" from
|
|
"not reached yet" — without it a restart would relaunch a dead run."""
|
|
self.adapter_is(DECLINES)
|
|
self.start()
|
|
self.restart()
|
|
|
|
self.advance()
|
|
|
|
self.assertEqual(len([e for e in self.log() if e == "31 started"]), 1)
|
|
self.assertIn("31:", phases._halt_reason(self.log()) or "")
|
|
|
|
def test_a_run_lost_to_a_restart_halts_rather_than_relaunching(self):
|
|
self.start()
|
|
# put the card back where a run that never finished would leave it
|
|
taskfiles.move_task(ONE, "review", "in-progress", actor="tester")
|
|
self.restart()
|
|
|
|
self.advance()
|
|
|
|
self.assertIn("without reaching review/", phases._halt_reason(self.log()) or "")
|
|
|
|
|
|
class HandMovesDoNotDoubleLaunch(PhaseCase):
|
|
def test_a_card_moved_by_hand_under_a_running_agent_is_left_alone(self):
|
|
self.start()
|
|
state.AGENTS.clear()
|
|
state.AGENTS["live"] = {"id": "live", "task": TWO, "status": "running",
|
|
"started": time.time(), "mode": "work"}
|
|
taskfiles.move_task(TWO, "backlog", "review", actor="you")
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
snapshot = phases.advance_all()[PHASE]
|
|
|
|
self.assertEqual([m["state"] for m in snapshot["members"] if m["file"] == TWO],
|
|
["running"], "an agent is on it — the phase waits")
|
|
self.assertEqual(len(state.AGENTS), 1, "no second launch on the same task")
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"))
|
|
|
|
def test_a_member_finished_before_the_phase_reached_it_needs_no_run(self):
|
|
self.write(TWO, card("32 — Serve it", status="Done"), "done")
|
|
(self.tasks / "backlog" / TWO).unlink()
|
|
self.start()
|
|
|
|
self.advance()
|
|
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
"a card with no branch that is already done is finished")
|
|
self.assertEqual(self.stage_of(PHASE), "review")
|
|
|
|
|
|
class AnUnwritableLogHalts(PhaseCase):
|
|
"""The log is the durable memory a restart reads; a launch or a merge
|
|
with no line behind it is exactly what a restarted board would repeat.
|
|
So a log that will not take the line halts before the action it was
|
|
meant to precede."""
|
|
|
|
def test_a_launch_never_outruns_its_record(self):
|
|
real = phases._write_log
|
|
self.addCleanup(setattr, phases, "_write_log", real)
|
|
# the one line that will not write is the member-started record
|
|
phases._write_log = lambda phase, entry: (
|
|
False if entry == "31 started" else real(phase, entry))
|
|
|
|
self.start()
|
|
|
|
self.assertEqual(
|
|
[r for r in state.AGENTS.values() if r["task"] == ONE], [],
|
|
"the member was never launched")
|
|
self.assertNotIn("31 started", self.log())
|
|
self.assertTrue(any("halted" in s for s in self.summaries()),
|
|
"the phase halts instead of launching blind")
|
|
|
|
|
|
class OneBoardRunsIt(PhaseCase):
|
|
"""State syncs; reactions don't — the assignee is where "who runs it"
|
|
is written down, and a replica advances nothing."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.patch(COMMIT_MOVES=True)
|
|
|
|
def test_the_board_that_holds_the_card_runs_it(self):
|
|
self.start()
|
|
|
|
self.assertIn("**Assignee:** tester", self.text(PHASE))
|
|
self.assertEqual(self.stage_of(ONE), "review")
|
|
|
|
def test_someone_elses_phase_advances_nowhere(self):
|
|
self.write(PHASE, card("40 — Ship the site", status="In Progress",
|
|
kind="Phase",
|
|
cards="- 31 — Stand up site/\n- 32 — Serve it\n"
|
|
).replace("**Priority:** High",
|
|
"**Priority:** High\n**Assignee:** elena"),
|
|
"in-progress")
|
|
git(self.repo, "checkout", "-q", "-b", "phase/40-ship-the-site")
|
|
git(self.repo, "checkout", "-q", "main")
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
snapshot = phases.advance_all()[PHASE]
|
|
|
|
self.assertEqual(len(state.AGENTS), 0, "a replica launches nothing")
|
|
self.assertEqual(self.summaries(), [])
|
|
self.assertEqual([m["state"] for m in snapshot["members"]],
|
|
["pending", "pending"], "but it still renders the phase")
|
|
|
|
def test_a_board_with_no_git_identity_is_not_gated_out(self):
|
|
"""Team mode with no local git name is the one case that does not
|
|
gate: `claim_for_launch` cannot write an assignee there and so
|
|
cannot refuse a launch, and the beat must match it — else a phase
|
|
starts (branch cut, run recorded) and then advances nowhere."""
|
|
self.addCleanup(setattr, taskfiles, "actor_name", taskfiles.actor_name)
|
|
taskfiles.actor_name = lambda: ""
|
|
|
|
self.assertTrue(phases._mine({"file": PHASE, "assignee": "elena"}))
|
|
|
|
def test_a_named_board_that_is_not_the_assignee_is_gated_out(self):
|
|
self.addCleanup(setattr, taskfiles, "actor_name", taskfiles.actor_name)
|
|
taskfiles.actor_name = lambda: "ronald"
|
|
|
|
self.assertFalse(phases._mine({"file": PHASE, "assignee": "elena"}))
|
|
|
|
def test_starting_someone_elses_phase_refuses_and_names_them(self):
|
|
self.write(PHASE, card("40 — Ship the site", status="In Progress",
|
|
kind="Phase",
|
|
cards="- 31 — Stand up site/\n"
|
|
).replace("**Priority:** High",
|
|
"**Priority:** High\n**Assignee:** elena"),
|
|
"in-progress")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
self.assertIn("elena holds", str(caught.exception))
|
|
self.assertFalse(self.branch_exists(PHASE_BRANCH))
|
|
|
|
|
|
class ThePhasePR(PhaseCase):
|
|
"""From review/ onwards a phase card is an ordinary card — which means
|
|
its branch has to be the one the apparatus finds."""
|
|
|
|
REMOTE = True
|
|
|
|
def test_the_pr_goes_from_the_phase_branch_into_main(self):
|
|
self.start()
|
|
self.advance()
|
|
self.advance()
|
|
|
|
create = [c for c in self.gh_calls() if c[:2] == ["pr", "create"]]
|
|
self.assertEqual(len(create), 1, "one PR at the end, not one per card")
|
|
self.assertIn("--head", create[0])
|
|
self.assertEqual(create[0][create[0].index("--head") + 1], PHASE_BRANCH)
|
|
self.assertEqual(create[0][create[0].index("--base") + 1], "main")
|
|
self.assertIn(f"**PR:** {PR_URL}", self.text(PHASE))
|
|
self.assertEqual(self.stage_of(PHASE), "review")
|
|
|
|
def test_the_body_is_the_member_list(self):
|
|
self.start()
|
|
self.advance()
|
|
self.advance()
|
|
|
|
create = [c for c in self.gh_calls() if c[:2] == ["pr", "create"]][0]
|
|
body = create[create.index("--body") + 1]
|
|
self.assertIn("31 — 31 — Stand up site/", body)
|
|
self.assertIn("32 — 32 — Serve it", body)
|
|
|
|
def test_a_members_own_pr_is_based_on_the_phase_branch(self):
|
|
"""A member's branch was cut from the phase's, so main is not a base
|
|
whose diff means anything — and a PR into main would invite exactly
|
|
the merge this whole design refuses to make."""
|
|
self.start()
|
|
|
|
github.maybe_open_pr(ONE)
|
|
|
|
create = [c for c in self.gh_calls() if c[:2] == ["pr", "create"]]
|
|
self.assertEqual(create[0][create[0].index("--base") + 1], PHASE_BRANCH)
|
|
self.assertEqual(create[0][create[0].index("--head") + 1], f"task/{ONE[:-3]}")
|
|
|
|
def test_the_phase_branch_is_published_so_the_base_exists(self):
|
|
self.start()
|
|
self.assertEqual(
|
|
git(self.repo, "rev-parse", f"origin/{PHASE_BRANCH}").returncode, 0,
|
|
"a member's PR needs its base on the remote")
|
|
|
|
def test_a_member_pr_bases_on_a_phase_branch_only_the_remote_carries(self):
|
|
"""A board that did not run the phase knows the phase branch only
|
|
through the remote — sync fetches origin/main and nothing else. That
|
|
branch is still the base a member's PR opens against, never main."""
|
|
git(self.repo, "push", "-q", "origin", f"main:refs/heads/{PHASE_BRANCH}")
|
|
|
|
self.assertFalse(self.branch_exists(PHASE_BRANCH), "no local phase branch")
|
|
self.assertEqual(github._pr_base({"phase": {"file": PHASE}}), PHASE_BRANCH)
|
|
|
|
|
|
class TheCardsBranchIsFound(PhaseCase):
|
|
def test_a_phase_card_wears_its_own_branch(self):
|
|
self.start()
|
|
self.assertEqual(github.branch_of(PHASE), PHASE_BRANCH)
|
|
self.assertEqual(github.branch_of(ONE), f"task/{ONE[:-3]}")
|
|
self.assertIn(PHASE[:-3], github.task_branches())
|
|
self.assertIn(ONE[:-3], github.task_branches())
|
|
|
|
def test_a_card_with_no_branch_at_all_names_the_task_branch(self):
|
|
self.assertEqual(github.branch_of(PHASE), f"task/{PHASE[:-3]}")
|
|
|
|
|
|
class TheBranchPointOfAMember(PhaseCase):
|
|
def test_a_member_of_a_running_phase_branches_from_the_phase(self):
|
|
self.start()
|
|
point, note = agents.phase_branch_point(TWO)
|
|
self.assertEqual(point, PHASE_BRANCH)
|
|
self.assertIn("the phase's own branch", note)
|
|
|
|
def test_a_member_of_a_phase_that_has_not_started_branches_as_usual(self):
|
|
self.assertEqual(agents.phase_branch_point(TWO), (None, None))
|
|
|
|
def test_a_card_in_no_phase_branches_as_usual(self):
|
|
self.write("77-alone.md", card("77 — On its own"))
|
|
self.start()
|
|
self.assertEqual(agents.phase_branch_point("77-alone.md"), (None, None))
|
|
|
|
def test_the_ticker_names_the_branch_point(self):
|
|
self.start()
|
|
self.advance()
|
|
self.assertTrue(any("the phase's own branch" in s for s in self.summaries()),
|
|
"an unusual branch point is always narrated")
|
|
|
|
|
|
class ThePhaseLog(unittest.TestCase):
|
|
"""The record on the card, read back — the only thing that can tell an
|
|
already-run member from one the phase has not reached."""
|
|
|
|
def entries(self, *lines: str) -> list[str]:
|
|
body = "\n".join(f"- 2026-08-01 09:0{i} · {line}"
|
|
for i, line in enumerate(lines))
|
|
return phases.log_entries(f"# 40\n\n## Phase log\n\n{body}\n")
|
|
|
|
def test_entries_are_read_in_order(self):
|
|
self.assertEqual(self.entries("run started on phase/40-x", "31 started"),
|
|
["run started on phase/40-x", "31 started"])
|
|
|
|
def test_a_card_with_no_log_has_no_entries(self):
|
|
self.assertEqual(phases.log_entries("# 40\n\n**Status:** In Progress\n"), [])
|
|
|
|
def test_the_section_ends_where_the_next_heading_begins(self):
|
|
text = ("# 40\n\n## Phase log\n\n- 2026-08-01 09:00 · 31 started\n\n"
|
|
"## Work report\n\n- 2026-08-01 09:01 · 32 started\n")
|
|
self.assertEqual(phases.log_entries(text), ["31 started"])
|
|
|
|
def test_started_members_are_the_ones_it_launched(self):
|
|
entries = self.entries("run started on phase/40-x", "31 started",
|
|
"31 merged into phase/40-x", "32 started")
|
|
self.assertEqual(phases._started(entries), {"31", "32"})
|
|
|
|
def test_a_halt_holds_until_a_run_clears_it(self):
|
|
entries = self.entries("31 started", "halted at 31 — its CI is red")
|
|
self.assertEqual(phases._halt_reason(entries), "31: its CI is red")
|
|
|
|
def test_a_later_run_clears_the_halt(self):
|
|
entries = self.entries("halted at 31 — its CI is red",
|
|
"run started on phase/40-x")
|
|
self.assertIsNone(phases._halt_reason(entries))
|
|
|
|
def test_a_phase_that_never_halted_is_not_halted(self):
|
|
self.assertIsNone(phases._halt_reason(
|
|
self.entries("run started on phase/40-x", "31 started")))
|
|
|
|
|
|
class AppendingToASection(unittest.TestCase):
|
|
"""taskfiles' third door: a running record that stays in one place."""
|
|
|
|
def setUp(self):
|
|
tmp = Path(tempfile.mkdtemp(prefix="bench-section-")).resolve()
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
|
self.tasks = tmp / "tasks"
|
|
(self.tasks / "in-progress").mkdir(parents=True)
|
|
self.addCleanup(setattr, config, "TASKS", config.TASKS)
|
|
config.TASKS = self.tasks
|
|
self.addCleanup(setattr, config, "COMMIT_MOVES", config.COMMIT_MOVES)
|
|
config.COMMIT_MOVES = False
|
|
self.path = self.tasks / "in-progress" / PHASE
|
|
self.path.write_text(card("40 — Ship the site", status="In Progress",
|
|
kind="Phase", cards="- 31 — one\n"),
|
|
encoding="utf-8")
|
|
|
|
def append(self, line: str) -> None:
|
|
taskfiles.append_to_section(PHASE, "in-progress", "Phase log", line, "log")
|
|
|
|
def test_the_first_line_creates_the_section(self):
|
|
self.append("- one")
|
|
self.assertIn("## Phase log\n\n- one\n", self.path.read_text())
|
|
|
|
def test_later_lines_join_it(self):
|
|
self.append("- one")
|
|
self.append("- two")
|
|
text = self.path.read_text()
|
|
self.assertIn("## Phase log\n\n- one\n- two\n", text)
|
|
self.assertEqual(text.count("## Phase log"), 1)
|
|
|
|
def test_a_section_appended_after_it_does_not_break_the_record(self):
|
|
self.append("- one")
|
|
taskfiles.append_to_task(PHASE, "in-progress",
|
|
"\n\n---\n\n## Work report\n\nsomething\n", "report")
|
|
self.append("- two")
|
|
text = self.path.read_text()
|
|
self.assertLess(text.index("- two"), text.index("## Work report"),
|
|
"the log stays in one place a person can read")
|
|
self.assertEqual(phases.log_entries(text), []) # not stamped lines
|
|
|
|
def test_the_cards_section_is_left_intact(self):
|
|
self.append("- one")
|
|
task = taskfiles.read_task(self.path, "in-progress")
|
|
self.assertEqual(task["cards"], ["31"])
|
|
self.assertEqual(task["phaseDrift"], [])
|
|
|
|
|
|
class TheDocumentedPromise(unittest.TestCase):
|
|
"""AGENTS.md quotes "the board never merges" as an absolute. After this
|
|
card it has to say what it always meant."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
|
|
|
def test_the_promise_is_about_main(self):
|
|
self.assertIn("the board never merges into `main`", self.doc)
|
|
self.assertNotIn("Merging remains yours — the board never merges.", self.doc)
|
|
|
|
def test_the_module_map_names_the_runner(self):
|
|
self.assertIn("phases.py", self.doc)
|
|
|
|
def test_the_runner_is_described(self):
|
|
self.assertIn("phase/<task-stem>", self.doc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|