A phase card does not move while its work runs

Dragging a phase card between stages while one of its members has an
agent in it is a move nobody can mean: the card lands somewhere its
branch, its worktree and its live agent are not. The board refuses it,
and names the way through rather than the wall — which member is
working, and that ‖ hold stops the phase and the agent it has in flight
while leaving the branch, the merges and every worktree as they were.

- phases.assert_not_working() is the refusal: it reads the one file
  first, so an ordinary card never reaches the question, then resolves
  the phase's list and asks what is actually running.
- agents.working_on() answers that from the processes themselves, not
  from the registry's status alone — the reaper flips that a moment
  after a run ends, and a rule that only refuses must not hold a card
  hostage to a run that has already died. stop_phase() now reads the
  same helper.
- httpd asks it on /api/move, /api/archive (archiving is a move) and
  /api/task/complete, so a stale page cannot get past it.
- The toast wraps, is bounded to the viewport and stays up for as long
  as its text takes to read: a refusal you cannot finish reading is the
  wall this was written against.

What still moves: a phase between members, a halted phase, a held one,
a member card, and every ordinary card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-02 09:23:23 +02:00
co-authored by Claude Opus 5
parent 208077073e
commit 8fbfc1085d
6 changed files with 606 additions and 9 deletions
+29
View File
@@ -119,6 +119,35 @@ def _assert_no_running_agent(filename: str) -> None:
raise ValueError(f"an agent is already working on {filename}")
def _alive(record: dict) -> bool:
"""Is this run actually running, or only recorded as running?
The registry's status is flipped by the reaper thread, a moment after
the process it waits on has gone. Anything that *acts* on a card should
keep out through that moment — the reaper is about to move the card. A
rule that only *refuses* must not: a run that died between two reads
would otherwise lock the card it died on for as long as the board
lives. So this asks the process rather than the record.
"""
proc = record.get("proc")
if proc is None: # nothing to ask: the registry's word stands
return True
try:
return proc.poll() is None
except (OSError, ValueError):
return False
def working_on(files: set[str]) -> list[dict]:
"""The runs alive on these cards, right now — copies, never the
registry itself. `[]` is "nothing is running here", read from what is
actually running rather than from what was once started."""
with state.LOCK:
records = [dict(record) for record in state.AGENTS.values()
if record["task"] in files and record["status"] == "running"]
return [record for record in records if _alive(record)]
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None:
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
+14 -2
View File
@@ -616,13 +616,19 @@
.sheet .sbtns button.shipit small{color:color-mix(in oklab, var(--on-calm) 75%, transparent)}
.sheet .sbtns button.shipit:hover{border-color:var(--calm);color:var(--on-calm);filter:brightness(1.06)}
/* ── toast ── */
/* ── toast ──
A refusal has to say the whole thing — which member is working, and
that ‖ hold stops it — so the pill wraps and stays on the screen
rather than running off both ends of it. */
#toast{
position:fixed;left:50%;bottom:44px;transform:translateX(-50%);z-index:40;display:none;
align-items:center;gap:10px;padding:11px 16px;font-size:13px;color:var(--text);
background:var(--raised);border:1px solid var(--border);border-radius:99px;
box-shadow:0 16px 34px -22px rgba(0,0,0,.8);animation:rise .18s ease;
max-width:min(620px, calc(100vw - 48px));line-height:1.5;
}
#toast.wrapped{border-radius:16px;align-items:flex-start}
#toast.wrapped .dot{margin-top:6px}
#toast.show{display:flex}
#toast .dot{background:var(--calm)}
#toast.err .dot{background:var(--alarm)}
@@ -800,13 +806,19 @@ function sessionElapsed(meta) {
}
let toastTimer = null;
/* A refusal that names what to do instead is longer than "07 → done/", and
a message you cannot finish reading is a wall however politely it is
worded. So the pill takes the shape its text needs and stays up for as
long as that text takes to read, up to a ceiling. */
function toast(message, isError = false) {
const el = $('#toast');
$('#toastmsg').textContent = message;
el.classList.toggle('err', isError);
el.classList.toggle('wrapped', message.length > 90);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3200);
toastTimer = setTimeout(() => el.classList.remove('show'),
Math.min(9000, 3200 + message.length * 28));
}
function applyTheme() {
+12
View File
@@ -177,6 +177,10 @@ class Handler(BaseHTTPRequestHandler):
try:
if path == "/api/move":
payload = self._read_body()
# a phase card stands for cards this view no longer draws,
# so it does not move while one of them has an agent in it
# — the refusal names ‖ hold, which stops both
phases.assert_not_working(payload["file"])
task = taskfiles.move_task(payload["file"], payload["from"], payload["to"])
self._json(200, {"task": task})
elif path == "/api/events":
@@ -233,9 +237,17 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"url": url})
elif path == "/api/task/complete":
payload = self._read_body()
# merge & clean up ends with a move to done/, so the same
# guard: a phase whose member is still working would take
# its branch to main without that member's work in it
phases.assert_not_working(payload["file"], "merge it")
self._json(200, github.complete_task(payload["file"], payload["from"]))
elif path == "/api/archive":
payload = self._read_body()
# archiving is a move, so it takes the same guard — and it
# is the likeliest one: a phase you have given up on is
# exactly the one you would tidy away mid-run
phases.assert_not_working(payload["file"], "archive it")
result = taskfiles.archive_task(payload["file"], payload["from"])
with state.LOCK:
state.LAST_ARCHIVED = result
+77 -7
View File
@@ -653,12 +653,6 @@ def _start(phase: dict, filename: str) -> dict:
return advance(by_file.get(filename) or phase, by_file, by_number)
def _agents_on(files: set[str]) -> list[dict]:
with state.LOCK:
return [dict(r) for r in state.AGENTS.values()
if r["task"] in files and r["status"] == "running"]
def stop_phase(filename: str, stage: str) -> dict:
"""Hold a phase: stop, without unwinding anything.
@@ -691,7 +685,7 @@ def stop_phase(filename: str, stage: str) -> dict:
f"phase log must be writable, or the next beat "
f"would carry on regardless")
held = []
for record in _agents_on({m["file"] for m in phase["members"]}):
for record in agents.working_on({m["file"] for m in phase["members"]}):
try:
agents.stop_agent(record["id"])
except ValueError: # it ended between the read and the ask
@@ -704,3 +698,79 @@ def stop_phase(filename: str, stage: str) -> dict:
SNAPSHOTS[filename] = snapshot
state.broadcast({"type": "board"})
return snapshot
# ── and not moving it while it works ───────────────────────────────────
def _member_name(member: dict) -> str:
"""`31 — Stand up site/` — the same line the phase's own list would
carry, built by the same helper, so the refusal names the member the
way the card that holds it does."""
if not member.get("number"):
return member["file"]
return taskfiles._member_entry(member["number"], member["title"])
def _joined(parts: list[str]) -> str:
return (" and ".join([", ".join(parts[:-1]), parts[-1]])
if len(parts) > 1 else (parts[0] if parts else ""))
def assert_not_working(filename: str, doing: str = "move the card") -> None:
"""A phase card does not move while one of its members has an agent in
it. Refuse, and say the whole thing.
The phase card stands for its members (they are not drawn on the Board
at all), so moving it to another stage, or out to `tasks/archive/`,
which is a move like any other while a member is mid-run is a move
nobody can mean: the card lands somewhere its branch, its worktree and
its live agent are not. The one action that settles it already exists
and does exactly the right thing, so the refusal names it rather than
just saying no: ` hold` stops the run and the agent it has in flight
and unwinds nothing.
What is refused is narrow on purpose. A phase *between* members has
nothing to lose and moves freely, and so does a halted one by
construction nothing is running there, which is precisely when walking
the card back is the thing to do. And "running" is read from the
processes themselves (`agents.working_on`), never from what the log
says was once started, so a member's run that died between two beats
cannot lock its phase card for the life of the board.
An ordinary card never reaches the question: the one file is read, it
is not a phase, and the move goes through untouched.
It reads and never writes, so it does not take `_LOCK` a pass of the
beat can spend minutes in a merge, and a move that waited on one would
be a worse answer than the sliver it closes. A launch landing between
this read and the rename is the same state a `mv` produces, and the
runner already knows how to read it: a member walked out from under
the phase halts it.
"""
stage = taskfiles.find_stage_of(filename)
if stage is None:
return
card = taskfiles.read_task(config.TASKS / stage / filename, stage)
if not card["isPhase"] or not card["cards"]:
return
# only now the whole board, to resolve the listed numbers to cards
phase = _cards()[0].get(filename)
if phase is None:
return
members = {member["file"]: member for member in phase["members"]}
working = {record["task"]: record
for record in agents.working_on(set(members))}
if not working:
return
parts = [f"{working[file].get('name') or 'an agent'} is on "
f"{_member_name(member)}"
for file, member in members.items() if file in working]
# the phase by name, as the header chip says it — the number it opens
# with would be the third one in a sentence that already has two
name = taskfiles.LEADING_NUMBER_RE.sub("", phase["title"]).strip()
raise ValueError(
f"{name or phase['title']} is still working — {_joined(parts)}. ‖ hold stops "
f"the phase and the agent it has in flight, and leaves the phase "
f"branch, everything merged into it and every worktree exactly as "
f"they are. Hold it first, then {doing}.")