The board shows a phase running, and shows it stopping

A phase card inherits the PR chip, CI, the review actions and the
merge-and-clean-up sheet by being a card. Three things it does not
inherit, and this is them.

The runner learns to stop. `stopped — held by <name>` joins `halted` and
`run started` in the phase log, and one reader (`run_state`) answers
where a phase is from the last line that says: running, halted, stopped
or never run. `stop_phase` writes that line — so the next beat stands
down — and holds the member agent in flight, because a phase that said
it had stopped while its agent kept working would be lying about the one
thing it was asked. Nothing is unwound: the branch, every card merged
into it and every worktree are left exactly as they were. A halted phase
can be held too, which is the other half of the halt's promise that it
holds until the phase is run again or stopped.

The halt is now told three times, as a dead run is: the state, the
ticker line it already had, and a toast — rare, actionable, and the
whole argument for starting a phase and walking away. Every advance is
narrated: the member that came up green, the merge, the next start.

The page: run phase (take over on someone else's) in the slot start work
occupies, hold while it runs, and a header chip beside the agents chip —
breathing accent with the phase, its progress and the card in flight,
alarm and holding when it halts, absent entirely when there is nothing
to say. One chip per phase, because two phases could in principle run at
once and a chip that showed one of them silently would be worse than
none. Opening a phase card lists its members in run order with each
one's stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-01 10:32:32 +02:00
co-authored by Claude Opus 5
parent 7198c17332
commit c49f5bd014
6 changed files with 1017 additions and 39 deletions
+45 -2
View File
@@ -879,8 +879,51 @@ the existing apparatus applies unchanged: the CI chip, **◔ review PR**,
**Assignee** is where "who runs it" is written down — the same claim that
gates starting work. A replica renders the phase and advances nothing.
Members run one at a time. Running independent members in parallel, and
any UI beyond the API and the ticker, are separate cards.
Members run one at a time. Running independent members in parallel is a
separate card.
### Watching one run, and watching it stop
A phase card is a card, so the PR chip, CI, the review actions and the
merge-and-clean-up sheet all reach it by inheritance. Three things do not,
and they are the phase's own interface:
- **▸ run phase**, on an `in-progress/` phase card, in the slot **▸ start
work** occupies on an ordinary one — arming and firing like every other
launch, and reading **▸ take over** on a card someone else holds. Moving
the card to `in-progress/` stays the commitment; this is the second half
of it. **‖ hold** replaces it while the phase runs and means what it
means everywhere else: the run stops and the member agent in flight
stops with it, while the phase branch, every card already merged into it
and every worktree are left exactly as they were. A hold is not an undo,
and a held member's card keeps its own work — so running the phase again
may halt on it, which is the honest reading of a run that ended without
reaching `review/`.
- **A chip in the header while a phase runs**, beside the agents chip and
present on the same terms as the sync chip — only when there is
something to say. It breathes in `--accent` and names the phase, how far
it has got and the card in flight: `⟶ Ship the site · 2/5 · on #33`.
Progress is what has landed on the phase branch, which is a different
fact from which card is running, so both are there. Two phases could in
principle run on one board, and two phases get two chips — a chip that
silently showed one of them would be worse than none.
- **The halt, in `--alarm`, holding.** `⟶ Ship the site · halted at #35
it is not ready` stays in the header until the phase is run again or
held, rather than scrolling away; a toast fires with it, because a halt
is rare and actionable; and the ticker keeps the line. The member card
wears its own failure independently — a phase you started and stopped
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.
Every advance is narrated in the ticker as it happens: the member that
came up green, the merge into the phase branch, the member starting next.
And the phase card's sheet lists its members in run order with each one's
stage — and, while a phase is in flight, the runner's own reading of each
(merged in, working, checking, stopped here) — so the card answers "where
is this up to" without a hunt across five columns.
Filtering the board to a phase's cards, and a Focus view for a phase, are
separate cards.
An optional **Assignee** line records who holds the card:
+230 -8
View File
@@ -99,6 +99,18 @@
/* an author display beats the UA's [hidden] — say it here or the sync
chip is never hidden */
.livechip[hidden]{display:none}
/* A running phase is the same kind of fact as the agents and the sync
chips: what is happening across the board without you. One chip per
phase in flight — two phases could in principle run at once, and a
chip that showed one of them would be worse than none — so the row
gives way before the chips already here do, clipping the names it
cannot fit rather than shrinking its neighbours. */
.phasechips{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}
.phasechips[hidden]{display:none}
.phasechips .livechip{min-width:0}
.phasechips .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.phasechips .mono{white-space:nowrap}
.phasechips .halted{color:var(--alarm)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
/* the model chip: which brain did this, beside the name that did it.
@@ -451,6 +463,28 @@
#drawer .dhead{display:flex;align-items:center;gap:10px}
/* the dead run's excerpt, above the task itself: machine output, bounded */
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
/* the phase's list, above the card's own text: run order, one row per
card, machine facts (position, number, stage, the runner's reading)
in mono and the title in the reader's face */
#drawer .pmembers{display:flex;flex-direction:column;gap:1px}
#drawer .phead{
display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;
color:var(--muted);padding-bottom:5px;
}
#drawer .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
#drawer .prow{
display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;
padding:5px 8px;border:1px solid transparent;border-radius:7px;
background:var(--sunken);font-size:12.5px;color:var(--text);
}
#drawer .prow:hover{border-color:var(--accent)}
#drawer .prow .mono{font-family:var(--mono);font-size:11px;color:var(--dim)}
#drawer .prow .pn{width:14px;flex:none;text-align:right}
#drawer .prow .pref{flex:none}
#drawer .prow .ptitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#drawer .prow .pstage{flex:none;color:var(--muted)}
#drawer .prow .pstate{flex:none}
#drawer .pempty{font-size:12px;color:var(--dim);padding:4px 8px}
#drawer .dbody{font-size:13px;line-height:1.6}
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
@@ -542,6 +576,7 @@
<button data-view="focus">Focus</button>
</nav>
<span class="spacer"></span>
<div class="phasechips" id="phasechips" hidden></div>
<div class="livechip" id="syncchip" hidden style="cursor:default"></div>
<div class="livechip" id="livechip" title="open Sessions"></div>
<button id="themebtn">Daylight</button>
@@ -625,10 +660,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
move: '⇢', new: '', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
move: '⇢', new: '', agent: '⚑', report: '▣', sync: '⇅', phase: '⟶', other: '·' };
const FILTERS = [
['all', 'All', null],
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync', 'phase'])],
['edits', 'Edits', new Set(['edit'])],
['reads', 'Reads', new Set(['read', 'search'])],
['tests', 'Tests', new Set(['test', 'check'])],
@@ -890,6 +925,82 @@ function renderSync() {
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
}
/* ── a phase in flight ────────────────────────────────────────────────── */
/* The phases the header has something to say about: one running, or one
halted and not yet settled. A phase nobody has started, and one whose
card has left in-progress/, are simply not here — with none of them the
header is exactly what it was before phases existed.
A halt leads, because it is the one a person must not scroll past. */
function phasesInFlight() {
return Object.values(S.state?.phases || {})
.filter(p => p.running || p.halted)
.sort((a, b) => (b.halted ? 1 : 0) - (a.halted ? 1 : 0) ||
a.file.localeCompare(b.file));
}
/* Where a phase is up to, from the members the last pass read: how many
have landed on the phase branch, and which one is in flight — an agent
on it, or its checks still running. */
function phaseProgress(p) {
const members = p.members || [];
return {
done: members.filter(m => m.state === 'merged').length,
total: members.length,
on: members.find(m => m.state === 'running') ||
members.find(m => m.state === 'waiting') || null,
};
}
function clip(text, n) {
return text.length > n ? text.slice(0, n - 1) + '…' : text;
}
/* What the chip says after the name: the halt and where it happened, or
the progress and the card in flight. The whole of it is on the title —
a header chip is not the place a reason gets to run long. */
function phaseChipDetail(p) {
if (p.halted) {
return (p.haltedAt ? `halted at #${p.haltedAt} — ` : 'halted — ') +
clip(p.haltedWhy || p.halted, 46);
}
const at = phaseProgress(p);
const rest = at.on ? `on #${at.on.number || at.on.file}`
: (p.waitingOn || []).length ? `waiting on #${p.waitingOn[0]}` : '';
return `${at.done}/${at.total}` + (rest ? ` · ${rest}` : '');
}
function renderPhases() {
const el = $('#phasechips');
const live = phasesInFlight();
el.hidden = !live.length;
el.innerHTML = live.map(p => {
const at = phaseProgress(p);
const detail = phaseChipDetail(p);
// colour only means state: a run alive breathes in accent, a halt
// holds in alarm until the phase is resumed or held
const title = (p.halted
? `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
`${p.haltedWhy || p.halted}. It stays here until the phase is run again or held.`
: `${p.file} is running on ${p.branch} — ${at.done} of ${at.total} cards merged`) +
' · click to open the phase card';
const dot = p.halted
? '<span class="dot" style="background:var(--alarm)"></span>'
: '<span class="dot live"></span>';
return `<div class="livechip" data-phasechip="${esc(p.file)}" title="${esc(title)}">` +
dot + `<span class="name${p.halted ? ' halted' : ''}">⟶ ${esc(phaseLabel(p))}</span>` +
`<span class="mono${p.halted ? ' halted' : ''}">${esc(detail)}</span></div>`;
}).join('');
el.querySelectorAll('[data-phasechip]').forEach(chip =>
chip.addEventListener('click', () => {
const task = findTask(chip.dataset.phasechip);
if (!task) return;
setView('board');
showDetail(task);
}));
}
function setView(view) {
S.view = view;
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
@@ -932,6 +1043,7 @@ function render() {
if (!S.state) return;
renderTitle();
renderChip();
renderPhases();
renderSync();
if (S.view === 'board') renderBoard();
else if (S.view === 'flight') renderFlight();
@@ -1072,11 +1184,38 @@ function cardFor(task) {
run: () => askCopilot(task) });
}
} else {
if (task.stage === 'in-progress') {
// someone else's card is never started by accident: the action says
// whose it is, and firing it is the deliberate takeover
const held = task.assignee && S.state.me && task.assignee !== S.state.me
? task.assignee : null;
// someone else's card is never started by accident: the action says
// whose it is, and firing it is the deliberate takeover
const held = task.assignee && S.state.me && task.assignee !== S.state.me
? task.assignee : null;
if (task.stage === 'in-progress' && task.isPhase) {
// the phase's own launch, in the slot ▸ start work has on every other
// card: moving it here was the commitment, this is the second half
const ph = (S.state.phases || {})[task.file];
const hold = { glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop the phase — the phase branch, every card already merged '
+ 'into it and every worktree stay exactly as they are',
run: () => holdPhase(task) };
const start = held
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`,
busy: 'starting…',
title: `${held} holds this phase — running it takes the card over and `
+ 'this board becomes the one that advances it',
run: () => runPhase(task, { takeover: true }) }
: { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
title: ph && ph.halted
? 'Run it again — the halt is cleared and the phase carries on from '
+ 'where it stopped'
: 'A branch of its own, each card in the list run on it in turn, and '
+ 'one PR into main at the end',
run: () => runPhase(task) };
// a halt holds until the phase is run again or held: both are here,
// because a person who has read it and does not want to carry on
// needs a way to say so that is not walking the card backwards
if (ph && ph.running) actions.push(hold);
else if (ph && ph.halted) actions.push(start, hold);
else actions.push(start);
} else if (task.stage === 'in-progress') {
actions.push(held
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
@@ -1099,7 +1238,9 @@ function cardFor(task) {
title: 'Push the branch and open its PR — the board does this when a card '
+ 'enters review, and this is how you ask for it afterwards',
run: () => openPR(task) });
} else {
} else if (actions.length < 2) {
// two per state is the whole budget, and a halted phase has already
// spent it on running it again and holding it
actions.push(stillTrue);
}
}
@@ -1443,6 +1584,35 @@ async function askCopilot(task) {
return res.ok;
}
/* Running a phase, and holding it. Both are ordinary POSTs on the one
action machine — armed, fired, and honest about failing — because a
phase is a card and its actions are card actions. What follows either
one is watched in the header chip and narrated in the ticker, so
neither says more here than what it just asked for. */
async function runPhase(task, extra) {
const res = await fetch('/api/phase/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
});
const data = await res.json();
toast(res.ok ? `Phase ${task.file} running — the header chip follows it`
: (data.error || 'the phase did not start'), !res.ok);
await loadState();
return res.ok;
}
async function holdPhase(task) {
const res = await fetch('/api/phase/stop', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, stage: task.stage }),
});
const data = await res.json();
toast(res.ok ? 'Held — the phase branch and everything merged into it stay as they are'
: (data.error || 'could not hold it'), !res.ok);
await loadState();
return res.ok;
}
async function move(file, from, to) {
// finishing a task with work attached is a decision, not just a drag
if (to === 'done') {
@@ -1517,6 +1687,52 @@ function showDetail(task) {
if (changed) $('#drawerbody').scrollTop = 0;
}
/* The runner's word for a member, in the vocabulary the rest of the board
uses. `pending` is the phase simply not having reached it, which the
stage beside it already says — so it says nothing. */
const MEMBER_STATE = { merged: 'merged in', running: 'working',
waiting: 'checking', ready: 'ready to merge', halt: 'stopped here' };
/* A phase card answers "where is this up to" on its own face: its members
in run order, each with the stage it is in, so the question costs no
hunt across five columns. The runner's reading (merged in, working,
stopped here) is added only while a phase is in flight, because only
then is there one — and only it can tell a card that has run from one
the phase has not reached. Each row opens that card. */
function phaseMembers(t) {
if (!t.isPhase) return '';
if (!(t.members || []).length) {
return `<div class="pmembers"><div class="phead">Cards</div>` +
`<div class="pempty">This phase lists no cards yet — a <code>## Cards</code> ` +
`section, one task number per line, in the order they run.</div></div>`;
}
const snap = (S.state.phases || {})[t.file];
const read = {};
for (const m of ((snap && snap.members) || [])) read[m.file] = m;
const rows = t.members.map((m, i) => {
const seen = read[m.file];
const note = seen ? MEMBER_STATE[seen.state] || '' : '';
const tint = seen && seen.state === 'halt' ? 'var(--alarm)'
: seen && ['running', 'waiting'].includes(seen.state) ? 'var(--accent)'
: seen && seen.state === 'merged' ? 'var(--calm)' : '';
return `<button class="prow" data-member="${esc(m.file)}">` +
`<span class="pn mono">${i + 1}</span>` +
`<span class="pref mono">#${esc(m.number || '')}</span>` +
`<span class="ptitle">${esc(m.title)}</span>` +
`<span class="pstage mono">${esc(m.stage)}</span>` +
(note ? `<span class="pstate mono"${tint ? ` style="color:${tint}"` : ''}` +
`${seen.why ? ` title="${esc(seen.why)}"` : ''}>${esc(note)}</span>` : '') +
`</button>`;
}).join('');
const halt = snap && snap.halted
? `<div class="pempty" style="color:var(--alarm)">halted` +
`${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''} — ` +
`${esc(snap.haltedWhy || snap.halted)}</div>` : '';
return `<div class="pmembers"><div class="phead">Cards` +
`<span class="mono">${t.members.length} in run order</span></div>` +
rows + halt + `</div>`;
}
function renderDrawer() {
const panel = $('#drawer');
const body = $('#drawerbody');
@@ -1557,9 +1773,15 @@ function renderDrawer() {
`<span class="spacer"></span>` +
`<button id="closeDrawer">Close</button></div>` +
failBlock +
phaseMembers(t) +
`<div class="dbody">${md(t.body)}</div>` +
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
panel.classList.add('open');
body.querySelectorAll('[data-member]').forEach(row =>
row.addEventListener('click', () => {
const member = findTask(row.dataset.member);
if (member) showDetail(member);
}));
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); });
}
+9 -2
View File
@@ -35,8 +35,10 @@ 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
# what the last pass of the phase runner saw: per phase card in
# in-progress/, its branch, whether a run is in force or halted (and
# why, and where), and each member's state — the header chip and the
# phase card's own list are both read from here
"phases": phases.public_state(),
"drive": drive.public(),
"hasDriver": config.driver_path() is not None,
@@ -201,6 +203,11 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"phase": phases.start_phase(
payload["file"], payload["stage"],
bool(payload.get("takeover")))})
elif path == "/api/phase/stop":
payload = self._read_body()
# ‖ hold on a phase card: the run stops, nothing is unwound
self._json(200, {"phase": phases.stop_phase(payload["file"],
payload["stage"])})
elif path == "/api/pr/open":
payload = self._read_body()
self._json(200, {"url": github.open_pr_now(payload["file"])})
+127 -26
View File
@@ -64,6 +64,7 @@ LOG_LINE_RE = re.compile(r"^-\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+·\s+(.+?)\s*$
STARTED_RE = re.compile(r"^(\d+) started\b")
HALTED_RE = re.compile(r"^halted(?: at (\d+))? — (.*)$")
RUN_RE = re.compile(r"^run started\b")
STOPPED_RE = re.compile(r"^stopped — (.*)$")
# What the last computed pass saw, for the API to render without paying for
# a git walk on every request. A display cache, not a decision: nothing here
@@ -161,17 +162,37 @@ def _started(entries: list[str]) -> set[str]:
(STARTED_RE.match(e) for e in _this_run(entries)) if match}
def _halt_reason(entries: list[str]) -> str | None:
"""The halt still in force, or None. A `run started` line after a halt
clears it that is what running the phase again means."""
def run_state(entries: list[str]) -> dict:
"""Where the phase stands, read off its own log — the whole of what a
restarted board, and the chip in the header, know about a run.
Four answers, and the last line that says one of them wins: `running`
(a run was started and nothing has ended it), `halted` (with the reason
and the member it happened at), `stopped` (a person held it) and `idle`
(a card whose phase has never been run). Running it again is what
clears a halt or a stop, because that line is the person's decision.
"""
for entry in reversed(entries):
halted = HALTED_RE.match(entry)
if halted:
return (f"{halted.group(1)}: {halted.group(2)}" if halted.group(1)
else halted.group(2))
return {"state": "halted", "reason": halted.group(2),
"at": halted.group(1)}
stopped = STOPPED_RE.match(entry)
if stopped:
return {"state": "stopped", "reason": stopped.group(1), "at": None}
if RUN_RE.match(entry):
return None
return None
return {"state": "running", "reason": "", "at": None}
return {"state": "idle", "reason": "", "at": None}
def _halt_reason(entries: list[str]) -> str | None:
"""The halt still in force, as one line — the member it happened at and
why, which is what the log's reader and the ticker both want."""
where = run_state(entries)
if where["state"] != "halted":
return None
return (f"{where['at']}: {where['reason']}" if where["at"]
else where["reason"])
# ── reading the board ──────────────────────────────────────────────────
@@ -266,6 +287,7 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
"""The whole phase, recomputed. Nothing is remembered between passes."""
branch = agents.phase_branch(phase["file"])
entries = log_entries(phase["body"])
where = run_state(entries)
started = _started(entries)
members = []
for listed in phase["members"]:
@@ -280,8 +302,20 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
"title": task["title"], "stage": task["stage"],
"state": member_state, "why": why,
"dependsOn": task["dependsOn"]})
halted = where["state"] == "halted"
return {"file": phase["file"], "branch": branch, "stage": phase["stage"],
"members": members, "halted": _halt_reason(entries),
"title": phase["title"], "number": phase["number"],
"members": members,
# the halt as one line for the log's reader, and in its parts for
# anything that renders it — the same reading, said twice
"halted": _halt_reason(entries),
"haltedAt": where["at"] if halted else None,
"haltedWhy": where["reason"] if halted else None,
# is a run in force? The log answers it, so a restarted board and
# the chip in the header read the same thing
"running": where["state"] == "running",
"stopped": where["state"] == "stopped",
"stoppedBy": where["reason"] if where["state"] == "stopped" else None,
# the branch is the whole of "this phase has been started"
"started": _branch_exists(branch)}
@@ -428,6 +462,11 @@ def _launch(phase: dict, member: dict) -> None:
def _merge_member(phase: dict, member: dict) -> None:
branch = f"task/{member['file'][:-3]}"
phase_branch = agents.phase_branch(phase["file"])
# every advance is narrated, and finishing is its own half of one: what
# the phase judged green is a different fact from what it then merged,
# and a phase nobody can reconstruct afterwards is a phase nobody trusts
_say(phase["file"], f"{phase['file']}: {member['number']}"
f"{member['title']} is green")
if _branch_exists(branch):
_merge_into_phase(phase, branch, f"{member['number']}'s branch", member)
_push_phase(phase)
@@ -449,11 +488,17 @@ def _finish(phase: dict) -> None:
def _halt(phase: dict, member: dict | None, reason: str) -> None:
"""Stop, and say so once. The log holds the halt from here on, so the
next pass reads it rather than saying the same thing again."""
"""Stop, and say so once — at three altitudes, like every other outcome
a person must not miss. The log holds the halt from here on (so the next
pass reads it rather than saying the same thing again), the ticker keeps
the line, and a toast says it to whoever is looking: a halt is rare,
actionable, and the whole argument for starting a phase and walking away.
"""
at = f" at {member['number']}" if member and member["number"] else ""
_write_log(phase, f"halted{at}{reason}") # best effort: never re-raise
_say(phase["file"], f"{phase['file']} halted{at}{reason}")
state.broadcast({"type": "toast", "error": True,
"message": f"phase {phase['file']} halted{at}{reason}"})
def _do_pass(phase: dict, snapshot: dict, by_number: dict[str, dict]) -> list[str]:
@@ -490,7 +535,7 @@ def advance(phase: dict, by_file: dict[str, dict],
"""
snapshot = _snapshot(phase, by_file)
SNAPSHOTS[phase["file"]] = snapshot
if snapshot["halted"] or not snapshot["started"] or not _mine(phase):
if not snapshot["running"] or not snapshot["started"] or not _mine(phase):
return snapshot
waiting_on: list[str] = []
try:
@@ -541,30 +586,33 @@ def beat() -> None:
pass
# ── starting one ───────────────────────────────────────────────────────
# ── starting one, and stopping it ──────────────────────────────────────
def _phase_card(filename: str, stage: str) -> dict:
"""The phase card an action names, or the reason it is not one."""
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
if stage != "in-progress":
raise ValueError("a phase runs from in-progress/ — move the card there first")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
phase = _cards()[0].get(filename)
if phase is None or not phase["isPhase"]:
raise ValueError(f"{filename} is not a phase — a phase card is "
f"**Type:** Phase with a ## Cards section")
return phase
def start_phase(filename: str, stage: str, takeover: bool = False) -> dict:
"""Run a phase — the first time, or again after a halt.
"""Run a phase — the first time, or again after a halt or a hold.
Cutting `phase/<stem>` from the newest main is the whole of "starting":
everything after it is the beat looking at what is there. Running a
halted phase again is the person's decision that cleared the halt, so
it appends the line that clears it and takes one pass immediately.
"""
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
if stage != "in-progress":
raise ValueError("a phase runs from in-progress/ — move the card there first")
path = config.TASKS / stage / filename
if not path.is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
by_file, by_number = _cards()
phase = by_file.get(filename)
if phase is None or not phase["isPhase"]:
raise ValueError(f"{filename} is not a phase — a phase card is "
f"**Type:** Phase with a ## Cards section")
phase = _phase_card(filename, stage)
if phase["phaseDrift"]:
raise ValueError(f"{filename} does not resolve: {phase['phaseDrift'][0]} "
f"— fix the list before running it")
@@ -603,3 +651,56 @@ def _start(phase: dict, filename: str) -> dict:
by_file, by_number = _cards() # the log just rewrote the card
return advance(by_file.get(filename) or phase, by_file, by_number)
def _agents_on(files: set[str]) -> list[dict]:
with state.LOCK:
return [dict(r) for r in state.AGENTS.values()
if r["task"] in files and r["status"] == "running"]
def stop_phase(filename: str, stage: str) -> dict:
"""Hold a phase: stop, without unwinding anything.
` hold` means here what it means on any other card the work stops
and nothing is lost. So the line goes into the log (which is what the
beat reads, so the next pass stands down), and the agent the phase has
in flight is held exactly as its own card's hold would hold it. The
phase branch, every member already merged into it, and every worktree
are left precisely as they were: a hold is not an undo, and the only
way back to `main` is still the PR at the end.
A halted phase can be held too, and that is the other half of the
halt's promise: it holds until the phase is run again *or* stopped,
and a person who has read the halt and does not want to carry on needs
a way to say so that is not walking the card backwards.
A held member's card keeps its own work and its own state, so running
the phase again may well halt on it that is the honest reading of a
run that ended without reaching review/, and it is a person's to settle.
"""
phase = _phase_card(filename, stage)
with _LOCK: # never alongside a pass of the beat
where = run_state(log_entries(phase["body"]))
if where["state"] not in ("running", "halted"):
raise ValueError(f"{filename} is not running — nothing to hold "
f"(its phase log says: {where['state']})")
who = taskfiles.actor_name() or "you"
if not _write_log(phase, f"stopped — held by {who}"):
raise ValueError(f"could not record the hold on {filename} — its "
f"phase log must be writable, or the next beat "
f"would carry on regardless")
held = []
for record in _agents_on({m["file"] for m in phase["members"]}):
try:
agents.stop_agent(record["id"])
except ValueError: # it ended between the read and the ask
continue
held.append(record["task"])
_say(filename, f"phase {filename} held by {who}"
+ (f"{', '.join(held)} stopped with it" if held else "")
+ f"{agents.phase_branch(filename)} is left as it is")
snapshot = _snapshot(_reread(phase) or phase, _cards()[0])
SNAPSHOTS[filename] = snapshot
state.broadcast({"type": "board"})
return snapshot
+6 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import importlib
import os
import re
import shutil
import subprocess
import sys
@@ -709,7 +710,11 @@ class TheSyncChip(unittest.TestCase):
def test_sync_events_have_a_glyph_and_a_filter(self):
self.assertIn("sync: ''", self.html)
self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html)
# the board-level kinds share the Moves filter; what matters here is
# that sync is one of them, not which others have joined it since
moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
self.assertIsNotNone(moves, "board.html lost its Moves filter")
self.assertIn("'sync'", moves.group(1))
if __name__ == "__main__":
+600
View File
@@ -0,0 +1,600 @@
"""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_both_ways_out(self):
self.assertIn("ph.halted) actions.push(start, hold)", 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()