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:
+76
-7
@@ -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
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user