Files
bench/tests/test_phase_watch.py
T
istosandClaude Opus 5 a3388ed2ea The Phases view: a swimlane each
Card 56 took a phase's members off the Board; this is where they went. A fourth view beside Board, Sessions and Focus, holding one lane per phase card — every phase there is, not only the running ones, because a phase whose cards are all merged and which is waiting on its own PR still owns them.

A lane is a head, five stage columns and the phase's log. The head carries the phase, its progress, the member in flight and its own controls — hold, the phase branch, the phase card — and nothing that ends a phase, which stays a board move on the phase card. Four columns are the board's stages; the fifth is 'Merged in', not done/, because a member merged into the phase branch is finished as far as the phase is concerned and is not in main yet. Which members the branch holds is the runner's last pass while a phase runs, and the phase log once there are no more passes, so a phase in review/ still draws the work where it landed. The cards are the cards: cardFor() builds them here exactly as it builds them for the Board, agent line, chips, position-in-the-run chip and hover actions intact. The phase log sits under the lane, the runner's decisions in the order it made them, read off the card's own '## Phase log' — the only thing that can tell "not reached yet" from "started and ended badly".

A halted lane says so at the top of itself, names the card it stopped on, and offers run again beneath the reason. That action left the Board's phase card for the same reason: clearing a halt should mean having read what caused it, so the card offers a way into the view instead, and hold, which needs nothing read. The crossing the other way is the switcher, which carries how many phases are running and an alarm mark while one has halted — on every view, alongside the toast and the ticker line that already fire.

Covered by tests/test_phases_view.py: collect() reads a throwaway tasks/ tree and the page's own lane rules run over that reading in node (ordering, placement, the last column, the log, the all-merged phase in review/), plus source-level wiring invariants for the DOM half. test_phase_watch, test_card_actions and test_scroll_kept updated where this moves what they pin. Full suite green: 939 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:55:17 +02:00

608 lines
25 KiB
Python

"""The board shows a phase running, and shows it stopping (task 50).
Card 49 built the runner; this one is the interface it does not inherit
from being a card — an action to start and stop it, a header chip while it
runs, and the same chip in `--alarm` when it halts.
Two halves, tested the way each can be: the runner's own half (a hold that
records itself, a run-state read back off the phase log, the toast a halt
fires) runs for real on a real repo through `tests.test_phase_runs`'
harness; the page's half is source-level invariants over board.html, with
its pure functions lifted out and run under node — board.html has no test
runner, and a chip that quietly shows one of two running phases is exactly
the kind of thing that would otherwise reach a person at 2am.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
import config # noqa: E402
import phases # noqa: E402
import state # noqa: E402
from tests.test_phase_runs import (DIES, ONE, PHASE, PHASE_BRANCH, TWO, # noqa: E402
PhaseCase, card, wait_for)
BOARD = REPO / "manager" / "core" / "board.html"
NODE = shutil.which("node")
# An agent that is still working when the hold arrives.
SLEEPS = """#!/usr/bin/env python3
import time
time.sleep(60)
"""
class HoldingAPhase(PhaseCase):
"""`‖ hold` means here what it means everywhere else: the work stops
and nothing is lost."""
def running_agents(self) -> list[dict]:
return [r for r in state.AGENTS.values() if r["status"] == "running"]
def test_the_hold_is_recorded_where_the_beat_reads_it(self):
self.start()
snapshot = phases.stop_phase(PHASE, "in-progress")
self.assertTrue(any(e.startswith("stopped — held by") for e in self.log()))
self.assertFalse(snapshot["running"])
self.assertTrue(snapshot["stopped"])
self.assertTrue(any("held by" in s for s in self.summaries()))
def test_the_beat_stands_down_afterwards(self):
self.start()
phases.stop_phase(PHASE, "in-progress")
state.BOARD_EVENTS.clear()
snapshot = self.advance()
self.assertEqual(self.summaries(), [], "a held phase is quiet")
self.assertEqual(self.merges_on(PHASE_BRANCH), [],
"and merges nothing more")
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
"and launches nothing more")
self.assertTrue(snapshot["stopped"])
def test_the_phase_branch_and_what_landed_on_it_are_untouched(self):
self.start()
self.advance() # 31 merges, 32 runs
merges, tip = self.merges_on(PHASE_BRANCH), self.tip(PHASE_BRANCH)
phases.stop_phase(PHASE, "in-progress")
self.assertEqual(self.merges_on(PHASE_BRANCH), merges)
self.assertEqual(self.tip(PHASE_BRANCH), tip, "a hold is not an undo")
self.assertTrue(self.branch_exists(f"task/{ONE[:-3]}"))
self.assertTrue((config.WORKTREES / PHASE[:-3]).is_dir())
def test_the_member_in_flight_is_held_with_it(self):
"""A phase that said it had stopped while the agent it launched
kept working would be lying about the one thing it was asked."""
self.adapter_is(SLEEPS)
phases.start_phase(PHASE, "in-progress")
self.assertTrue(wait_for(lambda: any(r["task"] == ONE for r in
self.running_agents())),
"the phase never launched its first member")
phases.stop_phase(PHASE, "in-progress")
self.assertTrue(wait_for(lambda: not self.running_agents()),
"the member's agent was left running")
self.assertTrue(any(ONE in s and "stopped with it" in s
for s in self.summaries()),
"and the ticker says which cards stopped with it")
def test_running_it_again_after_a_hold_carries_on(self):
self.start()
phases.stop_phase(PHASE, "in-progress")
phases.start_phase(PHASE, "in-progress")
self.settle()
self.assertIn(f"31 merged into {PHASE_BRANCH}", self.log())
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
"the next member started")
def test_a_halted_phase_can_be_held_rather_than_run_again(self):
"""The halt holds until the phase is resumed *or* stopped: someone
who has read it and does not want to carry on says so here."""
self.adapter_is(DIES)
self.start()
self.advance()
self.assertIsNotNone(self.advance()["halted"])
snapshot = phases.stop_phase(PHASE, "in-progress")
self.assertIsNone(snapshot["halted"], "the alarm is settled")
self.assertTrue(snapshot["stopped"])
def test_holding_a_phase_that_never_ran_refuses(self):
with self.assertRaises(ValueError) as caught:
phases.stop_phase(PHASE, "in-progress")
self.assertIn("not running", str(caught.exception))
def test_holding_it_twice_refuses(self):
self.start()
phases.stop_phase(PHASE, "in-progress")
with self.assertRaises(ValueError) as caught:
phases.stop_phase(PHASE, "in-progress")
self.assertIn("not running", str(caught.exception))
def test_an_ordinary_card_is_not_held_as_a_phase(self):
self.write(ONE, card("31 — Stand up site/", status="In Progress"),
"in-progress")
(self.tasks / "backlog" / ONE).unlink()
with self.assertRaises(ValueError) as caught:
phases.stop_phase(ONE, "in-progress")
self.assertIn("not a phase", str(caught.exception))
class WhatTheChipIsToldFrom(PhaseCase):
"""The snapshot the API carries has to answer the chip's questions —
which phase, how far, on which card, and what stopped it — without the
page inferring any of them."""
def test_a_running_phase_names_itself_and_where_it_is(self):
self.adapter_is(SLEEPS) # so the member is still in flight
phases.start_phase(PHASE, "in-progress")
self.addCleanup(phases.stop_phase, PHASE, "in-progress")
snapshot = phases.public_state()[PHASE]
self.assertTrue(snapshot["running"])
self.assertIsNone(snapshot["halted"])
self.assertEqual(snapshot["title"], "40 — Ship the site")
self.assertEqual(snapshot["number"], "40")
self.assertEqual(snapshot["branch"], PHASE_BRANCH)
self.assertEqual([m["number"] for m in snapshot["members"]], ["31", "32"])
self.assertEqual([m["state"] for m in snapshot["members"]],
["running", "pending"])
def test_a_halt_arrives_in_its_parts(self):
self.adapter_is(DIES)
self.start()
snapshot = self.advance()
self.assertEqual(snapshot["haltedAt"], "31")
self.assertIn("without reaching review/", snapshot["haltedWhy"])
self.assertFalse(snapshot["running"])
# the one line the log's reader gets, still said the old way
self.assertTrue(snapshot["halted"].startswith("31: "))
def test_a_halt_is_a_toast_as_well_as_a_state_and_a_line(self):
"""Rare and actionable: the person who is not watching the ticker
is told once, the way a dead run tells them."""
self.adapter_is(DIES)
self.start()
self.sent.clear()
phases.advance_all() # the pass halts; it launches nothing
toasts = [m for m in self.sent if m.get("type") == "toast"]
self.assertEqual(len(toasts), 1, "said once, not once a beat")
self.assertTrue(toasts[0]["error"])
self.assertIn(PHASE, toasts[0]["message"])
self.assertIn("halted at 31", toasts[0]["message"])
def test_a_second_beat_repeats_neither_the_toast_nor_the_line(self):
self.adapter_is(DIES)
self.start()
self.advance()
self.sent.clear()
phases.advance_all()
self.assertEqual([m for m in self.sent if m.get("type") == "toast"], [])
def test_two_phases_are_two_snapshots(self):
"""Two phases could in principle run on one board — the API says so
rather than leaving the header to show one of them."""
self.write("41-second-phase.md",
card("41 — A second phase", status="In Progress", kind="Phase",
cards="- 77 — On its own\n"), "in-progress")
self.write("77-alone.md", card("77 — On its own"))
self.start()
phases.start_phase("41-second-phase.md", "in-progress")
self.settle()
live = phases.public_state()
self.assertEqual(sorted(live), [PHASE, "41-second-phase.md"])
self.assertTrue(all(p["running"] for p in live.values()))
def test_a_phase_out_of_in_progress_leaves_the_header(self):
self.start()
self.advance()
self.advance() # every member merged: the card moves to review/
self.advance() # the next beat, with nothing in in-progress/
self.assertEqual(self.stage_of(PHASE), "review")
self.assertEqual(phases.public_state(), {},
"nothing is in flight, so there is nothing to show")
class EveryAdvanceIsNarrated(PhaseCase):
"""A phase that advances silently is a phase nobody can debug
afterwards: what finished, what merged, what started."""
def test_the_ticker_names_all_three(self):
self.start()
state.BOARD_EVENTS.clear()
self.advance()
said = " | ".join(self.summaries())
self.assertIn("31 — 31 — Stand up site/ is green", said)
self.assertIn(f"merged 31 into {PHASE_BRANCH}", said)
self.assertIn("started 32", said)
def test_the_events_are_phase_events(self):
self.start()
mine = [e for e in state.BOARD_EVENTS if e["file"] == PHASE]
self.assertTrue(mine, "the phase narrated nothing at all")
self.assertEqual({e["kind"] for e in mine}, {"phase"},
"the runner's narration is its own kind of event")
class TheLogSaysWhereAPhaseIs(unittest.TestCase):
"""`run_state` is the whole of a restarted board's memory of a run, and
now of the header's too. The last line that says something wins."""
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 state_of(self, *lines: str) -> str:
return phases.run_state(self.entries(*lines))["state"]
def test_a_card_that_has_never_run_is_idle(self):
self.assertEqual(phases.run_state([])["state"], "idle")
def test_a_started_run_is_running(self):
self.assertEqual(self.state_of("run started on phase/40-x", "31 started"),
"running")
def test_a_halt_stops_it(self):
where = phases.run_state(self.entries("run started on phase/40-x",
"halted at 31 — its CI is red"))
self.assertEqual(where["state"], "halted")
self.assertEqual(where["at"], "31")
self.assertEqual(where["reason"], "its CI is red")
def test_a_hold_stops_it(self):
where = phases.run_state(self.entries("run started on phase/40-x",
"stopped — held by ronald"))
self.assertEqual(where["state"], "stopped")
self.assertEqual(where["reason"], "held by ronald")
def test_a_hold_after_a_halt_settles_it(self):
self.assertEqual(self.state_of("halted at 31 — its CI is red",
"stopped — held by ronald"), "stopped")
self.assertIsNone(phases._halt_reason(
self.entries("halted at 31 — its CI is red", "stopped — held by ronald")))
def test_running_it_again_clears_either(self):
self.assertEqual(self.state_of("stopped — held by ronald",
"run started on phase/40-x"), "running")
self.assertEqual(self.state_of("halted at 31 — its CI is red",
"run started on phase/40-x"), "running")
class TheHeaderChip(unittest.TestCase):
"""board.html has no test runner, so these are source-level invariants:
the chip exists, it is absent when there is nothing to say, it wears
the design system's colours and it is one chip per phase."""
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
def func(self, name: str) -> str:
match = re.search(rf"^function {name}\(.*?\n\}}\n", self.html,
re.M | re.S)
self.assertIsNotNone(match, f"board.html lost its {name}()")
return match.group(0)
def test_the_chip_has_a_home_beside_the_agents_chip(self):
header = re.search(r"<header>.*?</header>", self.html, re.S).group(0)
self.assertIn('id="phasechips"', header)
self.assertLess(header.index('id="phasechips"'), header.index('id="livechip"'),
"the phase chip sits with the header's other live chips")
def test_it_is_rendered_on_every_frame(self):
self.assertIn("renderPhases();", self.func("render"))
def test_nothing_running_is_no_chip_at_all(self):
body = self.func("renderPhases")
self.assertIn("el.hidden = !live.length", body,
"an empty chip or a placeholder is not the answer")
def test_only_a_run_or_a_halt_is_in_flight(self):
self.assertIn("p.running || p.halted", self.func("phasesInFlight"))
def test_a_halt_leads(self):
self.assertIn("sort(", self.func("phasesInFlight"))
self.assertIn("b.halted", self.func("phasesInFlight"))
def test_every_phase_in_flight_gets_its_own_chip(self):
body = self.func("renderPhases")
self.assertIn("live.map(", body,
"one chip per phase — never one of two, silently")
def test_the_colours_mean_what_they_mean(self):
body = self.func("renderPhases")
self.assertIn("dot live", body, "a run alive breathes in --accent")
self.assertIn("var(--alarm)", body, "a halt holds in --alarm")
def test_the_chip_opens_the_phase_card(self):
body = self.func("renderPhases")
self.assertIn("data-phasechip", body)
self.assertIn("showDetail(task)", body)
def test_the_page_still_parses(self):
scripts = re.findall(r"<script[^>]*>(.*?)</script>", self.html, re.S)
self.assertTrue(scripts, "board.html has no inline script")
if not NODE:
self.skipTest("node is needed to parse the page")
with tempfile.TemporaryDirectory() as tmp:
for index, script in enumerate(scripts):
source = Path(tmp) / f"page-{index}.js"
source.write_text(script, encoding="utf-8")
out = subprocess.run([NODE, "--check", str(source)],
capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
@unittest.skipUnless(NODE, "node is needed to run the page's own functions")
class WhatTheChipSays(unittest.TestCase):
"""`phaseChipDetail` is a pure function of the snapshot, so — as with
`md()` and `phaseLabel()` — it is lifted out of the page and run."""
@classmethod
def setUpClass(cls):
html = BOARD.read_text(encoding="utf-8")
source = ""
for name in ("phaseProgress", "clip", "phaseChipDetail", "phasesInFlight"):
match = re.search(rf"^function {name}\(.*?\n\}}\n", html, re.M | re.S)
assert match, f"board.html lost its {name}()"
source += match.group(0)
cls._dir = tempfile.TemporaryDirectory()
cls.js = Path(cls._dir.name) / "chip.js"
cls.js.write_text(
"const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));\n"
"const S = { state: { phases: input.phases || {} } };\n" + source +
"process.stdout.write(JSON.stringify(input.phase "
"? phaseChipDetail(input.phase) "
": phasesInFlight().map(p => p.file)));\n", encoding="utf-8")
@classmethod
def tearDownClass(cls):
cls._dir.cleanup()
def run_js(self, payload: dict):
out = subprocess.run([NODE, str(self.js)], input=json.dumps(payload),
capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
return json.loads(out.stdout)
def detail(self, **phase) -> str:
return self.run_js({"phase": phase})
def members(self, *states) -> list[dict]:
return [{"number": str(30 + i), "file": f"{30 + i}-c.md", "state": s}
for i, s in enumerate(states, start=1)]
def test_a_run_says_how_far_and_which_card(self):
"""Progress is what has landed on the phase branch, not where the
pointer is — `2/5 · on #33` says two different things, which is the
whole reason both are on the chip."""
self.assertEqual(
self.detail(running=True,
members=self.members("merged", "merged", "running",
"pending", "pending")),
"2/5 · on #33")
def test_a_member_waiting_on_its_checks_is_still_the_card_in_flight(self):
self.assertEqual(
self.detail(running=True, members=self.members("merged", "waiting")),
"1/2 · on #32")
def test_a_dependency_holding_the_phase_is_named(self):
self.assertEqual(
self.detail(running=True, waitingOn=["99"],
members=self.members("merged", "pending")),
"1/2 · waiting on #99")
def test_between_passes_the_progress_stands_alone(self):
self.assertEqual(
self.detail(running=True, members=self.members("merged", "pending")),
"1/2")
def test_a_halt_names_the_card_and_the_reason(self):
self.assertEqual(
self.detail(halted="35: it is not ready", haltedAt="35",
haltedWhy="it is not ready", members=self.members("merged")),
"halted at #35 — it is not ready")
def test_a_long_reason_is_clipped_for_the_header(self):
detail = self.detail(halted="x", haltedAt="35", members=self.members(),
haltedWhy="its run ended without reaching review/ — "
"API Error: 500 overloaded, the model said")
self.assertTrue(detail.endswith("…"), detail)
self.assertLessEqual(len(detail), 64, "a header chip is not the place "
"a reason gets to run long")
def test_a_halt_with_no_member_still_says_what_stopped(self):
self.assertEqual(
self.detail(halted="the run could not continue: no", members=[],
haltedWhy="the run could not continue: no"),
"halted — the run could not continue: no")
def test_a_quiet_board_has_nothing_in_flight(self):
self.assertEqual(self.run_js({"phases": {
"40-a.md": {"file": "40-a.md", "running": False, "halted": None},
}}), [])
def test_a_halted_phase_leads_the_running_one(self):
self.assertEqual(self.run_js({"phases": {
"40-a.md": {"file": "40-a.md", "running": True, "halted": None},
"41-b.md": {"file": "41-b.md", "running": False, "halted": "31: red"},
}}), ["41-b.md", "40-a.md"])
class TheCardsActions(unittest.TestCase):
"""▸ run phase sits in the slot ▸ start work has on every other card,
and ‖ hold replaces it while the phase runs — both on the one action
machine, so both arm before they fire."""
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
block = re.search(r"if \(task\.stage === 'in-progress' && task\.isPhase\)"
r" \{.*?\n \} else if \(task\.stage === 'in-progress'\)",
cls.html, re.S)
assert block, "board.html lost the phase card's actions"
cls.block = block.group(0)
def test_the_action_only_exists_on_an_in_progress_phase_card(self):
self.assertIn("task.stage === 'in-progress' && task.isPhase", self.html)
def test_running_a_phase_arms_like_every_other_launch(self):
self.assertIn("label: 'run phase'", self.block)
self.assertIn("confirm: 'run it?'", self.block)
self.assertIn("busy: 'starting…'", self.block)
def test_a_phase_someone_else_holds_is_taken_over_deliberately(self):
self.assertIn("label: 'take over'", self.block)
self.assertIn("takeover: true", self.block)
def test_hold_is_the_word_while_it_runs(self):
self.assertIn("ph.running) actions.push(hold)", self.block)
self.assertIn("label: 'hold'", self.block)
self.assertIn("confirm: 'hold it?'", self.block)
def test_a_halt_offers_the_way_out_and_the_way_to_the_reason(self):
"""A halt still holds until the phase is run again or held, and
holding is here. Running it again moved to the Phases view with
card 57: it sits under the reason the phase stopped, because
clearing a halt should mean having read what caused it. So this
card carries ⟶ phases instead — the way to that reading."""
self.assertIn("ph.halted) actions.push(room, hold)", self.block)
self.assertIn("setView('phases')", self.block)
self.assertNotIn("ph.halted) actions.push(start", self.block)
def test_the_slot_never_holds_three(self):
self.assertIn("else if (actions.length < 2) {", self.html,
"the relevance check gives way rather than stacking")
def test_both_go_through_the_api(self):
run = re.search(r"async function runPhase.*?\n\}", self.html, re.S).group(0)
hold = re.search(r"async function holdPhase.*?\n\}", self.html, re.S).group(0)
self.assertIn("'/api/phase/run'", run)
self.assertIn("'/api/phase/stop'", hold)
for body in (run, hold):
self.assertIn("return res.ok", body,
"fireAction can only unlock on error if it is told")
self.assertIn("toast(", body)
class ThePhaseCardsSheet(unittest.TestCase):
"""Opening a phase card answers "where is this up to" without a hunt
across five columns."""
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
cls.block = re.search(r"function phaseMembers\(t\) \{.*?\n\}\n",
cls.html, re.S).group(0)
def test_the_sheet_lists_the_members(self):
self.assertIn("phaseMembers(t) +", self.html,
"the list belongs in the drawer, above the card's text")
def test_only_a_phase_card_gets_one(self):
self.assertIn("if (!t.isPhase) return '';", self.block)
def test_run_order_is_the_order_it_lists(self):
self.assertIn("t.members.map((m, i)", self.block)
self.assertIn("${i + 1}", self.block)
def test_each_member_carries_its_stage(self):
self.assertIn("m.stage", self.block)
def test_the_runners_reading_is_shown_when_there_is_one(self):
self.assertIn("S.state.phases || {}", self.block)
self.assertIn("MEMBER_STATE", self.block)
for word in ("merged in", "working", "stopped here"):
self.assertIn(word, self.html)
def test_a_row_opens_that_card(self):
self.assertIn("data-member", self.block)
self.assertIn("findTask(row.dataset.member)", self.html)
class TheTickerReadsPhaseEvents(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
def test_a_phase_event_has_a_glyph_of_its_own(self):
glyphs = re.search(r"const GLYPHS = \{.*?\};", self.html, re.S).group(0)
self.assertIn("phase: '⟶'", glyphs)
def test_phase_events_are_in_the_moves_filter(self):
moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
self.assertIsNotNone(moves, "board.html lost its Moves filter")
self.assertIn("'phase'", moves.group(1),
"a phase's narration is board-level, like a move or a sync")
class TheApiHasAStop(unittest.TestCase):
def test_the_route_exists(self):
source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
self.assertIn('"/api/phase/stop"', source)
self.assertIn("phases.stop_phase", source)
class TheDocumentedPromise(unittest.TestCase):
"""What the board does is documented in AGENTS.md; an interface that is
not written down is one the next reader has to find by clicking."""
@classmethod
def setUpClass(cls):
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
def test_the_actions_are_named(self):
self.assertIn("▸ run phase", self.doc)
def test_the_chip_is_described(self):
self.assertIn("halted", self.doc)
self.assertIn("header", self.doc)
if __name__ == "__main__":
unittest.main()