The board shows a phase running, and shows it stopping
A phase card inherits the PR chip, CI, the review actions and the merge-and-clean-up sheet by being a card. Three things it does not inherit, and this is them. The runner learns to stop. `stopped — held by <name>` joins `halted` and `run started` in the phase log, and one reader (`run_state`) answers where a phase is from the last line that says: running, halted, stopped or never run. `stop_phase` writes that line — so the next beat stands down — and holds the member agent in flight, because a phase that said it had stopped while its agent kept working would be lying about the one thing it was asked. Nothing is unwound: the branch, every card merged into it and every worktree are left exactly as they were. A halted phase can be held too, which is the other half of the halt's promise that it holds until the phase is run again or stopped. The halt is now told three times, as a dead run is: the state, the ticker line it already had, and a toast — rare, actionable, and the whole argument for starting a phase and walking away. Every advance is narrated: the member that came up green, the merge, the next start. The page: run phase (take over on someone else's) in the slot start work occupies, hold while it runs, and a header chip beside the agents chip — breathing accent with the phase, its progress and the card in flight, alarm and holding when it halts, absent entirely when there is nothing to say. One chip per phase, because two phases could in principle run at once and a chip that showed one of them silently would be worse than none. Opening a phase card lists its members in run order with each one's stage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+230
-8
@@ -99,6 +99,18 @@
|
||||
/* an author display beats the UA's [hidden] — say it here or the sync
|
||||
chip is never hidden */
|
||||
.livechip[hidden]{display:none}
|
||||
/* A running phase is the same kind of fact as the agents and the sync
|
||||
chips: what is happening across the board without you. One chip per
|
||||
phase in flight — two phases could in principle run at once, and a
|
||||
chip that showed one of them would be worse than none — so the row
|
||||
gives way before the chips already here do, clipping the names it
|
||||
cannot fit rather than shrinking its neighbours. */
|
||||
.phasechips{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}
|
||||
.phasechips[hidden]{display:none}
|
||||
.phasechips .livechip{min-width:0}
|
||||
.phasechips .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.phasechips .mono{white-space:nowrap}
|
||||
.phasechips .halted{color:var(--alarm)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
|
||||
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
|
||||
/* the model chip: which brain did this, beside the name that did it.
|
||||
@@ -451,6 +463,28 @@
|
||||
#drawer .dhead{display:flex;align-items:center;gap:10px}
|
||||
/* the dead run's excerpt, above the task itself: machine output, bounded */
|
||||
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
|
||||
/* the phase's list, above the card's own text: run order, one row per
|
||||
card, machine facts (position, number, stage, the runner's reading)
|
||||
in mono and the title in the reader's face */
|
||||
#drawer .pmembers{display:flex;flex-direction:column;gap:1px}
|
||||
#drawer .phead{
|
||||
display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;
|
||||
color:var(--muted);padding-bottom:5px;
|
||||
}
|
||||
#drawer .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
|
||||
#drawer .prow{
|
||||
display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;
|
||||
padding:5px 8px;border:1px solid transparent;border-radius:7px;
|
||||
background:var(--sunken);font-size:12.5px;color:var(--text);
|
||||
}
|
||||
#drawer .prow:hover{border-color:var(--accent)}
|
||||
#drawer .prow .mono{font-family:var(--mono);font-size:11px;color:var(--dim)}
|
||||
#drawer .prow .pn{width:14px;flex:none;text-align:right}
|
||||
#drawer .prow .pref{flex:none}
|
||||
#drawer .prow .ptitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
#drawer .prow .pstage{flex:none;color:var(--muted)}
|
||||
#drawer .prow .pstate{flex:none}
|
||||
#drawer .pempty{font-size:12px;color:var(--dim);padding:4px 8px}
|
||||
#drawer .dbody{font-size:13px;line-height:1.6}
|
||||
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
|
||||
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
|
||||
@@ -542,6 +576,7 @@
|
||||
<button data-view="focus">Focus</button>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<div class="phasechips" id="phasechips" hidden></div>
|
||||
<div class="livechip" id="syncchip" hidden style="cursor:default"></div>
|
||||
<div class="livechip" id="livechip" title="open Sessions"></div>
|
||||
<button id="themebtn">Daylight</button>
|
||||
@@ -625,10 +660,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
|
||||
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
|
||||
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
|
||||
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', phase: '⟶', other: '·' };
|
||||
const FILTERS = [
|
||||
['all', 'All', null],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync', 'phase'])],
|
||||
['edits', 'Edits', new Set(['edit'])],
|
||||
['reads', 'Reads', new Set(['read', 'search'])],
|
||||
['tests', 'Tests', new Set(['test', 'check'])],
|
||||
@@ -890,6 +925,82 @@ function renderSync() {
|
||||
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
|
||||
}
|
||||
|
||||
/* ── a phase in flight ────────────────────────────────────────────────── */
|
||||
|
||||
/* The phases the header has something to say about: one running, or one
|
||||
halted and not yet settled. A phase nobody has started, and one whose
|
||||
card has left in-progress/, are simply not here — with none of them the
|
||||
header is exactly what it was before phases existed.
|
||||
|
||||
A halt leads, because it is the one a person must not scroll past. */
|
||||
function phasesInFlight() {
|
||||
return Object.values(S.state?.phases || {})
|
||||
.filter(p => p.running || p.halted)
|
||||
.sort((a, b) => (b.halted ? 1 : 0) - (a.halted ? 1 : 0) ||
|
||||
a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
/* Where a phase is up to, from the members the last pass read: how many
|
||||
have landed on the phase branch, and which one is in flight — an agent
|
||||
on it, or its checks still running. */
|
||||
function phaseProgress(p) {
|
||||
const members = p.members || [];
|
||||
return {
|
||||
done: members.filter(m => m.state === 'merged').length,
|
||||
total: members.length,
|
||||
on: members.find(m => m.state === 'running') ||
|
||||
members.find(m => m.state === 'waiting') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function clip(text, n) {
|
||||
return text.length > n ? text.slice(0, n - 1) + '…' : text;
|
||||
}
|
||||
|
||||
/* What the chip says after the name: the halt and where it happened, or
|
||||
the progress and the card in flight. The whole of it is on the title —
|
||||
a header chip is not the place a reason gets to run long. */
|
||||
function phaseChipDetail(p) {
|
||||
if (p.halted) {
|
||||
return (p.haltedAt ? `halted at #${p.haltedAt} — ` : 'halted — ') +
|
||||
clip(p.haltedWhy || p.halted, 46);
|
||||
}
|
||||
const at = phaseProgress(p);
|
||||
const rest = at.on ? `on #${at.on.number || at.on.file}`
|
||||
: (p.waitingOn || []).length ? `waiting on #${p.waitingOn[0]}` : '';
|
||||
return `${at.done}/${at.total}` + (rest ? ` · ${rest}` : '');
|
||||
}
|
||||
|
||||
function renderPhases() {
|
||||
const el = $('#phasechips');
|
||||
const live = phasesInFlight();
|
||||
el.hidden = !live.length;
|
||||
el.innerHTML = live.map(p => {
|
||||
const at = phaseProgress(p);
|
||||
const detail = phaseChipDetail(p);
|
||||
// colour only means state: a run alive breathes in accent, a halt
|
||||
// holds in alarm until the phase is resumed or held
|
||||
const title = (p.halted
|
||||
? `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
|
||||
`${p.haltedWhy || p.halted}. It stays here until the phase is run again or held.`
|
||||
: `${p.file} is running on ${p.branch} — ${at.done} of ${at.total} cards merged`) +
|
||||
' · click to open the phase card';
|
||||
const dot = p.halted
|
||||
? '<span class="dot" style="background:var(--alarm)"></span>'
|
||||
: '<span class="dot live"></span>';
|
||||
return `<div class="livechip" data-phasechip="${esc(p.file)}" title="${esc(title)}">` +
|
||||
dot + `<span class="name${p.halted ? ' halted' : ''}">⟶ ${esc(phaseLabel(p))}</span>` +
|
||||
`<span class="mono${p.halted ? ' halted' : ''}">${esc(detail)}</span></div>`;
|
||||
}).join('');
|
||||
el.querySelectorAll('[data-phasechip]').forEach(chip =>
|
||||
chip.addEventListener('click', () => {
|
||||
const task = findTask(chip.dataset.phasechip);
|
||||
if (!task) return;
|
||||
setView('board');
|
||||
showDetail(task);
|
||||
}));
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
S.view = view;
|
||||
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
|
||||
@@ -932,6 +1043,7 @@ function render() {
|
||||
if (!S.state) return;
|
||||
renderTitle();
|
||||
renderChip();
|
||||
renderPhases();
|
||||
renderSync();
|
||||
if (S.view === 'board') renderBoard();
|
||||
else if (S.view === 'flight') renderFlight();
|
||||
@@ -1072,11 +1184,38 @@ function cardFor(task) {
|
||||
run: () => askCopilot(task) });
|
||||
}
|
||||
} else {
|
||||
if (task.stage === 'in-progress') {
|
||||
// someone else's card is never started by accident: the action says
|
||||
// whose it is, and firing it is the deliberate takeover
|
||||
const held = task.assignee && S.state.me && task.assignee !== S.state.me
|
||||
? task.assignee : null;
|
||||
// someone else's card is never started by accident: the action says
|
||||
// whose it is, and firing it is the deliberate takeover
|
||||
const held = task.assignee && S.state.me && task.assignee !== S.state.me
|
||||
? task.assignee : null;
|
||||
if (task.stage === 'in-progress' && task.isPhase) {
|
||||
// the phase's own launch, in the slot ▸ start work has on every other
|
||||
// card: moving it here was the commitment, this is the second half
|
||||
const ph = (S.state.phases || {})[task.file];
|
||||
const hold = { glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
|
||||
title: 'Stop the phase — the phase branch, every card already merged '
|
||||
+ 'into it and every worktree stay exactly as they are',
|
||||
run: () => holdPhase(task) };
|
||||
const start = held
|
||||
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`,
|
||||
busy: 'starting…',
|
||||
title: `${held} holds this phase — running it takes the card over and `
|
||||
+ 'this board becomes the one that advances it',
|
||||
run: () => runPhase(task, { takeover: true }) }
|
||||
: { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
|
||||
title: ph && ph.halted
|
||||
? 'Run it again — the halt is cleared and the phase carries on from '
|
||||
+ 'where it stopped'
|
||||
: 'A branch of its own, each card in the list run on it in turn, and '
|
||||
+ 'one PR into main at the end',
|
||||
run: () => runPhase(task) };
|
||||
// a halt holds until the phase is run again or held: both are here,
|
||||
// because a person who has read it and does not want to carry on
|
||||
// needs a way to say so that is not walking the card backwards
|
||||
if (ph && ph.running) actions.push(hold);
|
||||
else if (ph && ph.halted) actions.push(start, hold);
|
||||
else actions.push(start);
|
||||
} else if (task.stage === 'in-progress') {
|
||||
actions.push(held
|
||||
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
|
||||
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
|
||||
@@ -1099,7 +1238,9 @@ function cardFor(task) {
|
||||
title: 'Push the branch and open its PR — the board does this when a card '
|
||||
+ 'enters review, and this is how you ask for it afterwards',
|
||||
run: () => openPR(task) });
|
||||
} else {
|
||||
} else if (actions.length < 2) {
|
||||
// two per state is the whole budget, and a halted phase has already
|
||||
// spent it on running it again and holding it
|
||||
actions.push(stillTrue);
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1584,35 @@ async function askCopilot(task) {
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
/* Running a phase, and holding it. Both are ordinary POSTs on the one
|
||||
action machine — armed, fired, and honest about failing — because a
|
||||
phase is a card and its actions are card actions. What follows either
|
||||
one is watched in the header chip and narrated in the ticker, so
|
||||
neither says more here than what it just asked for. */
|
||||
async function runPhase(task, extra) {
|
||||
const res = await fetch('/api/phase/run', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? `Phase ${task.file} running — the header chip follows it`
|
||||
: (data.error || 'the phase did not start'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function holdPhase(task) {
|
||||
const res = await fetch('/api/phase/stop', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? 'Held — the phase branch and everything merged into it stay as they are'
|
||||
: (data.error || 'could not hold it'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function move(file, from, to) {
|
||||
// finishing a task with work attached is a decision, not just a drag
|
||||
if (to === 'done') {
|
||||
@@ -1517,6 +1687,52 @@ function showDetail(task) {
|
||||
if (changed) $('#drawerbody').scrollTop = 0;
|
||||
}
|
||||
|
||||
/* The runner's word for a member, in the vocabulary the rest of the board
|
||||
uses. `pending` is the phase simply not having reached it, which the
|
||||
stage beside it already says — so it says nothing. */
|
||||
const MEMBER_STATE = { merged: 'merged in', running: 'working',
|
||||
waiting: 'checking', ready: 'ready to merge', halt: 'stopped here' };
|
||||
|
||||
/* A phase card answers "where is this up to" on its own face: its members
|
||||
in run order, each with the stage it is in, so the question costs no
|
||||
hunt across five columns. The runner's reading (merged in, working,
|
||||
stopped here) is added only while a phase is in flight, because only
|
||||
then is there one — and only it can tell a card that has run from one
|
||||
the phase has not reached. Each row opens that card. */
|
||||
function phaseMembers(t) {
|
||||
if (!t.isPhase) return '';
|
||||
if (!(t.members || []).length) {
|
||||
return `<div class="pmembers"><div class="phead">Cards</div>` +
|
||||
`<div class="pempty">This phase lists no cards yet — a <code>## Cards</code> ` +
|
||||
`section, one task number per line, in the order they run.</div></div>`;
|
||||
}
|
||||
const snap = (S.state.phases || {})[t.file];
|
||||
const read = {};
|
||||
for (const m of ((snap && snap.members) || [])) read[m.file] = m;
|
||||
const rows = t.members.map((m, i) => {
|
||||
const seen = read[m.file];
|
||||
const note = seen ? MEMBER_STATE[seen.state] || '' : '';
|
||||
const tint = seen && seen.state === 'halt' ? 'var(--alarm)'
|
||||
: seen && ['running', 'waiting'].includes(seen.state) ? 'var(--accent)'
|
||||
: seen && seen.state === 'merged' ? 'var(--calm)' : '';
|
||||
return `<button class="prow" data-member="${esc(m.file)}">` +
|
||||
`<span class="pn mono">${i + 1}</span>` +
|
||||
`<span class="pref mono">#${esc(m.number || '')}</span>` +
|
||||
`<span class="ptitle">${esc(m.title)}</span>` +
|
||||
`<span class="pstage mono">${esc(m.stage)}</span>` +
|
||||
(note ? `<span class="pstate mono"${tint ? ` style="color:${tint}"` : ''}` +
|
||||
`${seen.why ? ` title="${esc(seen.why)}"` : ''}>${esc(note)}</span>` : '') +
|
||||
`</button>`;
|
||||
}).join('');
|
||||
const halt = snap && snap.halted
|
||||
? `<div class="pempty" style="color:var(--alarm)">halted` +
|
||||
`${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''} — ` +
|
||||
`${esc(snap.haltedWhy || snap.halted)}</div>` : '';
|
||||
return `<div class="pmembers"><div class="phead">Cards` +
|
||||
`<span class="mono">${t.members.length} in run order</span></div>` +
|
||||
rows + halt + `</div>`;
|
||||
}
|
||||
|
||||
function renderDrawer() {
|
||||
const panel = $('#drawer');
|
||||
const body = $('#drawerbody');
|
||||
@@ -1557,9 +1773,15 @@ function renderDrawer() {
|
||||
`<span class="spacer"></span>` +
|
||||
`<button id="closeDrawer">Close</button></div>` +
|
||||
failBlock +
|
||||
phaseMembers(t) +
|
||||
`<div class="dbody">${md(t.body)}</div>` +
|
||||
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
|
||||
panel.classList.add('open');
|
||||
body.querySelectorAll('[data-member]').forEach(row =>
|
||||
row.addEventListener('click', () => {
|
||||
const member = findTask(row.dataset.member);
|
||||
if (member) showDetail(member);
|
||||
}));
|
||||
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ def state_payload() -> dict:
|
||||
"sessions": sessions,
|
||||
"agents": agents.list_public(),
|
||||
"prs": github.public_state(),
|
||||
# what the last pass of the phase runner saw: per running phase
|
||||
# card, its branch and each member's state
|
||||
# what the last pass of the phase runner saw: per phase card in
|
||||
# in-progress/, its branch, whether a run is in force or halted (and
|
||||
# why, and where), and each member's state — the header chip and the
|
||||
# phase card's own list are both read from here
|
||||
"phases": phases.public_state(),
|
||||
"drive": drive.public(),
|
||||
"hasDriver": config.driver_path() is not None,
|
||||
@@ -201,6 +203,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200, {"phase": phases.start_phase(
|
||||
payload["file"], payload["stage"],
|
||||
bool(payload.get("takeover")))})
|
||||
elif path == "/api/phase/stop":
|
||||
payload = self._read_body()
|
||||
# ‖ hold on a phase card: the run stops, nothing is unwound
|
||||
self._json(200, {"phase": phases.stop_phase(payload["file"],
|
||||
payload["stage"])})
|
||||
elif path == "/api/pr/open":
|
||||
payload = self._read_body()
|
||||
self._json(200, {"url": github.open_pr_now(payload["file"])})
|
||||
|
||||
+127
-26
@@ -64,6 +64,7 @@ LOG_LINE_RE = re.compile(r"^-\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+·\s+(.+?)\s*$
|
||||
STARTED_RE = re.compile(r"^(\d+) started\b")
|
||||
HALTED_RE = re.compile(r"^halted(?: at (\d+))? — (.*)$")
|
||||
RUN_RE = re.compile(r"^run started\b")
|
||||
STOPPED_RE = re.compile(r"^stopped — (.*)$")
|
||||
|
||||
# What the last computed pass saw, for the API to render without paying for
|
||||
# a git walk on every request. A display cache, not a decision: nothing here
|
||||
@@ -161,17 +162,37 @@ def _started(entries: list[str]) -> set[str]:
|
||||
(STARTED_RE.match(e) for e in _this_run(entries)) if match}
|
||||
|
||||
|
||||
def _halt_reason(entries: list[str]) -> str | None:
|
||||
"""The halt still in force, or None. A `run started` line after a halt
|
||||
clears it — that is what running the phase again means."""
|
||||
def run_state(entries: list[str]) -> dict:
|
||||
"""Where the phase stands, read off its own log — the whole of what a
|
||||
restarted board, and the chip in the header, know about a run.
|
||||
|
||||
Four answers, and the last line that says one of them wins: `running`
|
||||
(a run was started and nothing has ended it), `halted` (with the reason
|
||||
and the member it happened at), `stopped` (a person held it) and `idle`
|
||||
(a card whose phase has never been run). Running it again is what
|
||||
clears a halt or a stop, because that line is the person's decision.
|
||||
"""
|
||||
for entry in reversed(entries):
|
||||
halted = HALTED_RE.match(entry)
|
||||
if halted:
|
||||
return (f"{halted.group(1)}: {halted.group(2)}" if halted.group(1)
|
||||
else halted.group(2))
|
||||
return {"state": "halted", "reason": halted.group(2),
|
||||
"at": halted.group(1)}
|
||||
stopped = STOPPED_RE.match(entry)
|
||||
if stopped:
|
||||
return {"state": "stopped", "reason": stopped.group(1), "at": None}
|
||||
if RUN_RE.match(entry):
|
||||
return None
|
||||
return None
|
||||
return {"state": "running", "reason": "", "at": None}
|
||||
return {"state": "idle", "reason": "", "at": None}
|
||||
|
||||
|
||||
def _halt_reason(entries: list[str]) -> str | None:
|
||||
"""The halt still in force, as one line — the member it happened at and
|
||||
why, which is what the log's reader and the ticker both want."""
|
||||
where = run_state(entries)
|
||||
if where["state"] != "halted":
|
||||
return None
|
||||
return (f"{where['at']}: {where['reason']}" if where["at"]
|
||||
else where["reason"])
|
||||
|
||||
|
||||
# ── reading the board ──────────────────────────────────────────────────
|
||||
@@ -266,6 +287,7 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
|
||||
"""The whole phase, recomputed. Nothing is remembered between passes."""
|
||||
branch = agents.phase_branch(phase["file"])
|
||||
entries = log_entries(phase["body"])
|
||||
where = run_state(entries)
|
||||
started = _started(entries)
|
||||
members = []
|
||||
for listed in phase["members"]:
|
||||
@@ -280,8 +302,20 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
|
||||
"title": task["title"], "stage": task["stage"],
|
||||
"state": member_state, "why": why,
|
||||
"dependsOn": task["dependsOn"]})
|
||||
halted = where["state"] == "halted"
|
||||
return {"file": phase["file"], "branch": branch, "stage": phase["stage"],
|
||||
"members": members, "halted": _halt_reason(entries),
|
||||
"title": phase["title"], "number": phase["number"],
|
||||
"members": members,
|
||||
# the halt as one line for the log's reader, and in its parts for
|
||||
# anything that renders it — the same reading, said twice
|
||||
"halted": _halt_reason(entries),
|
||||
"haltedAt": where["at"] if halted else None,
|
||||
"haltedWhy": where["reason"] if halted else None,
|
||||
# is a run in force? The log answers it, so a restarted board and
|
||||
# the chip in the header read the same thing
|
||||
"running": where["state"] == "running",
|
||||
"stopped": where["state"] == "stopped",
|
||||
"stoppedBy": where["reason"] if where["state"] == "stopped" else None,
|
||||
# the branch is the whole of "this phase has been started"
|
||||
"started": _branch_exists(branch)}
|
||||
|
||||
@@ -428,6 +462,11 @@ def _launch(phase: dict, member: dict) -> None:
|
||||
def _merge_member(phase: dict, member: dict) -> None:
|
||||
branch = f"task/{member['file'][:-3]}"
|
||||
phase_branch = agents.phase_branch(phase["file"])
|
||||
# every advance is narrated, and finishing is its own half of one: what
|
||||
# the phase judged green is a different fact from what it then merged,
|
||||
# and a phase nobody can reconstruct afterwards is a phase nobody trusts
|
||||
_say(phase["file"], f"{phase['file']}: {member['number']} — "
|
||||
f"{member['title']} is green")
|
||||
if _branch_exists(branch):
|
||||
_merge_into_phase(phase, branch, f"{member['number']}'s branch", member)
|
||||
_push_phase(phase)
|
||||
@@ -449,11 +488,17 @@ def _finish(phase: dict) -> None:
|
||||
|
||||
|
||||
def _halt(phase: dict, member: dict | None, reason: str) -> None:
|
||||
"""Stop, and say so once. The log holds the halt from here on, so the
|
||||
next pass reads it rather than saying the same thing again."""
|
||||
"""Stop, and say so once — at three altitudes, like every other outcome
|
||||
a person must not miss. The log holds the halt from here on (so the next
|
||||
pass reads it rather than saying the same thing again), the ticker keeps
|
||||
the line, and a toast says it to whoever is looking: a halt is rare,
|
||||
actionable, and the whole argument for starting a phase and walking away.
|
||||
"""
|
||||
at = f" at {member['number']}" if member and member["number"] else ""
|
||||
_write_log(phase, f"halted{at} — {reason}") # best effort: never re-raise
|
||||
_say(phase["file"], f"{phase['file']} halted{at} — {reason}")
|
||||
state.broadcast({"type": "toast", "error": True,
|
||||
"message": f"phase {phase['file']} halted{at} — {reason}"})
|
||||
|
||||
|
||||
def _do_pass(phase: dict, snapshot: dict, by_number: dict[str, dict]) -> list[str]:
|
||||
@@ -490,7 +535,7 @@ def advance(phase: dict, by_file: dict[str, dict],
|
||||
"""
|
||||
snapshot = _snapshot(phase, by_file)
|
||||
SNAPSHOTS[phase["file"]] = snapshot
|
||||
if snapshot["halted"] or not snapshot["started"] or not _mine(phase):
|
||||
if not snapshot["running"] or not snapshot["started"] or not _mine(phase):
|
||||
return snapshot
|
||||
waiting_on: list[str] = []
|
||||
try:
|
||||
@@ -541,30 +586,33 @@ def beat() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── starting one ───────────────────────────────────────────────────────
|
||||
# ── starting one, and stopping it ──────────────────────────────────────
|
||||
|
||||
|
||||
def _phase_card(filename: str, stage: str) -> dict:
|
||||
"""The phase card an action names, or the reason it is not one."""
|
||||
if Path(filename).name != filename or not filename.endswith(".md"):
|
||||
raise ValueError("bad filename")
|
||||
if stage != "in-progress":
|
||||
raise ValueError("a phase runs from in-progress/ — move the card there first")
|
||||
if not (config.TASKS / stage / filename).is_file():
|
||||
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
||||
phase = _cards()[0].get(filename)
|
||||
if phase is None or not phase["isPhase"]:
|
||||
raise ValueError(f"{filename} is not a phase — a phase card is "
|
||||
f"**Type:** Phase with a ## Cards section")
|
||||
return phase
|
||||
|
||||
|
||||
def start_phase(filename: str, stage: str, takeover: bool = False) -> dict:
|
||||
"""Run a phase — the first time, or again after a halt.
|
||||
"""Run a phase — the first time, or again after a halt or a hold.
|
||||
|
||||
Cutting `phase/<stem>` from the newest main is the whole of "starting":
|
||||
everything after it is the beat looking at what is there. Running a
|
||||
halted phase again is the person's decision that cleared the halt, so
|
||||
it appends the line that clears it and takes one pass immediately.
|
||||
"""
|
||||
if Path(filename).name != filename or not filename.endswith(".md"):
|
||||
raise ValueError("bad filename")
|
||||
if stage != "in-progress":
|
||||
raise ValueError("a phase runs from in-progress/ — move the card there first")
|
||||
path = config.TASKS / stage / filename
|
||||
if not path.is_file():
|
||||
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
||||
|
||||
by_file, by_number = _cards()
|
||||
phase = by_file.get(filename)
|
||||
if phase is None or not phase["isPhase"]:
|
||||
raise ValueError(f"{filename} is not a phase — a phase card is "
|
||||
f"**Type:** Phase with a ## Cards section")
|
||||
phase = _phase_card(filename, stage)
|
||||
if phase["phaseDrift"]:
|
||||
raise ValueError(f"{filename} does not resolve: {phase['phaseDrift'][0]} "
|
||||
f"— fix the list before running it")
|
||||
@@ -603,3 +651,56 @@ def _start(phase: dict, filename: str) -> dict:
|
||||
|
||||
by_file, by_number = _cards() # the log just rewrote the card
|
||||
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.
|
||||
|
||||
`‖ hold` means here what it means on any other card — the work stops
|
||||
and nothing is lost. So the line goes into the log (which is what the
|
||||
beat reads, so the next pass stands down), and the agent the phase has
|
||||
in flight is held exactly as its own card's hold would hold it. The
|
||||
phase branch, every member already merged into it, and every worktree
|
||||
are left precisely as they were: a hold is not an undo, and the only
|
||||
way back to `main` is still the PR at the end.
|
||||
|
||||
A halted phase can be held too, and that is the other half of the
|
||||
halt's promise: it holds until the phase is run again *or* stopped,
|
||||
and a person who has read the halt and does not want to carry on needs
|
||||
a way to say so that is not walking the card backwards.
|
||||
|
||||
A held member's card keeps its own work and its own state, so running
|
||||
the phase again may well halt on it — that is the honest reading of a
|
||||
run that ended without reaching review/, and it is a person's to settle.
|
||||
"""
|
||||
phase = _phase_card(filename, stage)
|
||||
with _LOCK: # never alongside a pass of the beat
|
||||
where = run_state(log_entries(phase["body"]))
|
||||
if where["state"] not in ("running", "halted"):
|
||||
raise ValueError(f"{filename} is not running — nothing to hold "
|
||||
f"(its phase log says: {where['state']})")
|
||||
who = taskfiles.actor_name() or "you"
|
||||
if not _write_log(phase, f"stopped — held by {who}"):
|
||||
raise ValueError(f"could not record the hold on {filename} — its "
|
||||
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"]}):
|
||||
try:
|
||||
agents.stop_agent(record["id"])
|
||||
except ValueError: # it ended between the read and the ask
|
||||
continue
|
||||
held.append(record["task"])
|
||||
_say(filename, f"phase {filename} held by {who}"
|
||||
+ (f" — {', '.join(held)} stopped with it" if held else "")
|
||||
+ f" — {agents.phase_branch(filename)} is left as it is")
|
||||
snapshot = _snapshot(_reread(phase) or phase, _cards()[0])
|
||||
SNAPSHOTS[filename] = snapshot
|
||||
state.broadcast({"type": "board"})
|
||||
return snapshot
|
||||
|
||||
Reference in New Issue
Block a user