A work agent refuses a phase card

`▸ run phase` guarded its own door and left the neighbour's open:
`/api/agent/start` accepted a phase card, cut `task/<stem>` and handed a
list of other cards to a work agent as a brief. It did that once, and the
agent implemented two cards at once in a worktree nobody was watching.

The refusal is a server rule, in `_validate` with the stage check — ahead
of the claim and well ahead of the worktree, so it costs nothing and
leaves nothing to clean up — and it names ▸ run phase rather than just
saying no. Which headless kinds a phase card may host is now decided kind
by kind where the guard lives: ▸ start work and ↻ act on PR refuse it
(both are work agents), while ◔ still true? and ◔ review PR are allowed —
the latter now told the phase's own branch, since its PR is from
`phase/<stem>` and `task/<stem>` was never cut. The guard is about
starting, so a card retyped under a running agent is left alone.

And the card says which state it is in: an `in-progress/` phase nobody has
started read exactly like one mid-run, the header chip being absent in
both cases. It now wears `not started` (or `held`) in the settled
register, with the line under it saying what ▸ run phase would do — never
the accent, the breathing mark or the caret, which mean work is happening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-02 13:32:08 +02:00
co-authored by Claude Opus 5
parent a5f05ea29d
commit ccffbec831
5 changed files with 532 additions and 15 deletions
+26
View File
@@ -378,6 +378,21 @@ there, and firing it is the deliberate reassignment. An unclaimed card
claims itself on launch. One agent per task at a time, and a work agent's
worktree must not already exist when it starts.
It also refuses a **phase card**, and the refusal names **▸ run phase** —
a phase card's body is a list of other cards, so a work agent handed one
implements the table of contents. The board's own page offers one action
or the other and never both, but a UI layer can be stale or bypassed, so
the rule lives where every other launch refusal lives: with the stage
check, ahead of the claim and well ahead of the worktree, costing nothing
and leaving nothing to clean up. Which headless kinds a phase card may
host is decided rather than left to omission: **▸ start work** and **↻ act
on PR** refuse it (both are work agents, and a phase's work belongs to its
members' cards), while the read-only pair — **◔ still true?** and **
review PR**, the latter told the phase's own branch — are allowed. The
guard is about *starting*: a card that gains `**Type:** Phase` while an
ordinary run is in flight is left alone, and that run ends as it would
have.
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
branch `task/<task-stem>` from the newest main it can see: with an
`origin` remote it fetches `origin/main` first (bounded by
@@ -951,6 +966,17 @@ and they are the phase's own interface:
watching is only trustworthy if its halt is impossible to miss, so it is
told three times at three altitudes, exactly as a dead run is.
**And the card itself says which state it is in.** The header chip only
appears while there is something to say, so it cannot tell "the phase has
not been started" from "there is no phase here at all" — which left an
`in-progress/` phase card nobody had run looking exactly like one mid-run.
So the card carries the distinction quietly, in the pill and the line
under it: `not started` (`▸ run phase` cuts its branch and starts the
first card), `held`, the accent and the member in flight while it runs,
and `halted` in `--alarm`. Only a run wears the working vocabulary — the
breathing mark, the accent border, the caret — because only a run is work
happening.
**And the card does not move while its work runs.** A phase card stands
for cards the Board no longer draws, so dragging it to another stage — or
onto the archive tray, which is a move like any other — while a member has
+76 -7
View File
@@ -148,13 +148,65 @@ def working_on(files: set[str]) -> list[dict]:
return [record for record in records if _alive(record)]
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None:
# ── what a phase card may host ─────────────────────────────────────────
#
# A phase card is a list of other cards. Handed to a work agent as a brief
# it reads as a table of contents, and the agent does what it is told —
# which is how one run once implemented two cards at once in a worktree
# nobody was watching. `▸ run phase` already guards its own door (a card
# that is not a phase is refused there); this is the other half of that
# gate, and it is decided kind by kind rather than left to omission:
#
# - **▸ start work** (`start_agent`) refuses. A phase is run with ▸ run
# phase, which works its list into a branch of its own; its members are
# worked on their own cards.
# - **↻ act on PR** (`start_pr_fix`) refuses. It is the same work agent
# with a push, and the phase's PR carries its members' commits — review
# feedback on it belongs on the member's own card, or on the phase
# branch by hand.
# - **◔ still true?** (`start_review`) is allowed. Read-only, no worktree:
# asking whether a phase is still worth running is a fair question, and
# the report is appended to the card like any other.
# - **◔ review PR** (`start_pr_review`) is allowed. Read-only, and the PR
# into `main` is the one thing the whole run exists to produce — the
# launch just has to name the phase's own branch rather than a
# `task/<stem>` that was never cut.
#
# It is about *starting*: a card that gains `**Type:** Phase` while an
# ordinary run is in flight is left alone, and the run ends as it would
# have.
PHASE_RUNS_WITH = ("a list of other cards, not a brief. Run it with ▸ run "
"phase, which cuts a branch of its own and works the list "
"into it; its members are worked on their own cards")
def is_phase_card(filename: str, stage: str) -> bool:
"""Is this card a phase card? `**Type:** Phase` is the whole of it —
the same reading `phases._phase_card` refuses a non-phase by, so the
two gates cannot disagree. A phase card whose list is empty or unwritten
is still a coordinator, and still no brief for a work agent."""
try:
return bool(read_task(config.TASKS / stage / filename, stage)["isPhase"])
except OSError:
return False
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None,
*, phase: str | None = None) -> None:
"""The refusals every launch shares, before anything exists to clean up.
`phase` is what a phase card should do instead — pass it and this kind
refuses one, naming that; omit it and the kind is one a phase card may
host. See the note above for which is which and why.
"""
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
if stage not in allowed:
raise ValueError(why or f"agents cannot start from {stage}/")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
if phase and is_phase_card(filename, stage):
raise ValueError(f"{filename} is a phase card — {phase}")
_assert_no_running_agent(filename)
@@ -304,9 +356,12 @@ def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None:
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.
# Moving a card to in-progress is the commitment; only then does work
# start — and a phase card is refused here, in the same breath and ahead
# of the claim, so the refusal costs nothing and leaves nothing behind.
_validate(filename, stage, {"in-progress"},
"work starts from in-progress/ — move the card there first")
"work starts from in-progress/ — move the card there first",
phase=PHASE_RUNS_WITH)
claim_for_launch(filename, stage, takeover)
stem = filename[:-3]
@@ -386,7 +441,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
def start_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that checks the task against the codebase."""
"""Fire a read-only agent that checks the task against the codebase.
Every stage, and a phase card too: no `phase=` here is the deliberate
answer, not an omission. Nothing is written but the report.
"""
_validate(filename, stage, config.STAGE_DIRS)
task = read_task(config.TASKS / stage / filename, stage)
@@ -593,14 +652,21 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
def start_pr_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that reviews the task's PR and posts the
verdict to GitHub as well as back to the board."""
verdict to GitHub as well as back to the board.
A phase card is allowed here, deliberately: the PR into `main` is what
the whole run exists to produce, and reading it writes nothing.
"""
_validate(filename, stage, {"review"},
"PR reviews run on cards in review/")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR yet — nothing to review")
branch = f"task/{filename[:-3]}"
# …but it must be told the branch its PR is actually from: the prompt
# asks GitHub for the diff by branch, and a phase's is its own.
branch = (phase_branch(filename) if task["isPhase"]
else f"task/{filename[:-3]}")
name = _pick_name(filename)
agent_id = f"review-pr-{filename[:-3]}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
@@ -632,7 +698,10 @@ def start_pr_fix(filename: str, stage: str) -> dict:
working in the task's existing worktree (recreated from the branch if it
was cleaned up), committing and pushing to update the PR."""
_validate(filename, stage, {"review"},
"acting on a PR happens from review/")
"acting on a PR happens from review/",
phase="↻ act on PR is a work agent, and a phase's PR carries "
"its members' commits — address the review on the member's "
"own card, or on the phase branch by hand")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR to act on")
+29 -6
View File
@@ -1347,8 +1347,14 @@ function phaseSummary(task) {
}
/* …and what it is doing right now: the member in flight while a run is on,
the halt while one is held. Null when the phase has no story yet — a
phase waiting in to-do/ is just a card. */
the halt while one is held — and the quiet case this exists for, that
nobody has started it. A phase card sitting in in-progress/ that has not
been run used to look exactly like one that is running: the header chip
is the only other place that difference is written, and it is absent both
when a phase has not started and when there is no phase at all. So the
card says it itself. `idle` is not work — it takes no accent, no
breathing mark and no caret, because nothing is happening. Null when the
phase has no story to tell here: one waiting in to-do/ is just a card. */
function phaseFlight(task) {
const snap = (S.state.phases || {})[task.file];
if (!snap) return null;
@@ -1356,7 +1362,14 @@ function phaseFlight(task) {
return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') +
' — ' + (snap.haltedWhy || snap.halted) };
}
if (!snap.running) return null;
if (!snap.running) {
if (task.stage !== 'in-progress') return null;
return snap.stopped
? { idle: true, pill: 'held',
line: 'held — ▸ run phase carries on from where it stopped' }
: { idle: true, pill: 'not started',
line: 'not started — ▸ run phase cuts its branch and starts the first card' };
}
const at = phaseProgress(snap);
if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file}${at.on.title}` };
if ((snap.waitingOn || []).length) {
@@ -1392,7 +1405,8 @@ function cardFor(task) {
// a running phase has an agent alive on a card this view no longer draws,
// so the phase card wears that state on its members' behalf
const flight = task.isPhase ? phaseFlight(task) : null;
const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad);
const working = !!(agent && agent.mode !== 'review')
|| !!(flight && !flight.bad && !flight.idle);
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
// the server holds this, not the tab that clicked: while merge & clean up
@@ -1419,6 +1433,15 @@ function cardFor(task) {
pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) };
tint = 'var(--alarm)';
}
if (flight && flight.idle) {
// the state the card could not tell from a run in flight: a phase in
// in-progress/ that nobody has started, or one held. It is the absence
// of work, so it wears the settled register — never the accent, which
// means an agent is alive.
pill = { text: flight.pill, tint: 'var(--idle)', bg: 'var(--sunken)',
title: flight.line };
tint = 'var(--idle)';
}
if (failure) {
// the newest thing that happened here, and the only actionable one:
// it outranks a PR verdict from before the run died
@@ -1610,7 +1633,7 @@ function cardFor(task) {
// the header chip carries, at the altitude a person is already reading
liveLine = `<div class="well${flight.bad ? ' bad' : ''}"><span class="lead">·</span>` +
`<span class="wbody">${esc(flight.line)}` +
`${flight.bad ? '' : '<span class="caret">▌</span>'}</span></div>`;
`${flight.bad || flight.idle ? '' : '<span class="caret">▌</span>'}</span></div>`;
}
// tool chips: destinations, not statuses — they live in the card's footer
@@ -2503,7 +2526,7 @@ function laneFor(task) {
// a run in flight, in the same well the cards use for the same fact
const well = !halted && flight
? `<div class="well"><span class="lead">·</span><span class="wbody">${esc(flight.line)}` +
`<span class="caret">▌</span></span></div>`
`${flight.idle ? '' : '<span class="caret">▌</span>'}</span></div>`
: '';
el.innerHTML = head + halt + well +
+385
View File
@@ -0,0 +1,385 @@
"""A work agent must refuse a phase card (task 54).
` run phase` guards its own door a card that is not a phase is refused
there. This is the other half of that gate, on the neighbour's door: a
phase card's body is a list of other cards, so a work agent handed one
implements the table of contents, which is exactly what happened the first
time a phase reached the board. The refusal is a server rule because the
page that offers one action or the other can be stale or bypassed.
Which headless kinds a phase card may host is *decided* here rather than
left to omission, so each of the four has a case saying which it is.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
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 phases # noqa: E402
import state # noqa: E402
from tests.test_phase_runs import (ONE, PHASE, PHASE_BRANCH, PhaseCase, # noqa: E402
card, git, wait_for)
BOARD = REPO / "manager" / "core" / "board.html"
NODE = shutil.which("node")
ALONE = "77-alone.md"
PR_URL = "https://github.com/acme/widget/pull/7"
# Reads, says something, writes nothing — what a read-only kind does.
REPORTS = """#!/usr/bin/env python3
print("RELEVANCE REVIEW: Still relevant")
"""
# Commits, then waits for a file beside the worktrees directory before
# exiting — a run held open for as long as a test needs it in flight.
BLOCKS = """#!/usr/bin/env python3
import os, subprocess, time
cwd = os.environ["AGENT_CWD"]
open(os.path.join(cwd, "work.txt"), "w").write("work\\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", "work"], check=True)
go = os.path.join(os.path.dirname(os.path.dirname(cwd)), "worktrees", "go")
for _ in range(1200):
if os.path.exists(go):
break
time.sleep(0.05)
print("WORK REPORT: the work is committed")
"""
class LaunchCase(PhaseCase):
"""PhaseCase's world — a real repo, a real adapter, a real phase card in
in-progress/ with the launches aimed at the card rather than the run."""
def only_in(self, filename: str, text: str, stage: str) -> None:
"""One card, one stage — a rewrite that moves it rather than
leaving the board reading the same number twice."""
for slug in config.STAGE_DIRS:
path = self.tasks / slug / filename
if path.is_file():
path.unlink()
self.write(filename, text, stage)
def ordinary(self, stage: str = "in-progress", **kw) -> None:
"""A card that is not a phase, in the stage a launch wants it in."""
self.only_in(ALONE, card("77 — On its own",
status=config.STAGE_LABELS[stage], **kw), stage)
def as_phase(self, filename: str, stage: str, listed: str | None = None) -> None:
"""The same card, now typed Phase — what a person writes when they
decide a card coordinates rather than builds."""
self.only_in(filename, card("77 — On its own", kind="Phase",
status=config.STAGE_LABELS[stage],
cards=listed), stage)
def with_pr(self, filename: str, title: str, stage: str, **kw) -> None:
text = card(title, status=config.STAGE_LABELS[stage], **kw)
self.only_in(filename, text.replace(
"**Priority:** High\n",
f"**Priority:** High\n**PR:** {PR_URL}\n"), stage)
def running(self) -> list[dict]:
return [r for r in state.AGENTS.values() if r["status"] == "running"]
def worktrees(self) -> list[str]:
return [line.split(" ", 1)[1] for line in
git(self.repo, "worktree", "list", "--porcelain").stdout.splitlines()
if line.startswith("worktree ")]
class TheWorkAgentRefusesAPhaseCard(LaunchCase):
def test_a_phase_card_is_refused_and_told_what_to_run_instead(self):
with self.assertRaises(ValueError) as caught:
agents.start_agent(PHASE, "in-progress")
self.assertIn("▸ run phase", str(caught.exception))
self.assertIn("phase card", str(caught.exception))
def test_the_refusal_leaves_nothing_behind(self):
"""Refused with `_validate`, so it costs nothing: no branch, no
worktree, no process, nothing for anyone to clean up."""
with self.assertRaises(ValueError):
agents.start_agent(PHASE, "in-progress")
self.assertFalse(self.branch_exists(f"task/{PHASE[:-3]}"))
self.assertFalse((config.WORKTREES / PHASE[:-3]).exists())
self.assertEqual(self.worktrees(), [str(self.repo)])
self.assertEqual(state.AGENTS, {}, "no run was ever recorded")
def test_it_refuses_ahead_of_the_claim(self):
"""The order the shape depends on: a refusal must not write the card
it refused. In team mode a launch claims an unheld card, so an
assignee appearing here would mean the guard ran too late."""
self.patch(COMMIT_MOVES=True)
with self.assertRaises(ValueError):
agents.start_agent(PHASE, "in-progress")
self.assertNotIn("**Assignee:**", self.text(PHASE))
def test_a_takeover_is_refused_just_the_same(self):
"""The deliberate second click reassigns a card; it does not make a
table of contents into a brief."""
self.patch(COMMIT_MOVES=True)
with self.assertRaises(ValueError) as caught:
agents.start_agent(PHASE, "in-progress", takeover=True)
self.assertIn("▸ run phase", str(caught.exception))
self.assertNotIn("**Assignee:**", self.text(PHASE))
def test_a_phase_card_with_no_list_yet_is_still_a_phase_card(self):
"""`**Type:** Phase` is the whole of the reading, the same one
`phases._phase_card` refuses a non-phase by. A list not written yet
is an authoring mistake to fix, not an invitation to build it."""
self.as_phase(ALONE, "in-progress")
with self.assertRaises(ValueError) as caught:
agents.start_agent(ALONE, "in-progress")
self.assertIn("▸ run phase", str(caught.exception))
def test_an_ordinary_card_starts_work_exactly_as_it_did(self):
self.ordinary()
agent = agents.start_agent(ALONE, "in-progress")
self.settle()
self.assertEqual(agent["branch"], f"task/{ALONE[:-3]}")
self.assertTrue(self.branch_exists(f"task/{ALONE[:-3]}"))
self.assertEqual(self.stage_of(ALONE), "review",
"the ordinary path is untouched: commits, then review/")
def test_the_two_gates_are_mirror_images(self):
"""The bug was an asymmetry, so the symmetry is the test: each door
refuses the card the other one is for, and says so."""
self.ordinary()
with self.assertRaises(ValueError) as work:
agents.start_agent(PHASE, "in-progress")
with self.assertRaises(ValueError) as run:
phases.start_phase(ALONE, "in-progress")
self.assertIn("phase card", str(work.exception))
self.assertIn("not a phase", str(run.exception))
self.assertEqual(state.AGENTS, {})
self.assertFalse(self.branch_exists(f"phase/{ALONE[:-3]}"))
class ARunInFlightIsLeftAlone(LaunchCase):
"""The guard is about starting. A card retyped under a running agent is
a person's edit, not a reason to break the run underneath it."""
def test_a_card_that_becomes_a_phase_mid_run_still_lands(self):
self.ordinary()
self.adapter_is(BLOCKS)
go = config.WORKTREES / "go"
agents.start_agent(ALONE, "in-progress")
self.assertTrue(
wait_for(lambda: (config.WORKTREES / ALONE[:-3] / "work.txt").is_file()),
"the agent never got as far as its commit")
# the edit, while the process is alive and the reaper has not run
self.as_phase(ALONE, "in-progress")
self.assertTrue(self.running(), "the run ended before the edit landed")
go.write_text("done\n", encoding="utf-8")
self.settle()
record = next(iter(state.AGENTS.values()))
self.assertEqual(record["rc"], 0)
self.assertEqual(record["status"], "done", "the run ended as it would have")
self.assertEqual(self.stage_of(ALONE), "review",
"and the card moved on, phase line or not")
def tearDown(self):
# never leave a blocked adapter behind if an assertion jumped the wire
(config.WORKTREES / "go").parent.mkdir(parents=True, exist_ok=True)
(config.WORKTREES / "go").write_text("done\n", encoding="utf-8")
wait_for(lambda: not self.running(), timeout=10)
class WhichKindsAPhaseCardMayHost(LaunchCase):
"""Four kinds, four deliberate answers — the two that would work on the
card refuse it, the two that only read it are allowed."""
def test_still_true_is_allowed_on_a_phase_card(self):
self.adapter_is(REPORTS)
agent = agents.start_review(PHASE, "in-progress")
self.assertTrue(wait_for(lambda: not self.running()))
self.assertEqual(agent["mode"], "review")
self.assertIsNone(agent["worktree"], "read-only: nothing is cut for it")
self.assertIn("Relevance review", self.text(PHASE))
def test_reviewing_the_phase_pr_is_allowed_and_names_the_phase_branch(self):
"""The prompt asks GitHub for the diff by branch, and a phase's PR
is from its own branch `task/<stem>` was never cut."""
self.adapter_is(REPORTS)
self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase",
cards="- 31 — Stand up site/\n")
agent = agents.start_pr_review(PHASE, "review")
self.assertTrue(wait_for(lambda: not self.running()))
self.assertEqual(agent["branch"], PHASE_BRANCH)
def test_an_ordinary_cards_pr_review_still_names_its_task_branch(self):
self.adapter_is(REPORTS)
self.with_pr(ONE, "31 — Stand up site/", "review")
agent = agents.start_pr_review(ONE, "review")
self.assertTrue(wait_for(lambda: not self.running()))
self.assertEqual(agent["branch"], f"task/{ONE[:-3]}")
def test_acting_on_a_phase_pr_is_refused_and_says_where_it_belongs(self):
"""↻ act on PR is the same work agent with a push, and a phase's PR
carries its members' commits."""
self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase",
cards="- 31 — Stand up site/\n")
with self.assertRaises(ValueError) as caught:
agents.start_pr_fix(PHASE, "review")
self.assertIn("phase card", str(caught.exception))
self.assertIn("member", str(caught.exception))
self.assertFalse((config.WORKTREES / PHASE[:-3]).exists())
self.assertEqual(state.AGENTS, {})
@unittest.skipUnless(NODE, "node is needed to run the page's own rules")
class TheCardSaysWhichStateItIsIn(unittest.TestCase):
"""A phase card in in-progress/ that nobody has started used to look
exactly like one mid-run. These run the page's own function over the
snapshots the server sends."""
PARTS = (r"function phaseProgress\(p\) \{.*?\n\}",
r"function phaseFlight\(task\) \{.*?\n\}")
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
parts = []
for pattern in cls.PARTS:
match = re.search(pattern, cls.html, re.S)
if match is None:
raise AssertionError(f"board.html no longer defines {pattern!r}")
parts.append(match.group(0))
cls.src = "\n".join(parts)
cls.card_fn = re.search(r"function cardFor\(task\) \{.*?\n\}",
cls.html, re.S).group(0)
def flight(self, snapshot: dict | None, stage: str = "in-progress") -> object:
task = {"file": PHASE, "stage": stage, "isPhase": True}
script = (self.src + "\nvar S = { state: { phases: "
+ json.dumps({PHASE: snapshot} if snapshot else {})
+ " } };\nconsole.log(JSON.stringify(phaseFlight("
+ json.dumps(task) + ")));\n")
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
return json.loads(out.stdout)
def snapshot(self, **extra) -> dict:
return {"file": PHASE, "members": [], "halted": None, "haltedAt": None,
"haltedWhy": None, "running": False, "stopped": False,
"started": False, **extra}
def test_a_phase_nobody_has_started_says_so(self):
flight = self.flight(self.snapshot())
self.assertTrue(flight["idle"], "nothing is happening, so nothing breathes")
self.assertEqual(flight["pill"], "not started")
self.assertIn("▸ run phase", flight["line"])
def test_a_held_phase_is_its_own_state(self):
flight = self.flight(self.snapshot(started=True, stopped=True))
self.assertTrue(flight["idle"])
self.assertEqual(flight["pill"], "held")
def test_a_running_phase_is_unchanged(self):
flight = self.flight(self.snapshot(
started=True, running=True,
members=[{"number": "31", "file": ONE, "title": "Stand up site/",
"state": "running"}]))
self.assertFalse(flight.get("idle"), "a run is work: it keeps the accent")
self.assertIn("on #31", flight["line"])
def test_a_halted_phase_is_unchanged(self):
flight = self.flight(self.snapshot(started=True, halted="31: its CI is red",
haltedAt="31", haltedWhy="its CI is red"))
self.assertTrue(flight["bad"])
self.assertFalse(flight.get("idle"))
def test_a_phase_the_runner_has_not_read_says_nothing(self):
"""Before the first beat there is no snapshot, and a card that
guessed would be worse than one that waits."""
self.assertIsNone(self.flight(None))
def test_a_settled_phase_card_is_not_told_it_never_started(self):
"""review/ and done/ are past the question — the card there is
waiting on a person, which its stage already says."""
self.assertIsNone(self.flight(self.snapshot(started=True), stage="review"))
self.assertIsNone(self.flight(self.snapshot(started=True), stage="done"))
def test_idle_wears_the_settled_register_and_never_the_accent(self):
block = re.search(r"if \(flight && flight\.idle\) \{.*?\n \}",
self.card_fn, re.S).group(0)
self.assertIn("var(--idle)", block)
for colour in ("--accent", "--alarm", "--calm"):
self.assertNotIn(colour, block,
"not started is neither work, an alarm nor a verdict")
self.assertIn("!flight.bad && !flight.idle", self.card_fn,
"an unstarted phase must not read as an agent working")
self.assertIn("flight.bad || flight.idle ? '' :", self.card_fn,
"…and nothing is still arriving, so there is no caret")
def test_the_halt_and_the_failure_still_outrank_it(self):
idle = self.card_fn.index("if (flight && flight.idle)")
self.assertLess(idle, self.card_fn.index("if (failure)"))
self.assertLess(idle, self.card_fn.index("if (flight && flight.bad)"))
class TheDocumentedGuard(unittest.TestCase):
"""The doctrine this card restores: the file-carried gates exist because
a UI layer can be stale, so AGENTS.md has to say this one is there."""
@classmethod
def setUpClass(cls):
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
# the doc is hard-wrapped, so read it as the sentence it is
cls.flat = re.sub(r"\s+", " ", cls.doc.replace("**", ""))
def test_the_refusal_is_written_down_with_the_action_it_names(self):
self.assertIn("It also refuses a phase card", self.flat)
self.assertIn("the refusal names ▸ run phase", self.flat)
def test_the_kinds_a_phase_card_may_host_are_named(self):
self.assertIn("▸ start work and ↻ act on PR refuse it", self.flat)
self.assertIn("◔ still true? and ◔ review PR", self.flat)
def test_the_unstarted_card_is_described(self):
self.assertIn("`not started`", self.doc)
if __name__ == "__main__":
unittest.main()
+16 -2
View File
@@ -416,10 +416,24 @@ class ThePhaseCardsSummary(BoardViewCase):
self.assertIsNone(self.flight())
def test_a_phase_whose_run_ended_has_nothing_to_say(self):
def test_a_phase_in_progress_with_no_run_says_it_has_not_started(self):
"""It said nothing here once, which made a phase nobody had started
look exactly like one mid-run the header chip is absent in both
cases. Task 54 gave the card the distinction; it is quiet, and it is
not work, so nothing about it breathes."""
self.a_phase_of_two()
self.assertIsNone(self.flight(phases=self.snapshot(running=False)))
flight = self.flight(phases=self.snapshot(running=False))
self.assertTrue(flight["idle"])
self.assertEqual(flight["pill"], "not started")
def test_a_phase_that_was_held_says_that_instead(self):
self.a_phase_of_two()
flight = self.flight(phases=self.snapshot(running=False, stopped=True))
self.assertEqual(flight["pill"], "held")
class Wiring(unittest.TestCase):