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()