Finishing a phase finishes its cards, and clears up after them

Merge & clean up on a phase card now ends the phase: after the merge into
main has actually succeeded, every member the phase merged moves to done/
and its workspace is cleared the way completing an ordinary card clears
its own — worktree removed, local branch deleted, branch on the remote
deleted.

Only what the phase merged. A member is swept when its card settled into
review/ or done/ and its branch is contained in the phase branch (or
there was never a branch to bring) — the same pair the runner reads a
member as merged by. One that halted, was held or was walked back keeps
its card, its worktree and its branch: there is work in them.

Nothing uncommitted is thrown away: a member's worktree comes out without
--force, and a dirty one is reported in the ticker and kept with its
branch rather than forced.

One ending, told once. taskfiles.move_together moves the cards in a
single commit naming all of them (`board: 47, 52 → done with phase 53`,
so it publishes in team mode like any other board commit), and the moves
are marked quiet so the watcher does not also scroll five identical move
lines behind the one line the ending gets.

The other endings are untouched: "just move the card" and archiving the
phase card move no member, because neither puts anything in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-02 10:08:00 +02:00
co-authored by Claude Opus 5
parent 064ed1f505
commit b690060be2
8 changed files with 825 additions and 48 deletions
+14 -2
View File
@@ -2002,6 +2002,11 @@ function closeSheet() { $('#sheetwrap').classList.remove('open'); $('#sheetwrap'
function completeSheet(task, from) {
const d = S.state.drive;
const driving = d && d.task === task.file && ['starting', 'up'].includes(d.status);
// A phase card stands for cards the Board does not draw, so the sheet
// has to say what merging it does to them: the ones it merged go to
// done/ with it and their workspaces go too. Counted the way the lane
// counts them, from the runner's reading and the phase's own log.
const swept = task.isPhase ? mergedIn(task).size : 0;
const wrap = $('#sheetwrap');
wrap.innerHTML =
`<div class="sheet">` +
@@ -2010,13 +2015,15 @@ function completeSheet(task, from) {
`<p>This task has ${task.pr ? 'a PR and ' : ''}a branch with work on it. What should happen?</p>` +
`<div class="sbtns">` +
`<button id="sh-keep">Keep it where it is<small>Nothing moves, nothing changes.</small></button>` +
`<button id="sh-move">Just move the card<small>The branch${task.pr ? ', PR' : ''} and worktree stay as they are.</small></button>` +
`<button id="sh-move">Just move the card<small>The branch${task.pr ? ', PR' : ''} and worktree stay as they are` +
`${task.isPhase ? ", and so does every card in the phase" : ''}.</small></button>` +
// team mode merges on origin: the board never makes a merge commit of
// its own, so every replica's main keeps fast-forwarding
`<button id="sh-ship" class="shipit">Merge &amp; clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge ` +
((S.state.sync || {}).enabled
? `the PR on GitHub, remove the worktree and branches, move the card — local main fast-forwards on the next sync beat.`
: `the branch into main, push${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.`) +
(swept ? ` The ${swept} card${swept === 1 ? '' : 's'} this phase merged go to done/ with it, and their worktrees and branches go too.` : '') +
`</small></button>` +
`</div></div>`;
wrap.classList.add('open');
@@ -2032,7 +2039,12 @@ function completeSheet(task, from) {
});
const data = await res.json();
if (!res.ok) { toast(data.error || 'completion failed — the card stays', true); }
else toast(data.merged ? `${task.file} merged and cleaned up` : `${task.file} moved to done`);
else {
const went = (data.swept || []).length;
toast(data.merged ? `${task.file} merged and cleaned up` +
(went ? `${went} card${went === 1 ? '' : 's'} went to done/ with it` : '')
: `${task.file} moved to done`);
}
await loadState();
});
}
+135 -2
View File
@@ -28,7 +28,7 @@ import drive as drive_mod
import reports
import state
from taskfiles import (STATUS_RE, collect, commit_edit, find_stage_of,
move_task, read_task)
member_entry, move_task, move_together, read_task)
PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts}
_OPENING: set[str] = set() # filenames with a PR-open in flight
@@ -53,6 +53,13 @@ def _branch_exists(branch: str) -> bool:
return _run(["git", "rev-parse", "--verify", "--quiet", branch]).returncode == 0
def _contains(branch: str, tip: str) -> bool:
"""Is `branch` already in `tip`? The same question phases.py asks of a
member a merge leaves no record but this one, and it is the only
thing that can say a phase's ending speaks for a particular card."""
return _run(["git", "merge-base", "--is-ancestor", branch, tip]).returncode == 0
def branch_of(filename: str) -> str:
"""The branch a card's work lives on. Ordinary cards get `task/<stem>`;
a phase runs on `phase/<stem>`, its own integration branch (phases.py),
@@ -493,6 +500,11 @@ def _complete(filename: str, stage: str) -> dict:
"""The steps themselves, run under the claim complete_task holds."""
stem = filename[:-3]
branch = branch_of(filename) # a phase's own branch, or task/<stem>
task = _woven(filename, stage)
# Read before anything is destroyed and acted on only after the merge
# lands: the phase branch is deleted in the middle of this, and it is
# the only thing that can say which members it carried.
brought = _brought(task, branch) if task.get("isPhase") else []
# 1. the app must not keep running code that is about to be merged away
d = drive_mod.DRIVE
@@ -504,6 +516,7 @@ def _complete(filename: str, stage: str) -> dict:
time.sleep(0.5)
merged = False
swept: list[dict] = []
if _branch_exists(branch):
if config.SYNC:
_merge_on_origin(filename, stage, branch)
@@ -522,9 +535,129 @@ def _complete(filename: str, stage: str) -> dict:
"kind": "agent", "actor": "board", "file": filename,
"summary": f"cleaned up: worktree and local branch for {stem} removed"})
# the merge succeeded, so the members' work is in main too
swept = _sweep(filename, task, brought)
move_task(filename, stage, "done", actor="you")
state.broadcast({"type": "board"})
return {"merged": merged}
return {"merged": merged, "swept": [card["file"] for card in swept]}
def _brought(task: dict, phase_branch: str) -> list[dict]:
"""The members a phase actually merged — the only cards its ending
speaks for.
Two things have to be true, and they are the same pair phases.py reads
a member as `merged` by: the card settled into `review/` or `done/`,
and its branch is contained in the phase branch or there was never a
branch to bring, which is a member finished before the phase reached
it. Containment alone is not enough, for the reason it is not enough
there either: a run that exits without committing leaves an empty
branch that is trivially contained.
A member that halted, was held or was walked back satisfies neither.
Its card stays where it is and so do its worktree and its branch:
there is work in them, and it is the reason a person will look at this
phase afterwards. Removing a worktree with work in it is the one
unrecoverable thing here.
"""
brought = []
for member in task.get("members") or []:
if member.get("stage") not in ("review", "done"):
continue
branch = f"task/{member['file'][:-3]}"
if _branch_exists(branch) and not _contains(branch, phase_branch):
continue
brought.append(member)
return brought
def _clear_member(member: dict) -> str:
"""One merged member's workspace, cleared exactly as completing an
ordinary card clears its own: the worktree, the local branch, the
branch on the remote. Returns what to say when something was kept.
The worktree comes out without `--force`, which is the whole
difference from the card's own cleanup above. A member's work is in
`main` by the time this runs, so nothing in there is *needed* but
that is a judgement about committed files, and an uncommitted change
is exactly what it does not cover. A dirty worktree is reported and
kept, with its branch, rather than forced: this is the one
unrecoverable step in the ending, and it is asked before it is taken
rather than left to git to refuse.
"""
stem = member["file"][:-3]
branch = f"task/{stem}"
worktree = config.WORKTREES / stem
try:
if worktree.exists():
dirty = _run(["git", "-C", str(worktree), "status",
"--porcelain"]).stdout.split("\n")
dirty = [line for line in dirty if line.strip()]
if dirty:
return (f"{stem}: worktree kept — {len(dirty)} uncommitted "
f"change{'' if len(dirty) == 1 else 's'} in it")
removed = _run(["git", "worktree", "remove", str(worktree)])
if removed.returncode != 0:
detail = (removed.stderr.strip() or removed.stdout.strip()
or "git would not remove it")
return f"{stem}: worktree kept — {detail.splitlines()[-1][:120]}"
if _branch_exists(branch):
# -D under sync for the same reason the card's own branch takes
# it: main here has not caught up with the merge origin made.
_run(["git", "branch", "-D" if config.SYNC else "-d", branch])
rname = remote()
if rname:
_run(["git", "push", rname, "--delete", branch], timeout=60)
except (subprocess.SubprocessError, OSError) as exc:
return f"{stem}: workspace kept — {str(exc)[:120]}"
PR_STATE.pop(member["file"], None)
return ""
def _sweep(filename: str, task: dict, brought: list[dict]) -> list[dict]:
"""Finish the cards the phase finished.
A member stops at `review/` on purpose `done/` has always meant
merged into `main`, and merged into a phase branch is not that. This
is the moment it becomes that: the phase's PR is in `main`, so every
card it carried is too, and leaving them in the column whose note is
"your move" would hand back a pile of work already judged.
One ending, so it is told as one: the cards move together in a single
commit (`taskfiles.move_together`) and the ticker gets one line naming
the phase and how many went with it, rather than five moves scrolling
past. What was *not* swept is named too a phase that left cards
behind is a phase somebody still has to look at.
"""
members = task.get("members") or []
if not members: # a phase card listing nobody
return []
kept = [note for note in (_clear_member(member) for member in brought) if note]
moved = move_together([(member["file"], member["stage"]) for member in brought
if member["stage"] != "done"],
"done", f"with phase {task['number'] or filename[:-3]}")
left = len(members) - len(brought)
name = (member_entry(task["number"], task["title"]) if task["number"]
else task["title"])
summary = f"phase {name} finished — "
summary += (f"{_cards(len(brought))} merged into it went to done/, worktrees "
f"and branches cleaned up" if brought
else "it had merged nothing, so no card went with it")
if left:
summary += (f" · {_cards(left)} it never merged "
+ ("stays where it is" if left == 1 else "stay where they are"))
state.record_board_event({"kind": "phase", "actor": "board",
"file": filename, "summary": summary})
for note in kept:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"{note} — nothing uncommitted is thrown away"})
return moved
def _cards(count: int) -> str:
return f"{count} card{'' if count == 1 else 's'}"
def _merge_locally(filename: str, branch: str) -> None:
+1 -1
View File
@@ -709,7 +709,7 @@ def _member_name(member: dict) -> str:
way the card that holds it does."""
if not member.get("number"):
return member["file"]
return taskfiles._member_entry(member["number"], member["title"])
return taskfiles.member_entry(member["number"], member["title"])
def _joined(parts: list[str]) -> str:
+24 -11
View File
@@ -21,7 +21,7 @@ SESSIONS: dict[str, dict] = {} # session_id -> meta
EVENTS: dict[str, list[dict]] = {} # session_id -> slim events
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)
EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float, bool]] = {} # (file, to) -> (actor, ts, quiet)
COMMIT_HOOKS: list = [] # run after a board-made task commit
COMPLETING: dict[str, dict] = {} # filename -> {started, step}: merge & clean up in flight
@@ -173,17 +173,30 @@ def task_committed(filename: str) -> None:
pass
def expect_move(filename: str, target: str, actor: str) -> None:
"""Tell the watcher who is about to move a file so it can attribute it."""
def expect_move(filename: str, target: str, actor: str, quiet: bool = False) -> None:
"""Tell the watcher who is about to move a file so it can attribute it.
`quiet` adds the other half of that: the mover has already narrated
this one, so the watcher renders it and says nothing. A phase ending
moves every card it merged at once and reports it as the one thing it
is; five identical move lines scrolling behind that would be the same
fact told badly. It suppresses the ticker line only the move is
still a move, and everything the board hangs off one still happens.
"""
with LOCK:
EXPECTED_MOVES[(filename, target)] = (actor, time.time())
EXPECTED_MOVES[(filename, target)] = (actor, time.time(), quiet)
def claim_move(filename: str, target: str) -> tuple[str, bool]:
"""Who moved this, and whether they already said so."""
with LOCK:
expected = EXPECTED_MOVES.pop((filename, target), None)
# forget stale expectations while we're here
cutoff = time.time() - 30
for key in [k for k, (_, ts, _) in EXPECTED_MOVES.items() if ts < cutoff]:
EXPECTED_MOVES.pop(key, None)
return (expected[0], expected[2]) if expected else ("disk", False)
def claim_expected(filename: str, target: str) -> str:
with LOCK:
actor_ts = EXPECTED_MOVES.pop((filename, target), None)
# forget stale expectations while we're here
cutoff = time.time() - 30
for key in [k for k, (_, ts) in EXPECTED_MOVES.items() if ts < cutoff]:
EXPECTED_MOVES.pop(key, None)
return actor_ts[0] if actor_ts else "disk"
return claim_move(filename, target)[0]
+70 -15
View File
@@ -375,14 +375,13 @@ def _number(filename: str) -> str | None:
return match.group(1) if match else None
def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
number: str | None) -> None:
"""The move and the claim in one commit.
def _move_spec(src: Path, dst: Path) -> list[str]:
"""The paths one move's commit names.
Both paths are named, so git records a rename rather than a delete and
an add and a card git has never seen (a brand-new backlog file) names
only its destination, since a pathspec matching nothing in HEAD would
fail the commit outright.
Both ends, so git records a rename rather than a delete and an add
and a card git has never seen (a brand-new backlog file) names only its
destination, since a pathspec matching nothing in HEAD would fail the
commit outright.
"""
spec = [str(dst)]
try:
@@ -391,12 +390,19 @@ def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
spec.insert(0, str(src)) # git knew the old path: record its removal
except (subprocess.SubprocessError, OSError):
pass
return spec
def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
number: str | None) -> None:
"""The move and the claim in one commit."""
_commit(filename, f"board: {number or filename[:-3]}{target} ({who or 'board'})",
spec, f"{filename} moved, but committing it failed")
_move_spec(src, dst), f"{filename} moved, but committing it failed")
def _relocate(filename: str, src: Path, dst: Path, text: str, target: str,
actor: str, who: str | None = None) -> None:
actor: str, who: str | None = None, commit: bool = True,
quiet: bool = False) -> None:
"""The one door out of a directory under tasks/: register who is doing
it, write the file, move it, commit it.
@@ -406,12 +412,16 @@ def _relocate(filename: str, src: Path, dst: Path, text: str, target: str,
and the commit message call the destination (a stage slug, or
`archived`), and `who` is this checkout's git name when the caller has
already paid for it.
`commit` and `quiet` are what a batch of moves turns off: several cards
that moved as one thing are committed as one thing and narrated as one
thing (`move_together`), and both would be wrong done per file.
"""
state.expect_move(filename, target, actor)
state.expect_move(filename, target, actor, quiet=quiet)
dst.parent.mkdir(parents=True, exist_ok=True)
src.write_text(text, encoding="utf-8")
shutil.move(str(src), str(dst))
if config.COMMIT_MOVES:
if config.COMMIT_MOVES and commit:
_commit_move(filename, target, src, dst,
actor_name() if who is None else who, _number(filename))
@@ -492,7 +502,7 @@ PHASE_JOIN_FROM = {"backlog", "to-do"} # a card joins a phase before it starts
PHASE_HOST = "to-do" # and only a phase still waiting accepts it
def _member_entry(number: str, title: str) -> str:
def member_entry(number: str, title: str) -> str:
"""`33 — The landing page` — the way a person writes it.
The section is authored by hand and read by hand, so a line the board
@@ -569,7 +579,7 @@ def add_to_phase(filename: str, stage: str, phase_file: str) -> dict:
else f"phase {_phase_name(holder)} already")
raise ValueError(f"{where} lists {card['number']}")
entry = _member_entry(card["number"], card["title"])
entry = member_entry(card["number"], card["title"])
if not append_to_section(phase_file, PHASE_HOST, "Cards", f"- {entry}",
f"gained {number}"):
raise ValueError(f"{phase_file} could not be written")
@@ -578,12 +588,16 @@ def add_to_phase(filename: str, stage: str, phase_file: str) -> dict:
"entry": entry, "line": f"- {entry}"}
def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict:
def move_task(filename: str, source: str, target: str, actor: str = "you",
commit: bool = True, quiet: bool = False) -> dict:
"""Move a task file between stage directories and fix its Status line.
With `BOARD_COMMIT_MOVES` on, the move also claims the card (an
**Assignee:** line, this checkout's git name) or releases it when the
card is walked back to backlog, and commits the whole change.
`commit` and `quiet` belong to `move_together` below a lone move
commits itself and narrates itself, as it always has.
"""
if source not in config.STAGE_DIRS or target not in config.STAGE_DIRS:
raise ValueError("unknown stage")
@@ -612,5 +626,46 @@ def move_task(filename: str, source: str, target: str, actor: str = "you") -> di
elif name and claims(source, target):
text = _set_assignee(text, name)
_relocate(filename, src, dst, text, target, actor, who=name)
_relocate(filename, src, dst, text, target, actor, who=name,
commit=commit, quiet=quiet)
return read_task(dst, target)
def move_together(moves: list[tuple[str, str]], target: str, note: str,
actor: str = "you") -> list[dict]:
"""Move several cards into one stage as one thing.
A phase's ending is one event that happens to move five files, so the
record it leaves should be one too: one commit naming every card that
moved, rather than five `board: NN done` lines in a row that a
reader of `git log` has to reassemble into the thing that happened.
The message still says what moved the numbers are in it and it
carries the `board: ` prefix, so in team mode it reaches the other
boards on the same push every other move rides.
The moves are quiet for the same reason (see `state.expect_move`): the
caller narrates the ending, and the ticker does not also scroll the
parts. Each takes `moves` as `(filename, source stage)`.
A card that cannot be moved gone, or already in `target` is
skipped rather than failing the rest. This runs after work that has
already landed in `main`, and finishing four cards beats abandoning
the sweep over the fifth.
"""
moved, spec = [], []
for filename, source in moves:
src = config.TASKS / source / filename
dst = config.TASKS / target / filename
try:
moved.append(move_task(filename, source, target, actor=actor,
commit=False, quiet=True))
except (ValueError, OSError):
continue
spec += _move_spec(src, dst)
if moved and config.COMMIT_MOVES:
numbers = ", ".join(task["number"] or task["file"][:-3] for task in moved)
_commit(moved[0]["file"],
f"board: {numbers}{target}" + (f" {note}" if note else "")
+ f" ({actor_name() or 'board'})",
spec, f"{len(moved)} cards moved, but committing them failed")
return moved
+20 -12
View File
@@ -30,20 +30,25 @@ def _board_sig() -> dict[str, set[str]]:
return sig
def _actor(filename: str, stage: str) -> tuple[str, bool]:
"""Who did this, and whether it happened somewhere else.
def _actor(filename: str, stage: str) -> tuple[str, bool, bool]:
"""Who did this, whether it happened somewhere else, and whether it has
already been narrated.
A move this board made is claimed from the expectations; one a pull
brought carries its commit author's name and is *remote* — this board
is only rendering it; a plain mv on this disk is nobody in particular,
but it is still this board's own disk, so it acts.
*Quiet* is the mover saying it has told this story already: a phase
ending sweeps its whole list into done/ and reports one ending. The
line is skipped; nothing else about the move is.
"""
actor = state.claim_expected(filename, stage)
actor, quiet = state.claim_move(filename, stage)
if actor == "disk":
who = sync.arrived_actor(filename)
if who:
return who, True
return actor, False
return who, True, False
return actor, False, quiet
def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
@@ -52,12 +57,13 @@ def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
cur_loc = {f: s for s, files in cur.items() for f in files}
for f, stage in sorted(cur_loc.items()):
if f in prev_loc and prev_loc[f] != stage:
actor, remote = _actor(f, stage)
state.record_board_event({
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
"actor": actor, "remote": remote,
"summary": f"{f} moved {prev_loc[f]}{stage} ({actor})",
})
actor, remote, quiet = _actor(f, stage)
if not quiet:
state.record_board_event({
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
"actor": actor, "remote": remote,
"summary": f"{f} moved {prev_loc[f]}{stage} ({actor})",
})
# a failed run is worn by the card in the stage it died in —
# wherever the card goes next, it arrives without the alarm
agents.forget_failure(f)
@@ -67,7 +73,9 @@ def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
# per replica
github.open_pr_async(f)
elif f not in prev_loc:
actor, remote = _actor(f, stage)
actor, remote, quiet = _actor(f, stage)
if quiet:
continue
state.record_board_event({
"kind": "new", "file": f, "to": stage, "actor": actor,
"remote": remote,