Merge pull request #49 from 12vectors/task/58-a-phase-card-will-not-move-while-its-work-runs

58 — A phase card will not move while its work is running
This commit is contained in:
Ronald Ashri
2026-08-02 09:32:59 +02:00
committed by GitHub
6 changed files with 606 additions and 9 deletions
+19
View File
@@ -949,6 +949,25 @@ and they are the phase's own interface:
watching is only trustworthy if its halt is impossible to miss, so it is
told three times at three altitudes, exactly as a dead run is.
**And the card does not move while its work runs.** A phase card stands
for cards the Board no longer draws, so dragging it to another stage — or
onto the archive tray, which is a move like any other — while a member has
an agent in it would leave the card in one place and a live run, a
worktree and a branch in another. That move is refused, and the refusal
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 phase branch, everything merged into it and every worktree
exactly as they are. The card stays where it was and the reason is in the
toast. The answer is the server's — `/api/move`, `/api/archive` and
merge-and-clean-up all ask it, so a stale page cannot get past a rule the
drag alone would enforce. What is refused is narrow on purpose: a phase
*between* members has nothing in flight and moves freely, a halted one has
nothing running by construction and moves freely — which is exactly when
walking it back is the thing to do — and "running" is read from the runs
themselves rather than from what the log says was once started, so a
member's run that died cannot lock its phase card. An ordinary card never
reaches the question.
Every advance is narrated in the ticker as it happens: the member that
came up green, the merge into the phase branch, the member starting next.
And the phase card's sheet lists its members in run order with each one's
+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}.")
+455
View File
@@ -0,0 +1,455 @@
"""A phase card will not move while its work is running (task 58).
The phase card stands for members the Board no longer draws, so moving it
mid-run to another stage, or out to `tasks/archive/`, which is a move
like any other leaves the card in one place and a live agent, a worktree
and a branch in another. `phases.assert_not_working` is the refusal, and
these cases are about its edges as much as its middle: a phase between
members moves, a halted one moves, an ordinary card never reaches the
question, and a run that died but has not been reaped does not lock its
phase card for the life of the board.
The guard is the server's, so the last case here goes through a real
`httpd.Handler` on a real socket: that is the path a stale page takes.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
import agents # noqa: E402
import config # noqa: E402
import github # noqa: E402
import httpd # noqa: E402
import phases # noqa: E402
import state # noqa: E402
import taskfiles # noqa: E402
from tests.test_phase_runs import PhaseCase, wait_for # noqa: E402
# An agent that is still working when the question is asked.
SLEEPS = """#!/usr/bin/env python3
import time
time.sleep(60)
"""
PHASE = "40-ship-the-site.md"
ONE = "31-stand-up-site.md"
TWO = "32-serve-it.md"
PLAIN = "33-landing.md"
HOLD = "‖ hold"
class FakeProc:
"""A launched process, as the guard asks after it: alive while poll()
says None, gone the moment it says anything else which is what the
reaper is about to notice too."""
def __init__(self, alive: bool = True):
self.rc = None if alive else 0
def poll(self):
return self.rc
def died(self) -> None:
self.rc = 1
def card(title: str, *, status: str = "Backlog", kind: str | None = None,
cards: str | None = None, log: str | None = None) -> str:
text = f"# {title}\n\n**Status:** {status}\n**Priority:** High\n"
if kind:
text += f"**Type:** {kind}\n"
text += "\nWhat this card is for, at enough length to be a brief.\n"
if cards is not None:
text += f"\n## Cards\n\n{cards}\n"
if log is not None:
text += f"\n## Phase log\n\n{log}\n"
return text
RUNNING = "- 2026-08-02 09:00 · run started on phase/40-ship-the-site\n"
HALTED = (RUNNING + "- 2026-08-02 09:05 · 31 started\n"
"- 2026-08-02 09:20 · halted at 31 — its CI is red\n")
HELD = (RUNNING + "- 2026-08-02 09:05 · 31 started\n"
"- 2026-08-02 09:20 · stopped — held by tester\n")
class PhaseCardCase(unittest.TestCase):
"""A phase in in-progress/ listing two members, and an agent registry
this test writes by hand the guard reads the registry and the disk,
and both are cheaper to state than to stage."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp(prefix="bench-phase-move-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
self.repo = self.tmp / "repo"
self.repo.mkdir()
subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)],
check=True, capture_output=True)
self.tasks = self.repo / "tasks"
for slug in (*config.STAGE_DIRS, "archive"):
(self.tasks / slug).mkdir(parents=True)
self.patch(REPO=self.repo, TASKS=self.tasks, TM_ROOT=self.repo,
WORKTREES=self.tmp / "worktrees", COMMIT_MOVES=False, SYNC=False)
for registry in (state.AGENTS, state.BOARD_EVENTS, state.EXPECTED_MOVES,
github.PR_STATE, phases.SNAPSHOTS):
registry.clear()
self.addCleanup(registry.clear)
self.addCleanup(setattr, state, "broadcast", state.broadcast)
state.broadcast = lambda message: None
self.write(ONE, card("31 — Stand up site/", status="In Progress"),
"in-progress")
self.write(TWO, card("32 — Serve it"))
self.write(PLAIN, card("33 — The landing page"))
self.phase_log(RUNNING)
# — the world —
def patch(self, **values) -> None:
for attr, value in values.items():
self.addCleanup(setattr, config, attr, getattr(config, attr))
setattr(config, attr, value)
def write(self, filename: str, text: str, stage: str = "backlog") -> None:
(self.tasks / stage / filename).write_text(text, encoding="utf-8")
def phase_log(self, log: str, stage: str = "in-progress") -> None:
"""Rewrite the phase card with the log a run would have left."""
for slug in config.STAGE_DIRS:
(self.tasks / slug / PHASE).unlink(missing_ok=True)
self.write(PHASE, card("40 — Ship the site", status="In Progress",
kind="Phase",
cards="- 31 — Stand up site/\n- 32 — Serve it\n",
log=log), stage)
def stage_of(self, filename: str) -> str | None:
for slug in (*config.STAGE_DIRS, "archive"):
if (self.tasks / slug / filename).is_file():
return slug
return None
# — the registry —
def working(self, filename: str, name: str = "Wren",
alive: bool = True, status: str = "running") -> FakeProc:
proc = FakeProc(alive)
agent_id = f"a{len(state.AGENTS) + 1}"
with state.LOCK:
state.AGENTS[agent_id] = {
"id": agent_id, "task": filename, "status": status,
"name": name, "proc": proc, "mode": "work",
"started": time.time(), "branch": None, "worktree": None,
"rc": None, "session": None}
return proc
# — the question —
def refusal(self, filename: str = PHASE, doing: str = "move the card") -> str:
with self.assertRaises(ValueError) as caught:
phases.assert_not_working(filename, doing)
return str(caught.exception)
class TheRefusal(PhaseCardCase):
def test_a_move_is_refused_while_a_member_has_an_agent(self):
self.working(ONE)
reason = self.refusal()
self.assertIn("31 — Stand up site/", reason)
self.assertIn(HOLD, reason)
self.assertIn("Hold it first, then move the card.", reason)
def test_it_names_who_is_working_and_what_hold_leaves_alone(self):
"""A refusal that only says no makes the person guess, and the
guess is usually to force it."""
self.working(ONE, name="Juno")
reason = self.refusal()
self.assertIn("Juno is on 31 — Stand up site/", reason)
self.assertIn("Ship the site", reason) # which phase
self.assertIn("worktree", reason) # what is left as it was
self.assertIn("phase branch", reason)
def test_the_whole_refusal_verbatim(self):
"""The board's first real "no" on a drag, so the wording is the
feature: an instruction, not a wall. Pinned in full here if this
has to change, it should be because someone meant it to."""
self.working(ONE, name="Juno")
self.assertEqual(
self.refusal(),
"Ship the site is still working — Juno is on 31 — Stand up site/. "
"‖ hold stops the phase and the agent it has in flight, and leaves "
"the phase branch, everything merged into it and every worktree "
"exactly as they are. Hold it first, then move the card.")
def test_every_working_member_is_named(self):
"""Members run one at a time, but a card started by hand under a
running phase is exactly the state that needs saying in full."""
self.working(ONE, name="Juno")
self.working(TWO, name="Basil")
reason = self.refusal()
self.assertIn("Juno is on 31 — Stand up site/", reason)
self.assertIn("Basil is on 32 — Serve it", reason)
def test_an_unnamed_run_still_reads_as_a_sentence(self):
"""A board restarted mid-run forgets the names it handed out."""
self.working(ONE, name=None)
self.assertIn("an agent is on 31 — Stand up site/", self.refusal())
def test_archiving_takes_the_same_guard(self):
"""A phase you have given up on is precisely the one you would
archive, and doing it mid-run leaves a branch, a worktree and a
running agent belonging to a card that is no longer on the board."""
self.working(ONE)
reason = self.refusal(doing="archive it")
self.assertIn("31 — Stand up site/", reason)
self.assertIn(HOLD, reason)
self.assertIn("Hold it first, then archive it.", reason)
class WhatStillMoves(PhaseCardCase):
"""The rule is narrow on purpose: refusing a phase that has nothing in
flight is a rule people learn to resent."""
def test_a_phase_between_members_moves(self):
"""Running, nothing launched — there is nothing to lose."""
self.assertIsNone(phases.assert_not_working(PHASE))
taskfiles.move_task(PHASE, "in-progress", "to-do")
self.assertEqual(self.stage_of(PHASE), "to-do")
def test_a_held_phase_moves_and_archives(self):
"""`‖ hold` is what the refusal asks for, so the card has to move
the moment it has been used the run stopped, the agent with it."""
proc = self.working(ONE)
self.refusal() # refused while it runs
with state.LOCK: # what stop_agent leaves behind
for record in state.AGENTS.values():
record["status"] = "stopped"
proc.died()
self.phase_log(HELD)
self.assertIsNone(phases.assert_not_working(PHASE))
taskfiles.move_task(PHASE, "in-progress", "to-do")
taskfiles.archive_task(PHASE, "to-do")
self.assertEqual(self.stage_of(PHASE), "archive")
def test_a_halted_phase_moves(self):
"""A halt has nothing running by construction — and walking the
card back is exactly what you would want to do then."""
self.phase_log(HALTED)
self.assertIsNone(phases.assert_not_working(PHASE))
taskfiles.move_task(PHASE, "in-progress", "to-do")
self.assertEqual(self.stage_of(PHASE), "to-do")
def test_a_run_that_died_unreaped_does_not_lock_the_card(self):
"""The registry's status is flipped by the reaper, a moment after
the process ends. The guard reads what is actually running."""
proc = self.working(ONE)
self.refusal()
proc.died() # the process is gone…
with state.LOCK: # …and the record still says running
self.assertEqual([r["status"] for r in state.AGENTS.values()],
["running"])
self.assertIsNone(phases.assert_not_working(PHASE))
def test_a_record_with_no_process_is_taken_at_its_word(self):
"""Nothing to ask means nothing to second-guess."""
with state.LOCK:
state.AGENTS["x"] = {"id": "x", "task": ONE, "status": "running",
"name": "Wren", "mode": "work",
"started": time.time()}
self.assertIn("Wren is on 31", self.refusal())
class OrdinaryCards(PhaseCardCase):
"""An ordinary card is unaffected in every case."""
def test_a_plain_card_moves_with_its_own_agent_running(self):
self.working(PLAIN)
self.assertIsNone(phases.assert_not_working(PLAIN))
taskfiles.move_task(PLAIN, "backlog", "to-do")
self.assertEqual(self.stage_of(PLAIN), "to-do")
def test_a_member_card_still_moves(self):
"""Moving a member is a person overriding the runner deliberately,
and the phase halting on it afterwards is the honest outcome."""
self.working(ONE)
self.assertIsNone(phases.assert_not_working(ONE))
taskfiles.move_task(ONE, "in-progress", "to-do")
self.assertEqual(self.stage_of(ONE), "to-do")
def test_an_agent_on_the_phase_card_itself_is_not_the_rule(self):
"""This card is about the members' work. A run on the phase card —
a relevance check, a PR fix is an ordinary card's business, and
ordinary cards move."""
self.working(PHASE)
self.assertIsNone(phases.assert_not_working(PHASE))
def test_a_card_that_has_left_the_board_asks_nothing(self):
self.assertIsNone(phases.assert_not_working("99-never-existed.md"))
def test_a_phase_that_lists_nobody_asks_nothing(self):
self.write("41-empty.md", card("41 — An empty phase", status="To Do",
kind="Phase", cards=""), "to-do")
self.working(ONE)
self.assertIsNone(phases.assert_not_working("41-empty.md"))
class ThroughTheServer(PhaseCardCase):
"""The board can be stale and `/api/move` is reachable regardless, so
the guard lives behind the API rather than in the drag."""
def setUp(self):
super().setUp()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), httpd.Handler)
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
def post(self, path: str, payload: dict) -> tuple[int, dict]:
request = urllib.request.Request(
self.url + path, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, json.loads(response.read() or b"{}")
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read() or b"{}")
def test_post_move_is_refused_with_the_same_reason(self):
self.working(ONE, name="Wren")
status, body = self.post("/api/move", {"file": PHASE,
"from": "in-progress",
"to": "to-do"})
self.assertEqual(status, 409)
self.assertIn("Wren is on 31 — Stand up site/", body["error"])
self.assertIn(HOLD, body["error"])
# the card is exactly where it was: refused, not half-moved
self.assertEqual(self.stage_of(PHASE), "in-progress")
def test_post_archive_is_refused_with_the_same_reason(self):
self.phase_log(RUNNING, stage="to-do")
self.working(ONE)
status, body = self.post("/api/archive", {"file": PHASE, "from": "to-do"})
self.assertEqual(status, 409)
self.assertIn(HOLD, body["error"])
self.assertIn("then archive it.", body["error"])
self.assertEqual(self.stage_of(PHASE), "to-do")
def test_the_move_goes_through_once_nothing_is_running(self):
status, body = self.post("/api/move", {"file": PHASE,
"from": "in-progress",
"to": "to-do"})
self.assertEqual(status, 200)
self.assertEqual(body["task"]["stage"], "to-do")
self.assertEqual(self.stage_of(PHASE), "to-do")
def test_an_ordinary_card_moves_through_the_api_regardless(self):
self.working(ONE)
status, _ = self.post("/api/move", {"file": PLAIN, "from": "backlog",
"to": "to-do"})
self.assertEqual(status, 200)
self.assertEqual(self.stage_of(PLAIN), "to-do")
class TheGuardIsOnEveryDoorOut(unittest.TestCase):
"""Source-level: the three routes that take a card out of the stage it
is in all ask, and they ask before anything is written."""
def setUp(self):
self.source = (REPO / "manager" / "core" / "httpd.py").read_text(
encoding="utf-8")
def test_move_archive_and_complete_all_ask(self):
for route, call in (("/api/move", "phases.assert_not_working(payload[\"file\"])"),
("/api/archive", "phases.assert_not_working(payload[\"file\"], \"archive it\")"),
("/api/task/complete", "phases.assert_not_working(payload[\"file\"], \"merge it\")")):
with self.subTest(route=route):
self.assertIn(call, self.source)
def test_the_guard_runs_before_the_write(self):
for guard, write in (("phases.assert_not_working(payload[\"file\"])",
"taskfiles.move_task("),
("phases.assert_not_working(payload[\"file\"], \"archive it\")",
"taskfiles.archive_task("),
("phases.assert_not_working(payload[\"file\"], \"merge it\")",
"github.complete_task(")):
with self.subTest(write=write):
self.assertLess(self.source.index(guard), self.source.index(write))
class TheToastCanBeRead(unittest.TestCase):
"""A refusal names what to do instead, which makes it the longest thing
the toast ever says. A message that runs off the screen, or leaves
before it can be read, is the wall this card was written against."""
@classmethod
def setUpClass(cls):
cls.html = (REPO / "manager" / "core" / "board.html").read_text(
encoding="utf-8")
def test_the_toast_is_bounded_and_wraps(self):
self.assertIn("max-width:min(620px, calc(100vw - 48px))", self.html)
self.assertIn("#toast.wrapped{", self.html)
def test_it_stays_up_for_as_long_as_it_takes_to_read(self):
self.assertIn("Math.min(9000, 3200 + message.length * 28)", self.html)
def test_a_refused_move_puts_the_reason_in_the_toast(self):
"""rawMove's failure path: the server's own words, and a reload so
the card is drawn where it actually is."""
raw = self.html[self.html.index("async function rawMove"):]
raw = raw[:raw.index("\n}")]
self.assertIn("toast(data.error || 'move failed', true)", raw)
self.assertIn("await loadState()", raw)
class AgainstARealRun(PhaseCase):
"""The registry is cheaper to state by hand than to stage, so one case
stages it: a phase started for real, its first member launched through
the adapter and still working, and ` hold` as the way out."""
def test_the_card_is_held_still_until_the_phase_is(self):
self.adapter_is(SLEEPS)
phases.start_phase(PHASE, "in-progress")
self.assertTrue(wait_for(lambda: any(
record["task"] == ONE and record["status"] == "running"
for record in state.AGENTS.values())),
"the phase never launched its first member")
with self.assertRaises(ValueError) as caught:
phases.assert_not_working(PHASE)
self.assertIn("31 — Stand up site/", str(caught.exception))
self.assertIn(HOLD, str(caught.exception))
self.assertEqual(self.stage_of(PHASE), "in-progress")
phases.stop_phase(PHASE, "in-progress")
self.assertIsNone(phases.assert_not_working(PHASE),
"a held phase moves — that is what the refusal asked for")
taskfiles.move_task(PHASE, "in-progress", "to-do")
self.assertEqual(self.stage_of(PHASE), "to-do")
if __name__ == "__main__":
unittest.main()