completing a card is a state the board holds

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.
This commit is contained in:
istos
2026-07-31 16:57:09 +02:00
parent 028635e3fc
commit 7df6e19d0d
3 changed files with 74 additions and 1 deletions
+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: