diff --git a/AGENTS.md b/AGENTS.md index 6ce902e..3734ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/manager/core/board.html b/manager/core/board.html index 1e45357..9fb67cc 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -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 = [ - ``, + ``, `${task.number ? '#' + esc(task.number) : esc(task.file.slice(0, 10))}`, '', high ? 'HIGH' : '', task.statusMismatch ? `drift` : '', - `${pill.text}`, ]; @@ -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 = `
·` + + `${esc(completing.step || 'completing…')}
`; + } 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) } diff --git a/manager/core/github.py b/manager/core/github.py index 4700220..a01d837 100644 --- a/manager/core/github.py +++ b/manager/core/github.py @@ -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}" diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 0cfe1eb..6edd014 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -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. diff --git a/manager/core/state.py b/manager/core/state.py index fbb76a2..2b9c86e 100644 --- a/manager/core/state.py +++ b/manager/core/state.py @@ -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: diff --git a/tests/test_completing_card.py b/tests/test_completing_card.py new file mode 100644 index 0000000..b543c27 --- /dev/null +++ b/tests/test_completing_card.py @@ -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()