From 7df6e19d0d52ff2e91ec0347fe8f58ab9ea6e5ee Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:57:09 +0200 Subject: [PATCH 1/3] completing a card is a state the board holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge & clean up is a minute of destructive work behind one click, and nothing recorded that it was running: /api/task/complete took a second request as readily as the first. state.py grows COMPLETING — claimed before the first step, released in a finally after the last one, on success, conflict and crash alike. The steps are already narrated as board events against the file, so record_board_event folds the latest summary into the claim rather than asking complete_task to report twice. It is memory, not disk: a board killed mid-completion leaves no card stuck busy. /api/state carries the registry and every change publishes it, so the board renders the busy card from the server's truth rather than from what one tab happened to click. --- manager/core/github.py | 20 +++++++++++++++- manager/core/httpd.py | 3 +++ manager/core/state.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) 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: From 5736126142c7c2a9efeb5758ad6d8bda6ce221ca Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:57:16 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=80=A6and=20the=20card=20wears=20it,=20a?= =?UTF-8?q?nd=20holds=20still?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card takes the accent border and a breathing `completing` pill in the slot the status pill already owns — the working vocabulary, because that is what is happening — and carries the latest narrated step on its activity line, so "parking the drive" and "cleaned up: worktree and local branch removed" land on the card and not only in the ticker. While it is claimed the card offers nothing: no hover actions, no drive or command chips (both run against the worktree being removed), and not draggable, so the sheet cannot be reopened behind the run. Suppressed, not ignored on click — an action that looks available and does nothing is the same lie in a different place. --- AGENTS.md | 14 +++++++++++ manager/core/board.html | 55 ++++++++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e9cd0f..4808fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -470,6 +470,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) } From a1124ec9914864818332dbea79c57659904c7501 Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:57:17 +0200 Subject: [PATCH 3/3] tests: the claim, the refusal, and the release on every exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against a real repo with a real branch: a real merge, a real conflict, the wrong-branch refusal, a crash, and a second complete arriving from inside the first one's merge — which is refused, starts no second merge, and does not release the run it lost to. Plus the registry's own rules and the card-face invariants in board.html. --- tests/test_completing_card.py | 362 ++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/test_completing_card.py 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()