From 7df6e19d0d52ff2e91ec0347fe8f58ab9ea6e5ee Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:57:09 +0200 Subject: [PATCH] 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: