12 KiB
38 — A card being merged and cleaned up looks like it, and holds still
Status: Done PR: https://github.com/12vectors/bench/pull/37 Assignee: istos Priority: Medium — a daily-path illusion, with a double-fire hazard sitting behind it Type: Feature
Choosing Merge & clean up on a card dragged to done/ starts a long
piece of work — park the drive, merge on GitHub, remove the worktree,
delete the branch, move the card — and the card shows none of it. It sits
in review/ looking idle for as long as the merge takes, fully
interactive, while the board is midway through disassembling its branch.
Give the card the state it is actually in, and take its actions away until
it is out of it.
Context
- The sheet's ship handler is
manager/core/board.html:1386-1397: it callscloseSheet(), fires one toast ("the ticker narrates each step"), then awaitsPOST /api/task/completeand only redraws when that returns. - What it is waiting for is
github.complete_task()(manager/core/github.py:383): stopping this task's drive and polling up to 20 seconds for it to die (:404-409),_merge_on_origin()or_merge_locally(),git worktree remove --force,git branch -D, thenmove_task(... 'done'). Only the last step changes anything the card renders. - The steps are narrated — each records a board event against the file and broadcasts — so the ticker tells the story while the card contradicts it.
- Nothing guards a second request.
/api/task/complete(manager/core/httpd.py:197) takes the call and starts work; the card is still draggable, so a second drag re-opens the sheet, and ↻ act on PR, ◔ review PR, ⛭ drive and ↩ back all remain armed on a card whose branch is being deleted. - The vocabulary for this already exists and should be reused rather than
reinvented:
--accentplus the breathe animation means an agent is working (board.html:207-239carries therest → armed → busystates for hover actions),◌accent chips already mark a starting agent (:1088) and a running local command (:1115), and a working card already carries a live activity line.
Affected areas: manager/core/board.html (card render, the complete
sheet, drag), manager/core/state.py (where shared registries live),
manager/core/github.py and manager/core/httpd.py (claim, release,
refuse).
What to build
- A card-level busy state, server-held.
complete_taskclaims the card in a registry instate.pybefore it does anything, and releases it in afinally— success, conflict or crash./api/stateexposes it, so the board renders from truth rather than from what this tab happens to have clicked. - Render it in the design's own terms. The card takes the accent border and a breathing status pill in the slot the status pill already owns — the same language as an agent working, because that is what is happening. No new colour: this is not an alarm and not a settled state.
- Say which step. The steps are already recorded as board events against the file; show the latest one on the card the way a working card carries its activity line, so "parking the drive", "merged task/29-… into main" and "cleaned up: worktree and local branch removed" land on the card and not only in the ticker.
- Take the actions away. While a card is claimed: no hover actions, not draggable, and the drawer's actions for it inert. Suppressed, not merely ignored on click — an action that looks available and does nothing is the same lie in a different place.
- Refuse the second request. A
completefor a card already claimed returns a readable error rather than starting a second merge, and the toast says the card is already being completed. - Release loudly on failure. A merge conflict aborts cleanly today
and the card stays in
review/; it must also come fully back to life, with the existing error toast unchanged. A card stuck busy forever is worse than the problem this card fixes.
Out of scope — tempting neighbours left alone:
- Hover actions that already run through
rest → armed → busy(board.html:1191-1230). They are fine; the sheet path is what skipped the pattern. - What
complete_taskactually does. Not one git command changes here. - Cross-board liveness. Agent registries stay in each board's own memory, and "State syncs; reactions don't" applies: a replica sees this card unchanged until the move arrives. Making busy states travel is a different design decision entirely.
- A progress bar or step counter. The steps are not uniform and their count is not known up front; the latest narrated line is the honest rendering.
Acceptance
- Given a
review/card with a branch, when Merge & clean up is chosen, then the card takes the busy treatment immediately — before the drive is parked — and holds it until it lands indone/. - While busy, the card cannot be dragged, shows no hover actions, and its drawer actions do nothing — verified by trying each of ↻ act on PR, ◔ review PR, ⛭ drive and ↩ back.
- The card shows what is happening, tracking the steps the ticker narrates rather than a single static word.
- Given a second
POST /api/task/completefor a card already being completed, when it arrives, then it is refused with a readable message and no second merge is attempted. - Given a merge conflict, when the request fails, then the card
returns to
review/fully interactive and the failure toast is unchanged from today. - Edge case: the board restarted mid-completion leaves no card stuck busy — the registry is in memory and dies with the process, and the card renders from disk as it always did.
- Edge case: a card with no branch and no PR still moves to
done/with no sheet and no flash of busy — that path is untouched. - With
BOARD_SYNCon, a second board shows the card unchanged during the merge and picks up the move when it syncs.
Notes
The gap is narrow but it is on the most consequential click on the board: the one that merges. Everything else the board does at length — agents, drives, commands, Copilot — already wears its work on the card. This one path narrates into the bar and leaves the card claiming nothing is happening.
Risks — the claim has to be released on every exit from
complete_task, including the raise ValueError paths for a conflict and
for a repo on the wrong branch. A finally around the body, not a release
after each failure point.
Work report — 2026-07-31 16:57 (Otto)
Done and committed on task/38-a-card-being-completed-looks-like-it — three commits, working tree clean, full suite green.
A card being merged and cleaned up now wears a server-held busy state; committed in three commits and verified with python3 -m unittest (617 tests, OK), 23 of them new.
What changed:
manager/core/state.py— aCOMPLETINGregistry (filename →{started, step}) withclaim_completing/release_completing/completing_public/publish_completing.record_board_eventfolds the latest summary narrated against a claimed file into itsstep, so the steps that already reach the ticker become the card's line without any caller reporting twice. It is memory only, so a board killed mid-completion leaves nothing stuck.manager/core/github.py—complete_taskvalidates, claims, then runs the (unchanged) steps in a new_completehelper insidetry/finally, releasing on every exit. A claim that fails raises before thetry, so a refused second request never releases the run it lost to. Not one git command changed.manager/core/httpd.py—/api/statecarriescompleting.manager/core/board.html— the card takes.card.completing(accent border,cursor:default), a breathingcompletingpill in the status pill's slot, and the latest step in the activity well. While claimed it builds no hover actions, drops the drive and$-command chips, and setsdraggable = false; the drawer's pill readscompletingtoo. A new SSEcompletingmessage keeps it live between full state loads, and the ship toast now says the card shows each step.AGENTS.md— a paragraph under "merge & clean up" describing the state, its scope and its release.
Two things worth a reviewer's eye first:
tests/test_completing_card.py::ACompletionInFlight::test_a_second_request_mid_completion_starts_no_second_merge— the secondcomplete_taskis fired re-entrantly from inside the first one's merge, which is the closest an in-process test gets to the real race; it asserts the refusal message, that only one merge ran, and that the first run still finished.- I could not verify this in a browser — no browser tooling is available in this session, so the card face is covered by source-level invariants in
TheCardFace(the same approachtests/test_card_actions.pytakes) rather than by looking at it. A human clicking Merge & clean up once is the check I did not do.
PR review — 2026-07-31 17:04 (Ada)
PR REVIEW: APPROVE
The work is committed on task/38-a-card-being-completed-looks-like-it (3 commits) and merges cleanly against main; I approve it. GitHub refused a formal --approve because the authenticated gh account authored the PR, so the verdict is posted to the PR as a review comment instead — a human still needs to click merge.
What the change does, and that it matches the task
A card being merged and cleaned up now wears a server-held busy state. The claim lives in manager/core/state.py (COMPLETING, guarded by LOCK), is exposed on /api/state and pushed over a new SSE completing message, and the card in manager/core/board.html renders from that truth rather than from the tab that clicked.
What I checked (all confirmed):
- The stated risk — release on every exit.
github.py:complete_taskvalidates stage and file-on-disk before claiming, then runs the unchanged body as_completeinsidetry/finally. Release fires on success, merge conflict, wrong-branch refusal, and an arbitrary crash. The refused second caller raises before thetry, so it never releases the run it lost to. Every one of these paths has a real-git test. - Lock discipline.
publish_completingre-acquiresLOCKviacompleting_public; every call site invokes it outside the held lock, so the plain (non-reentrant)threading.Lockcannot deadlock.completing_publicreturns per-record copies, so the browser never aliases the live dict.timeis imported. - The card holds still.
draggable = !completing; the action builder falls through to nothing underif (completing); drive and command chips drop; the drawer pill readscompleting. So↻ act on PR,◔ review PR,↩ back, and⛭ driveare all suppressed, not merely ignored on click. - Untouched paths. No-branch/no-PR cards route through
rawMove, never/api/task/complete, so no flash of busy.ThreadingHTTPServermeans the synchronous completion never freezes SSE. Correct layering — all code changes are in core, plus a documenting paragraph inAGENTS.md. Reused--accent+breathe; no new colour.
To do before merging:
- Click Merge & clean up once on a real review card and watch the card face. I could not run a browser and neither could the author — the card surface is covered only by source-level invariants in
tests/test_completing_card.py::TheCardFace. - Run
python3 -m unittest discover -s testslocally. Test execution was approval-gated in my session, so the "617 green / 23 new" figure is from reading the tests, not running them.