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:
+14
-2
@@ -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 & 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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user