Merge pull request #37 from 12vectors/task/38-a-card-being-completed-looks-like-it

38 — A card being merged and cleaned up looks like it, and holds still
This commit is contained in:
Ronald Ashri
2026-07-31 17:22:16 +02:00
committed by GitHub
6 changed files with 496 additions and 10 deletions
+14
View File
@@ -482,6 +482,20 @@ step narrates in the ticker; a merge conflict aborts cleanly and the card
stays put. Cards without work move silently, and hand-moves on disk are
never intercepted — the board only asks when you act through it.
That work takes as long as it takes, so **the card wears it** rather than
sitting there looking idle while its branch is disassembled: from the
first step to the last it takes the accent border and a breathing
`completing` pill — the same vocabulary as an agent working, because that
is what is happening — and carries the latest narrated step on its
activity line ("parking the drive", "merged task/29-… into main",
"cleaned up: worktree and local branch removed"). While it does, it has
no hover actions, no drive or command chips and cannot be dragged, and a
second `complete` for the same card is refused rather than started. The
claim lives in this board's memory and is released on every exit —
merged, conflicted or crashed — so a failure gives the card straight back
and a board restarted mid-completion leaves nothing stuck. Other replicas
see the card unchanged until the move itself arrives.
With `BOARD_SYNC` on the merge is made **on origin** instead: the board
runs `gh pr merge` on the card's PR, cleans up and moves the card, and
local `main` fast-forwards to the result on the next beat. Replicas
+46 -9
View File
@@ -161,6 +161,15 @@
/* the last run on this card died: the same terracotta, worn until the
next launch replaces it or the card moves stage */
.card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* merge & clean up is running on this card: the working vocabulary,
because that is what is happening — the board is disassembling its
branch. No new colour, no new animation, and nothing to grab: the card
is not draggable and carries no actions until it lands in done/. */
.card.completing{
border-color:color-mix(in oklab, var(--accent) 55%, var(--border));cursor:default;
}
.card.completing:hover{transform:none;border-color:color-mix(in oklab, var(--accent) 55%, var(--border))}
.card .pill.status.breathing{animation:breathe 2.4s ease-in-out infinite}
/* tool chips: destinations, not statuses — they live in the card's footer,
never squeezed into the author row */
.chiprow{
@@ -817,6 +826,11 @@ function connectStream() {
} else if (msg.type === 'board_event') {
S.state?.boardEvents.push(msg.event);
scheduleRender();
} else if (msg.type === 'completing') {
// the cards this board is merging and cleaning up, and which step
// each is on — whole map, so it can never go stale in pieces
if (S.state) S.state.completing = msg.completing;
scheduleRender();
}
};
}
@@ -968,13 +982,17 @@ function cardFor(task) {
const working = agent && agent.mode !== 'review';
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
// runs, the card is mid-disassembly and hands back none of its actions
const completing = (S.state.completing || {})[task.file];
el.className = 'card'
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
+ (working ? ' running' : '')
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
+ (failure ? ' run-failed' : '')
+ (completing ? ' completing' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = true;
el.draggable = !completing;
let tint = working ? 'var(--accent)' : STAGE_TINT[task.stage];
let pill = agent && agent.mode === 'review'
@@ -994,14 +1012,21 @@ function cardFor(task) {
title: failure.excerpt };
tint = 'var(--alarm)';
}
if (completing) {
// live work outranks every settled reading: the branch behind an
// 'approved' pill is being merged away as you look at it
pill = { text: 'completing', tint: 'var(--accent)', bg: mix('var(--accent)', 16),
title: 'merge & clean up is running — the card comes back when it lands in done/' };
tint = 'var(--accent)';
}
const high = (task.priority || '').toLowerCase() === 'high';
const top = [
`<span class="mark${working ? ' breathing' : ''}" style="background:${tint}"></span>`,
`<span class="mark${working || completing ? ' breathing' : ''}" style="background:${tint}"></span>`,
`<span class="ref">${task.number ? '#' + esc(task.number) : esc(task.file.slice(0, 10))}</span>`,
'<span class="spacer"></span>',
high ? '<span class="high">HIGH</span>' : '',
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}"` +
`<span class="pill status${completing ? ' breathing' : ''}" style="background:${pill.bg};color:${pill.tint}"` +
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
];
@@ -1010,7 +1035,10 @@ function cardFor(task) {
const stillTrue = { glyph: '◔', label: 'still true?', confirm: 'check it?', busy: 'checking…',
title: 'A read-only agent checks this task is still true of the codebase',
run: () => fireAgent(task, '/api/agent/review') };
if (agent) {
if (completing) {
// none, deliberately: an action that looks available and does nothing
// is the same lie as a card that looks idle while its branch is deleted
} else if (agent) {
actions.push({ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop this agent — nothing is lost',
run: () => stopAgent(agent.id) });
@@ -1088,7 +1116,12 @@ function cardFor(task) {
if (task.type) extras.push(esc(task.type.toLowerCase()));
let liveLine = '';
if (agent && agent.session) {
if (completing) {
// the steps are narrated as board events against this file; the card
// wears the latest one instead of leaving the ticker to tell the story
liveLine = `<div class="well"><span class="lead">·</span>` +
`<span class="wbody">${esc(completing.step || 'completing…')}<span class="caret"></span></span></div>`;
} else if (agent && agent.session) {
const run = S.running[agent.session];
const smeta = sessionMeta(agent.session);
const last = run || (smeta && smeta.lastSummary ? { summary: smeta.lastSummary, ok: null } : null);
@@ -1129,7 +1162,9 @@ function cardFor(task) {
}
if (task.pr) chips.push({ label: 'PR', glyph: '↗', cls: '', href: task.pr, title: detail });
let driveWell = '';
if (task.stage === 'review') {
// the drive and the project's commands run against a worktree this card
// is in the middle of removing: no chip offers either while it does
if (task.stage === 'review' && !completing) {
const d = S.state.drive;
if (d && d.task === task.file && (d.status === 'up' || d.status === 'starting')) {
if (d.status === 'up') chips.push({ label: 'open', glyph: '✳', cls: 'accent',
@@ -1158,7 +1193,7 @@ function cardFor(task) {
}
}
// project commands run against this task's worktree
if (hasBranch && ['in-progress', 'review'].includes(task.stage)) {
if (hasBranch && !completing && ['in-progress', 'review'].includes(task.stage)) {
for (const cmd of (S.state.commands || [])) {
const running = (S.state.commandRuns || []).some(r => r.task === task.file && r.name === cmd.name);
if (running) {
@@ -1435,7 +1470,7 @@ function completeSheet(task, from) {
$('#sh-move').addEventListener('click', () => { closeSheet(); rawMove(task.file, from, 'done'); });
$('#sh-ship').addEventListener('click', async () => {
closeSheet();
toast(`Completing ${task.file} — the ticker narrates each step`);
toast(`Completing ${task.file} — the card shows each step`);
const res = await fetch('/api/task/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: task.file, from }),
@@ -1471,7 +1506,9 @@ function renderDrawer() {
const t = S.selected;
const agent = agentOnTask(t.file);
const failure = failedRun(t);
const pill = failure
const pill = (S.state.completing || {})[t.file]
? { text: 'completing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
: failure
? { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) }
: agent && agent.mode === 'review'
? { text: 'reviewing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
+19 -1
View File
@@ -394,12 +394,30 @@ def complete_task(filename: str, stage: str) -> dict:
the local main and pushes it, exactly as it always did; with
`BOARD_SYNC` on the merge is made on origin through `gh pr merge`, so
local main only ever fast-forwards to it the discipline the whole
sync design rests on."""
sync design rests on.
None of it is quick, and all of it is destructive, so the card is
claimed before the first step and given back in a `finally` after the
last one. The claim is what the card wears while this runs and what
refuses a second request; see `state.claim_completing`.
"""
if stage not in config.STAGE_DIRS or stage == "done":
raise ValueError("complete runs on a live-stage card")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
if not state.claim_completing(filename, "merging and cleaning up…"):
raise ValueError(f"{filename} is already being completed — the card "
f"is showing each step; nothing was started twice")
try:
return _complete(filename, stage)
finally:
# every exit: merged, conflicted, on the wrong branch, or crashed
state.release_completing(filename)
def _complete(filename: str, stage: str) -> dict:
"""The steps themselves, run under the claim complete_task holds."""
stem = filename[:-3]
branch = f"task/{stem}"
+3
View File
@@ -39,6 +39,9 @@ def state_payload() -> dict:
"branches": github.task_branches(),
"commands": config.commands(),
"commandRuns": commands.public(),
# cards this board is midway through merging and cleaning up: the
# busy state renders from here, not from what a tab happened to click
"completing": state.completing_public(),
"checks": config.checks(),
# who this board is, so a card can tell "yours" from "someone
# else's". Empty outside team mode: nothing claims anything there.
+52
View File
@@ -23,6 +23,7 @@ BOARD_EVENTS: list[dict] = [] # moves + agent lifecycle
AGENTS: dict[str, dict] = {} # agent_id -> launch record
EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float]] = {} # (file, to) -> (actor, ts)
COMMIT_HOOKS: list = [] # run after a board-made task commit
COMPLETING: dict[str, dict] = {} # filename -> {started, step}: merge & clean up in flight
# The port actually being served; board.py sets it from --port at startup so
# launched agents know where to report events.
@@ -104,8 +105,59 @@ def record_board_event(event: dict) -> None:
with LOCK:
BOARD_EVENTS.append(event)
del BOARD_EVENTS[:-config.BOARD_EVENTS_CAP]
# a card being completed says which step it is on, and the steps are
# already narrated here — so the registry reads them rather than
# asking every caller to report twice
claimed = COMPLETING.get(event.get("file"))
stepped = bool(claimed) and bool(event.get("summary"))
if stepped:
claimed["step"] = event["summary"]
persist("board.jsonl", event)
broadcast({"type": "board_event", "event": event})
if stepped:
publish_completing()
def completing_public() -> dict:
with LOCK:
return {filename: dict(record) for filename, record in COMPLETING.items()}
def publish_completing() -> None:
"""The whole registry, every time it changes. It is one entry at most in
practice, and a whole map costs nothing to send and cannot go stale in
the way a patch can."""
broadcast({"type": "completing", "completing": completing_public()})
def claim_completing(filename: str, step: str) -> bool:
"""Claim a card for the long, destructive run behind "merge & clean up".
The claim is the card's busy state — what it renders instead of looking
idle, and what refuses a second request rather than starting a second
merge. It lives here, in this board's memory, so it dies with the
process: a board that is killed mid-completion leaves no card stuck
busy, and every other replica sees the card unchanged until the move
arrives (state syncs; reactions don't).
False when the card is already claimed the caller refuses and must
not release what it did not take.
"""
with LOCK:
if filename in COMPLETING:
return False
COMPLETING[filename] = {"started": time.time(), "step": step}
publish_completing()
return True
def release_completing(filename: str) -> None:
"""Give the card back — on success, on conflict, on crash alike. A card
stuck busy forever is worse than a card that looked idle."""
with LOCK:
released = COMPLETING.pop(filename, None) is not None
if released:
publish_completing()
def task_committed(filename: str) -> None:
+362
View File
@@ -0,0 +1,362 @@
"""A card being merged and cleaned up looks like it, and holds still (task 38).
"Merge & clean up" parks the drive, merges, deletes a worktree and a
branch and moves the card seconds to a minute of destructive work the
card used to render as "waiting on you", fully draggable, every action
armed. The state is server-held (`state.COMPLETING`), so these run the
real thing against a real git repo: a real merge, a real conflict, and a
real second request arriving mid-run.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
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 drive as drive_mod # noqa: E402
import github # noqa: E402
import state # noqa: E402
BOARD_HTML = REPO / "manager" / "core" / "board.html"
FILENAME = "38-a-card-being-completed.md"
STEM = FILENAME[:-3]
BRANCH = f"task/{STEM}"
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args],
capture_output=True, text=True)
CARD = ("# 38 — A card with work on it\n\n"
"**Status:** Review\n**Priority:** Medium\n\n"
"Body text long enough that git sees a rename rather than a delete\n"
"and an add when the file moves between stage directories.\n")
class TheRegistry(unittest.TestCase):
"""state.COMPLETING: one claim per card, the latest narrated step, and
a release that always publishes."""
def setUp(self):
state.COMPLETING.clear()
self.addCleanup(state.COMPLETING.clear)
state.BOARD_EVENTS.clear()
self.addCleanup(state.BOARD_EVENTS.clear)
self.sent: list[dict] = []
original = state.broadcast
self.addCleanup(setattr, state, "broadcast", original)
state.broadcast = self.sent.append
# persist() writes the event log; keep the test off the real one
self.tmp = Path(tempfile.mkdtemp(prefix="bench-registry-"))
self.addCleanup(shutil.rmtree, self.tmp, True)
self.addCleanup(setattr, config, "SESSIONS_DIR", config.SESSIONS_DIR)
config.SESSIONS_DIR = self.tmp
def published(self) -> list[dict]:
return [m["completing"] for m in self.sent if m["type"] == "completing"]
def test_a_second_claim_is_refused_rather_than_queued(self):
self.assertTrue(state.claim_completing(FILENAME, "merging…"))
self.assertFalse(state.claim_completing(FILENAME, "merging again…"),
"one completion per card, ever")
self.assertEqual(state.COMPLETING[FILENAME]["step"], "merging…",
"the refused claim must not overwrite the live one")
def test_the_claim_reaches_the_browser_immediately(self):
state.claim_completing(FILENAME, "merging and cleaning up…")
self.assertEqual(self.published()[-1][FILENAME]["step"],
"merging and cleaning up…")
def test_narrated_steps_become_the_card_s_line(self):
state.claim_completing(FILENAME, "merging and cleaning up…")
state.record_board_event({"kind": "agent", "actor": "board",
"file": FILENAME, "summary": "parking the drive"})
self.assertEqual(state.COMPLETING[FILENAME]["step"], "parking the drive")
self.assertEqual(self.published()[-1][FILENAME]["step"], "parking the drive")
def test_another_card_s_events_are_not_this_card_s_step(self):
state.claim_completing(FILENAME, "merging and cleaning up…")
before = len(self.published())
state.record_board_event({"kind": "agent", "actor": "board",
"file": "07-something-else.md",
"summary": "merged task/07 into main"})
self.assertEqual(state.COMPLETING[FILENAME]["step"], "merging and cleaning up…")
self.assertEqual(len(self.published()), before,
"an unrelated event must not republish the registry")
def test_release_empties_it_and_says_so(self):
state.claim_completing(FILENAME, "merging…")
state.release_completing(FILENAME)
self.assertEqual(state.COMPLETING, {})
self.assertEqual(self.published()[-1], {})
def test_releasing_what_was_never_claimed_is_silent(self):
state.release_completing(FILENAME)
self.assertEqual(self.published(), [])
def test_the_public_view_is_a_copy(self):
state.claim_completing(FILENAME, "merging…")
snapshot = state.completing_public()
snapshot[FILENAME]["step"] = "tampered"
self.assertEqual(state.COMPLETING[FILENAME]["step"], "merging…")
class ACompletionInFlight(unittest.TestCase):
"""One board, one card in review/ with a branch and a commit on it.
Sync off, so the merge is the local one."""
def setUp(self):
# resolve(): macOS tempdirs sit behind the /var → /private/var
# symlink and git reports the resolved path.
self.tmp = Path(tempfile.mkdtemp(prefix="bench-completing-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
self.board = self.tmp / "board"
self.board.mkdir()
subprocess.run(["git", "init", "-q", "-b", "main", str(self.board)],
check=True, capture_output=True)
git(self.board, "config", "user.name", "ada")
git(self.board, "config", "user.email", "ada@example.com")
for slug in config.STAGE_DIRS:
(self.board / "tasks" / slug).mkdir(parents=True)
(self.board / "code.txt").write_text("shipped\n", encoding="utf-8")
git(self.board, "add", "-A")
git(self.board, "commit", "-q", "-m", "root")
(self.board / "tasks" / "review" / FILENAME).write_text(CARD, encoding="utf-8")
self.patch(REPO=self.board, TASKS=self.board / "tasks",
WORKTREES=self.tmp / "worktrees", SESSIONS_DIR=self.tmp / "sessions",
SYNC=False, COMMIT_MOVES=False)
self.worktree = config.WORKTREES / STEM
git(self.board, "worktree", "add", "-q", "-b", BRANCH, str(self.worktree))
(self.worktree / "feature.txt").write_text("the work\n", encoding="utf-8")
git(self.worktree, "add", "-A")
git(self.worktree, "commit", "-q", "-m", "the work")
state.COMPLETING.clear()
self.addCleanup(state.COMPLETING.clear)
state.BOARD_EVENTS.clear()
self.addCleanup(state.BOARD_EVENTS.clear)
state.EXPECTED_MOVES.clear()
self.addCleanup(state.EXPECTED_MOVES.clear)
self.addCleanup(setattr, drive_mod, "DRIVE", drive_mod.DRIVE)
drive_mod.DRIVE = None
self.sent: list[dict] = []
self.addCleanup(setattr, state, "broadcast", state.broadcast)
state.broadcast = self.sent.append
def patch(self, **values) -> None:
for attr, value in values.items():
self.addCleanup(setattr, config, attr, getattr(config, attr))
setattr(config, attr, value)
def stage_of(self) -> str | None:
for slug in config.STAGE_DIRS:
if (self.board / "tasks" / slug / FILENAME).is_file():
return slug
return None
def steps(self) -> list[str]:
return [m["completing"][FILENAME]["step"] for m in self.sent
if m["type"] == "completing" and FILENAME in m["completing"]]
def narrated(self) -> list[str]:
return [e["summary"] for e in state.BOARD_EVENTS if e.get("file") == FILENAME]
def conflict(self) -> None:
"""The same line, two ways: main and the branch cannot be merged."""
for where, text in ((self.board, "main's version\n"),
(self.worktree, "the branch's version\n")):
(where / "contested.txt").write_text(text, encoding="utf-8")
git(where, "add", "-A")
git(where, "commit", "-q", "-m", f"contested in {where.name}")
# — the card is busy from the first step to the last —
def test_the_claim_is_held_before_the_drive_is_parked(self):
seen = {}
drive_mod.DRIVE = {"task": FILENAME, "status": "up", "proc": None,
"pgid": None, "started": 0, "log": str(self.tmp / "d.log")}
self.addCleanup(setattr, drive_mod, "stop", drive_mod.stop)
def fake_stop():
seen["claimed"] = FILENAME in state.COMPLETING
drive_mod.DRIVE["status"] = "parked"
drive_mod.stop = fake_stop
github.complete_task(FILENAME, "review")
self.assertTrue(seen.get("claimed"),
"the card must already be busy when the drive is parked")
def test_the_card_tracks_the_steps_the_ticker_narrates(self):
github.complete_task(FILENAME, "review")
steps = self.steps()
self.assertEqual(steps[0], "merging and cleaning up…")
self.assertTrue(any("merged" in s and "into main" in s for s in steps),
f"the merge never reached the card: {steps}")
self.assertTrue(any(s.startswith("cleaned up:") for s in steps),
f"the cleanup never reached the card: {steps}")
for summary in self.narrated():
self.assertIn(summary, steps,
"every step narrated to the ticker lands on the card too")
def test_a_finished_completion_gives_the_card_back(self):
github.complete_task(FILENAME, "review")
self.assertEqual(self.stage_of(), "done")
self.assertEqual(state.COMPLETING, {})
self.assertEqual([m["completing"] for m in self.sent
if m["type"] == "completing"][-1], {},
"the last word to the browser is 'nothing is busy'")
# — every failure gives it back too —
def test_a_merge_conflict_releases_the_card(self):
self.conflict()
with self.assertRaises(ValueError) as caught:
github.complete_task(FILENAME, "review")
self.assertIn("merge conflict", str(caught.exception))
self.assertEqual(self.stage_of(), "review", "the card stays put")
self.assertEqual(state.COMPLETING, {}, "…and comes back to life")
self.assertEqual(git(self.board, "rev-parse", "--verify", "--quiet",
BRANCH).returncode, 0, "the branch survives a conflict")
def test_the_wrong_branch_refusal_releases_the_card(self):
git(self.board, "checkout", "-q", "-b", "side")
with self.assertRaises(ValueError) as caught:
github.complete_task(FILENAME, "review")
self.assertIn("not main", str(caught.exception))
self.assertEqual(state.COMPLETING, {})
def test_a_crash_mid_completion_releases_the_card(self):
self.addCleanup(setattr, github, "_merge_locally", github._merge_locally)
def boom(filename, branch):
raise OSError("git went away")
github._merge_locally = boom
with self.assertRaises(OSError):
github.complete_task(FILENAME, "review")
self.assertEqual(state.COMPLETING, {}, "a finally, not a release per exit")
# — and the second request is refused, not run —
def test_a_second_request_mid_completion_starts_no_second_merge(self):
real = github._merge_locally
self.addCleanup(setattr, github, "_merge_locally", real)
calls, refused = [], []
def merge_then_race(filename, branch):
calls.append(branch)
try:
github.complete_task(FILENAME, "review")
except ValueError as exc:
refused.append(str(exc))
real(filename, branch)
github._merge_locally = merge_then_race
github.complete_task(FILENAME, "review")
self.assertEqual(len(refused), 1, "the second request must be refused")
self.assertIn("already being completed", refused[0])
self.assertEqual(len(calls), 1, "and no second merge attempted")
self.assertEqual(self.stage_of(), "done", "the first run finished normally")
def test_the_refusal_does_not_release_the_run_it_lost_to(self):
state.claim_completing(FILENAME, "merging and cleaning up…")
with self.assertRaises(ValueError):
github.complete_task(FILENAME, "review")
self.assertIn(FILENAME, state.COMPLETING,
"a refused caller must never release what it did not take")
self.assertEqual(self.stage_of(), "review")
class ServedToTheBrowser(unittest.TestCase):
def test_state_payload_carries_the_registry(self):
import httpd
self.assertIn("completing", httpd.state_payload())
def test_a_restart_leaves_nothing_stuck(self):
"""The registry is memory, never disk: a board killed mid-merge
comes back with every card renderable from the files alone."""
source = (REPO / "manager" / "core" / "state.py").read_text(encoding="utf-8")
body = re.search(r"def claim_completing.*?\n(?=\ndef )", source, re.S)
self.assertIsNotNone(body, "claim_completing is gone")
for persistent in ("persist(", "write_text", "open("):
self.assertNotIn(persistent, body.group(0),
"the claim must not outlive the process")
class TheCardFace(unittest.TestCase):
"""board.html is a single file with no frontend runner — these are the
source-level invariants of the surface this card adds."""
@classmethod
def setUpClass(cls):
cls.html = BOARD_HTML.read_text(encoding="utf-8")
cls.card = re.search(r"function cardFor\(task\) \{.*?\n\}", cls.html, re.S).group(0)
def test_the_card_renders_from_the_server_s_truth(self):
self.assertIn("(S.state.completing || {})[task.file]", self.card,
"the busy state comes from /api/state, not from a click")
self.assertIn("msg.type === 'completing'", self.html,
"…and follows the SSE stream between full loads")
def test_it_wears_the_working_vocabulary_and_no_new_colour(self):
completing = re.search(r"if \(completing\) \{\n\s*//.*?\n\s*//.*?\n(.*?)\n \}",
self.card, re.S).group(1)
self.assertIn("text: 'completing'", completing)
self.assertIn("var(--accent)", completing)
for colour in ("--alarm", "--calm", "--idle"):
self.assertNotIn(colour, completing, "busy is not an alarm or a verdict")
flat = self.html.replace(" ", "").replace("\n", "")
self.assertIn(".card.completing{border-color:color-mix(inoklab,var(--accent)55%,var(--border))", flat)
self.assertIn(".card.pill.status.breathing{animation:breathe", flat)
self.assertIn("""class="pill status${completing ? ' breathing' : ''}\"""", self.card)
def test_it_says_which_step(self):
self.assertIn("esc(completing.step", self.card,
"the card carries the latest narrated step")
def test_it_holds_still(self):
self.assertIn("el.draggable = !completing;", self.card)
def test_its_actions_are_suppressed_not_merely_ignored(self):
self.assertRegex(self.card, r"if \(completing\) \{\n(\s*//[^\n]*\n)+\s*\} else if \(agent\)",
"the action builder must fall through to nothing")
self.assertIn("if (task.stage === 'review' && !completing) {", self.card,
"the drive chips go with the worktree being removed")
self.assertIn("if (hasBranch && !completing &&", self.card,
"…and so do the project's command chips")
def test_the_sheet_still_only_opens_for_a_card_with_work(self):
"""Untouched path: no branch and no PR means no sheet, so no
completion and no flash of busy."""
mover = re.search(r"async function move\(file, from, to\) \{.*?\n\}",
self.html, re.S).group(0)
self.assertIn("task && (task.pr || (S.state.branches || []).includes(stem))", mover)
self.assertIn("completeSheet(task, from)", mover)
if __name__ == "__main__":
unittest.main()