A work agent refuses a phase card

`▸ run phase` guarded its own door and left the neighbour's open:
`/api/agent/start` accepted a phase card, cut `task/<stem>` and handed a
list of other cards to a work agent as a brief. It did that once, and the
agent implemented two cards at once in a worktree nobody was watching.

The refusal is a server rule, in `_validate` with the stage check — ahead
of the claim and well ahead of the worktree, so it costs nothing and
leaves nothing to clean up — and it names ▸ run phase rather than just
saying no. Which headless kinds a phase card may host is now decided kind
by kind where the guard lives: ▸ start work and ↻ act on PR refuse it
(both are work agents), while ◔ still true? and ◔ review PR are allowed —
the latter now told the phase's own branch, since its PR is from
`phase/<stem>` and `task/<stem>` was never cut. The guard is about
starting, so a card retyped under a running agent is left alone.

And the card says which state it is in: an `in-progress/` phase nobody has
started read exactly like one mid-run, the header chip being absent in
both cases. It now wears `not started` (or `held`) in the settled
register, with the line under it saying what ▸ run phase would do — never
the accent, the breathing mark or the caret, which mean work is happening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-02 13:32:08 +02:00
co-authored by Claude Opus 5
parent a5f05ea29d
commit ccffbec831
5 changed files with 532 additions and 15 deletions
+76 -7
View File
@@ -148,13 +148,65 @@ def working_on(files: set[str]) -> list[dict]:
return [record for record in records if _alive(record)]
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None:
# ── what a phase card may host ─────────────────────────────────────────
#
# A phase card is a list of other cards. Handed to a work agent as a brief
# it reads as a table of contents, and the agent does what it is told —
# which is how one run once implemented two cards at once in a worktree
# nobody was watching. `▸ run phase` already guards its own door (a card
# that is not a phase is refused there); this is the other half of that
# gate, and it is decided kind by kind rather than left to omission:
#
# - **▸ start work** (`start_agent`) refuses. A phase is run with ▸ run
# phase, which works its list into a branch of its own; its members are
# worked on their own cards.
# - **↻ act on PR** (`start_pr_fix`) refuses. It is the same work agent
# with a push, and the phase's PR carries its members' commits — review
# feedback on it belongs on the member's own card, or on the phase
# branch by hand.
# - **◔ still true?** (`start_review`) is allowed. Read-only, no worktree:
# asking whether a phase is still worth running is a fair question, and
# the report is appended to the card like any other.
# - **◔ review PR** (`start_pr_review`) is allowed. Read-only, and the PR
# into `main` is the one thing the whole run exists to produce — the
# launch just has to name the phase's own branch rather than a
# `task/<stem>` that was never cut.
#
# It is about *starting*: a card that gains `**Type:** Phase` while an
# ordinary run is in flight is left alone, and the run ends as it would
# have.
PHASE_RUNS_WITH = ("a list of other cards, not a brief. Run it with ▸ run "
"phase, which cuts a branch of its own and works the list "
"into it; its members are worked on their own cards")
def is_phase_card(filename: str, stage: str) -> bool:
"""Is this card a phase card? `**Type:** Phase` is the whole of it —
the same reading `phases._phase_card` refuses a non-phase by, so the
two gates cannot disagree. A phase card whose list is empty or unwritten
is still a coordinator, and still no brief for a work agent."""
try:
return bool(read_task(config.TASKS / stage / filename, stage)["isPhase"])
except OSError:
return False
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None,
*, phase: str | None = None) -> None:
"""The refusals every launch shares, before anything exists to clean up.
`phase` is what a phase card should do instead — pass it and this kind
refuses one, naming that; omit it and the kind is one a phase card may
host. See the note above for which is which and why.
"""
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
if stage not in allowed:
raise ValueError(why or f"agents cannot start from {stage}/")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
if phase and is_phase_card(filename, stage):
raise ValueError(f"{filename} is a phase card — {phase}")
_assert_no_running_agent(filename)
@@ -304,9 +356,12 @@ def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None:
def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
# Moving a card to in-progress is the commitment; only then does work start.
# Moving a card to in-progress is the commitment; only then does work
# start — and a phase card is refused here, in the same breath and ahead
# of the claim, so the refusal costs nothing and leaves nothing behind.
_validate(filename, stage, {"in-progress"},
"work starts from in-progress/ — move the card there first")
"work starts from in-progress/ — move the card there first",
phase=PHASE_RUNS_WITH)
claim_for_launch(filename, stage, takeover)
stem = filename[:-3]
@@ -386,7 +441,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
def start_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that checks the task against the codebase."""
"""Fire a read-only agent that checks the task against the codebase.
Every stage, and a phase card too: no `phase=` here is the deliberate
answer, not an omission. Nothing is written but the report.
"""
_validate(filename, stage, config.STAGE_DIRS)
task = read_task(config.TASKS / stage / filename, stage)
@@ -593,14 +652,21 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
def start_pr_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that reviews the task's PR and posts the
verdict to GitHub as well as back to the board."""
verdict to GitHub as well as back to the board.
A phase card is allowed here, deliberately: the PR into `main` is what
the whole run exists to produce, and reading it writes nothing.
"""
_validate(filename, stage, {"review"},
"PR reviews run on cards in review/")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR yet — nothing to review")
branch = f"task/{filename[:-3]}"
# …but it must be told the branch its PR is actually from: the prompt
# asks GitHub for the diff by branch, and a phase's is its own.
branch = (phase_branch(filename) if task["isPhase"]
else f"task/{filename[:-3]}")
name = _pick_name(filename)
agent_id = f"review-pr-{filename[:-3]}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
@@ -632,7 +698,10 @@ def start_pr_fix(filename: str, stage: str) -> dict:
working in the task's existing worktree (recreated from the branch if it
was cleaned up), committing and pushing to update the PR."""
_validate(filename, stage, {"review"},
"acting on a PR happens from review/")
"acting on a PR happens from review/",
phase="↻ act on PR is a work agent, and a phase's PR carries "
"its members' commits — address the review on the member's "
"own card, or on the phase branch by hand")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR to act on")
+29 -6
View File
@@ -1347,8 +1347,14 @@ function phaseSummary(task) {
}
/* …and what it is doing right now: the member in flight while a run is on,
the halt while one is held. Null when the phase has no story yet — a
phase waiting in to-do/ is just a card. */
the halt while one is held — and the quiet case this exists for, that
nobody has started it. A phase card sitting in in-progress/ that has not
been run used to look exactly like one that is running: the header chip
is the only other place that difference is written, and it is absent both
when a phase has not started and when there is no phase at all. So the
card says it itself. `idle` is not work — it takes no accent, no
breathing mark and no caret, because nothing is happening. Null when the
phase has no story to tell here: one waiting in to-do/ is just a card. */
function phaseFlight(task) {
const snap = (S.state.phases || {})[task.file];
if (!snap) return null;
@@ -1356,7 +1362,14 @@ function phaseFlight(task) {
return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') +
' — ' + (snap.haltedWhy || snap.halted) };
}
if (!snap.running) return null;
if (!snap.running) {
if (task.stage !== 'in-progress') return null;
return snap.stopped
? { idle: true, pill: 'held',
line: 'held — ▸ run phase carries on from where it stopped' }
: { idle: true, pill: 'not started',
line: 'not started — ▸ run phase cuts its branch and starts the first card' };
}
const at = phaseProgress(snap);
if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file} — ${at.on.title}` };
if ((snap.waitingOn || []).length) {
@@ -1392,7 +1405,8 @@ function cardFor(task) {
// a running phase has an agent alive on a card this view no longer draws,
// so the phase card wears that state on its members' behalf
const flight = task.isPhase ? phaseFlight(task) : null;
const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad);
const working = !!(agent && agent.mode !== 'review')
|| !!(flight && !flight.bad && !flight.idle);
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
// the server holds this, not the tab that clicked: while merge & clean up
@@ -1419,6 +1433,15 @@ function cardFor(task) {
pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) };
tint = 'var(--alarm)';
}
if (flight && flight.idle) {
// the state the card could not tell from a run in flight: a phase in
// in-progress/ that nobody has started, or one held. It is the absence
// of work, so it wears the settled register — never the accent, which
// means an agent is alive.
pill = { text: flight.pill, tint: 'var(--idle)', bg: 'var(--sunken)',
title: flight.line };
tint = 'var(--idle)';
}
if (failure) {
// the newest thing that happened here, and the only actionable one:
// it outranks a PR verdict from before the run died
@@ -1610,7 +1633,7 @@ function cardFor(task) {
// the header chip carries, at the altitude a person is already reading
liveLine = `<div class="well${flight.bad ? ' bad' : ''}"><span class="lead">·</span>` +
`<span class="wbody">${esc(flight.line)}` +
`${flight.bad ? '' : '<span class="caret"></span>'}</span></div>`;
`${flight.bad || flight.idle ? '' : '<span class="caret"></span>'}</span></div>`;
}
// tool chips: destinations, not statuses — they live in the card's footer
@@ -2503,7 +2526,7 @@ function laneFor(task) {
// a run in flight, in the same well the cards use for the same fact
const well = !halted && flight
? `<div class="well"><span class="lead">·</span><span class="wbody">${esc(flight.line)}` +
`<span class="caret"></span></span></div>`
`${flight.idle ? '' : '<span class="caret"></span>'}</span></div>`
: '';
el.innerHTML = head + halt + well +