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:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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}.")
|
||||
|
||||
Reference in New Issue
Block a user