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