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:
+19
-1
@@ -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}"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user