Merge pull request #54 from 12vectors/phase/61-what-an-agent-is-told-before-it-starts
61 — Phase: what an agent is told before it starts
This commit is contained in:
@@ -193,6 +193,19 @@ in. A wrong guess costs nothing an empty value would not, since a prefix
|
||||
that matches nothing denies exactly the same way. An existing value is
|
||||
never overwritten, so `--setup` cannot undo a hand-edit.
|
||||
|
||||
Empty is honest, but honest and invisible is a board that lets an agent
|
||||
discover the problem on your behalf, slowly — so the board says it out
|
||||
loud. With `BOARD_AGENT_COMMANDS` holding nothing (unset, whitespace, or
|
||||
a lone comma, which is how the adapters read it too) the header carries a
|
||||
quiet `no agent commands` chip naming the setting and the file that holds
|
||||
it, `manager/local/.env`, and every launch of the two intents that would
|
||||
have run those commands — **▸ start work** and **↻ act on PR** — appends
|
||||
the same sentence to its line in the ticker. It is a configuration fact,
|
||||
not an alarm: it wears the settled register rather than `--alarm`, it
|
||||
appears only while the setting is empty, and it never refuses a launch.
|
||||
An agent that only edits files is still useful, and bench does not
|
||||
decline work because a project is unconfigured.
|
||||
|
||||
Bare Enter takes the default, Ctrl-D skips the rest, and what lands is
|
||||
`core/.env.example` with the answers substituted into their lines: every
|
||||
other key, every comment, so the written file is where the project reads
|
||||
@@ -378,6 +391,21 @@ there, and firing it is the deliberate reassignment. An unclaimed card
|
||||
claims itself on launch. One agent per task at a time, and a work agent's
|
||||
worktree must not already exist when it starts.
|
||||
|
||||
It also refuses a **phase card**, and the refusal names **▸ run phase** —
|
||||
a phase card's body is a list of other cards, so a work agent handed one
|
||||
implements the table of contents. The board's own page offers one action
|
||||
or the other and never both, but a UI layer can be stale or bypassed, so
|
||||
the rule lives where every other launch refusal lives: with the stage
|
||||
check, ahead of the claim and well ahead of the worktree, costing nothing
|
||||
and leaving nothing to clean up. Which headless kinds a phase card may
|
||||
host is decided rather than left to omission: **▸ start work** and **↻ act
|
||||
on PR** refuse it (both are work agents, and a phase's work belongs to its
|
||||
members' cards), while the read-only pair — **◔ still true?** and **◔
|
||||
review PR**, the latter told the phase's own branch — are allowed. The
|
||||
guard is about *starting*: a card that gains `**Type:** Phase` while an
|
||||
ordinary run is in flight is left alone, and that run ends as it would
|
||||
have.
|
||||
|
||||
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
|
||||
branch `task/<task-stem>` from the newest main it can see: with an
|
||||
`origin` remote it fetches `origin/main` first (bounded by
|
||||
@@ -951,6 +979,17 @@ 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 itself says which state it is in.** The header chip only
|
||||
appears while there is something to say, so it cannot tell "the phase has
|
||||
not been started" from "there is no phase here at all" — which left an
|
||||
`in-progress/` phase card nobody had run looking exactly like one mid-run.
|
||||
So the card carries the distinction quietly, in the pill and the line
|
||||
under it: `not started` (`▸ run phase` cuts its branch and starts the
|
||||
first card), `held`, the accent and the member in flight while it runs,
|
||||
and `halted` in `--alarm`. Only a run wears the working vocabulary — the
|
||||
breathing mark, the accent border, the caret — because only a run is work
|
||||
happening.
|
||||
|
||||
**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
|
||||
|
||||
@@ -46,7 +46,9 @@ BOARD_AGENT_MODEL_REVIEW=
|
||||
# (each adapter renders them into its vendor's permission rules; the
|
||||
# git/gh grants per launch intent are built in). Headless runs have no
|
||||
# human at a permission prompt, so a test runner missing from this list
|
||||
# is a test the work agent cannot run.
|
||||
# is a test the work agent cannot run. Left empty — or holding only
|
||||
# whitespace or a lone comma — the board says so: a quiet `no agent
|
||||
# commands` chip in the header, and a note on every work launch.
|
||||
BOARD_AGENT_COMMANDS=python3 -m unittest
|
||||
|
||||
# What counts as a definition-of-done check (the Focus view's CHECKS
|
||||
|
||||
+99
-10
@@ -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)
|
||||
|
||||
|
||||
@@ -202,6 +254,21 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
|
||||
return proc, log_file, model
|
||||
|
||||
|
||||
def _no_commands_note() -> str | None:
|
||||
"""What a launch owes the ticker when the project configured nothing for
|
||||
its agents to run: this run can edit and commit, but it cannot check its
|
||||
own work. Only the two intents that would have run the commands say it
|
||||
(work and act-pr); a read-only kind never had them.
|
||||
|
||||
A note, never a refusal — an agent that only edits files is still
|
||||
useful, and bench does not decline work because a project is
|
||||
unconfigured. The header says the same thing standing still."""
|
||||
if config.agent_commands():
|
||||
return None
|
||||
return ("no project commands configured, so it cannot run this project's "
|
||||
"tests — set BOARD_AGENT_COMMANDS in manager/local/.env")
|
||||
|
||||
|
||||
def _fresh_branch_point() -> tuple[str | None, str | None]:
|
||||
"""Where a brand-new task branch should start: the newest main that
|
||||
exists. With an `origin` remote, fetch its main (bounded by
|
||||
@@ -304,9 +371,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]
|
||||
@@ -374,8 +444,9 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
|
||||
summary = (f"{name} is back on {filename} — continuing branch {branch}"
|
||||
if continuing else
|
||||
f"{name} started on {filename} (branch {branch})")
|
||||
if base_note:
|
||||
summary += f" — {base_note}"
|
||||
for note in (base_note, _no_commands_note()):
|
||||
if note:
|
||||
summary += f" — {note}"
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "agent", "file": filename,
|
||||
"summary": summary,
|
||||
@@ -386,7 +457,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 +668,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 +714,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")
|
||||
@@ -670,9 +755,13 @@ def start_pr_fix(filename: str, stage: str) -> dict:
|
||||
}
|
||||
with state.LOCK:
|
||||
state.AGENTS[agent_id] = record
|
||||
summary = f"{name} is acting on the review of {filename}'s PR"
|
||||
note = _no_commands_note()
|
||||
if note:
|
||||
summary += f" — {note}"
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "agent", "file": filename,
|
||||
"summary": f"{name} is acting on the review of {filename}'s PR",
|
||||
"summary": summary,
|
||||
})
|
||||
threading.Thread(target=_reap_pr_fix, args=(agent_id, proc, log_file),
|
||||
daemon=True).start()
|
||||
|
||||
+52
-6
@@ -661,6 +661,7 @@
|
||||
<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="cmdchip" hidden style="cursor:default"></div>
|
||||
<div class="livechip" id="livechip" title="open Sessions"></div>
|
||||
<button id="themebtn">Daylight</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
@@ -1066,6 +1067,27 @@ function renderSync() {
|
||||
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
|
||||
}
|
||||
|
||||
/* The commands a headless agent may run are the one thing it cannot work
|
||||
around: with none configured a work agent still edits and commits, but it
|
||||
can never run this project's tests, and nothing said so until a run had
|
||||
already ended saying it. Same shape as the drive's "no driver" — a quiet
|
||||
statement of what this project has not set up, in the settled register,
|
||||
never `--alarm`: nothing is failing here. A project that set it, by
|
||||
detection or by hand, sees nothing at all. */
|
||||
function renderAgentCommands() {
|
||||
const el = $('#cmdchip');
|
||||
const missing = S.state && S.state.hasAgentCommands === false;
|
||||
el.hidden = !missing;
|
||||
if (!missing) return;
|
||||
el.title = 'Headless agents have no project commands to run here, so a ' +
|
||||
"work agent cannot run this project's tests. Set BOARD_AGENT_COMMANDS " +
|
||||
'in manager/local/.env — comma-separated command prefixes, e.g. ' +
|
||||
'"npm test". Work still runs; the agent simply cannot check itself.';
|
||||
el.innerHTML = `<span class="dot" style="background:var(--idle)"></span>` +
|
||||
`<span style="color:var(--muted)">no agent commands</span>` +
|
||||
`<span class="mono">BOARD_AGENT_COMMANDS</span>`;
|
||||
}
|
||||
|
||||
/* ── a phase in flight ────────────────────────────────────────────────── */
|
||||
|
||||
/* The phases the header has something to say about: one running, or one
|
||||
@@ -1214,6 +1236,7 @@ function render() {
|
||||
renderPhases();
|
||||
renderViews();
|
||||
renderSync();
|
||||
renderAgentCommands();
|
||||
if (S.view === 'flight') renderFlight();
|
||||
else if (S.view === 'focus') renderFocus();
|
||||
else renderCards();
|
||||
@@ -1347,8 +1370,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 +1385,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 +1428,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 +1456,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 +1656,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 +2549,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 +
|
||||
|
||||
@@ -105,6 +105,16 @@ ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
|
||||
# the adapter's own knowledge; this list is the project's half.
|
||||
AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", "python3 -m unittest")
|
||||
|
||||
|
||||
def agent_commands() -> list[str]:
|
||||
"""The prefixes as an adapter reads them: comma-separated, blanks
|
||||
dropped — so whitespace, or a lone comma, is exactly nothing
|
||||
configured. The adapters split the same string in their own standalone
|
||||
copies of `split_commands()`; this is core's, and it is what the board
|
||||
asks so the page and the launch can never disagree about *empty*."""
|
||||
return [part.strip() for part in AGENT_COMMANDS.split(",") if part.strip()]
|
||||
|
||||
|
||||
# Model per launch intent — an opaque vendor-native name core passes to the
|
||||
# adapter untranslated (what names mean anything is vendor knowledge). Empty
|
||||
# = inherit the vendor's own default, exactly today's behaviour. A per-intent
|
||||
|
||||
@@ -42,6 +42,10 @@ def state_payload() -> dict:
|
||||
"phases": phases.public_state(),
|
||||
"drive": drive.public(),
|
||||
"hasDriver": config.driver_path() is not None,
|
||||
# whether this project gave its headless agents anything to run. The
|
||||
# one setting an agent cannot work around, so the board says it is
|
||||
# missing rather than letting a run discover it — see the header chip
|
||||
"hasAgentCommands": bool(config.agent_commands()),
|
||||
"branches": github.task_branches(),
|
||||
"commands": config.commands(),
|
||||
"commandRuns": commands.public(),
|
||||
|
||||
@@ -2,6 +2,27 @@ You are addressing review feedback on a pull request for a task from this
|
||||
repository's task board.
|
||||
|
||||
You are in the task's git worktree on branch `{branch}`. The PR is {pr}.
|
||||
|
||||
**This run is a single non-interactive turn.** Nobody is watching it and
|
||||
there is no second turn: when your reply ends, the process exits. Work
|
||||
you meant to finish afterwards is lost with it, and the board judges the
|
||||
run by what you actually left behind.
|
||||
|
||||
Two habits end runs early, so neither is allowed here:
|
||||
- Do not start something in the background and end your turn to wait for
|
||||
it. There is no monitor, no notification and no resume. If a check
|
||||
takes minutes, run it in the foreground and wait for it inside this
|
||||
turn.
|
||||
- Do not promise to come back to something. There is no coming back — do
|
||||
it now, or say plainly in your report that it is not done.
|
||||
|
||||
So commit early and push often: work that is not committed dies with the
|
||||
process, and a commit you never pushed never reaches the PR. Commit
|
||||
before you start anything long-running — the test suite especially —
|
||||
then commit and push again after it. A commit is cheap and a lost run is
|
||||
not, and an early commit can always be improved on later in the same
|
||||
turn.
|
||||
|
||||
The task, for what the work was supposed to be:
|
||||
|
||||
--- TASK ---
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
You are reviewing a pull request for a task on this repository's task board —
|
||||
you are NOT implementing anything.
|
||||
|
||||
**This run is a single non-interactive turn.** Nobody is watching it and
|
||||
there is no second turn: when your reply ends, the process exits. Work
|
||||
you meant to finish afterwards is lost with it, and the board judges the
|
||||
run by what you actually left behind.
|
||||
|
||||
Two habits end runs early, so neither is allowed here:
|
||||
- Do not start something in the background and end your turn to wait for
|
||||
it. There is no monitor, no notification and no resume. If a check
|
||||
takes minutes, run it in the foreground and wait for it inside this
|
||||
turn.
|
||||
- Do not promise to come back to something. There is no coming back — do
|
||||
it now, or say plainly in your report that it is not done.
|
||||
|
||||
So post your verdict to GitHub during the turn, before the reply that
|
||||
ends it: a verdict you only described in your report never reached the
|
||||
PR.
|
||||
|
||||
The task is `{filename}`, its branch is `{branch}`, and its PR is {pr}.
|
||||
The task content, for what the work was supposed to be:
|
||||
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
You are reviewing a task on this repository's task board for continued
|
||||
relevance — you are NOT implementing it.
|
||||
|
||||
**This run is a single non-interactive turn.** Nobody is watching it and
|
||||
there is no second turn: when your reply ends, the process exits. Work
|
||||
you meant to finish afterwards is lost with it, and the board judges the
|
||||
run by what you actually left behind.
|
||||
|
||||
Two habits end runs early, so neither is allowed here:
|
||||
- Do not start something in the background and end your turn to wait for
|
||||
it. There is no monitor, no notification and no resume. If a check
|
||||
takes minutes, run it in the foreground and wait for it inside this
|
||||
turn.
|
||||
- Do not promise to come back to something. There is no coming back — do
|
||||
it now, or say plainly in your report that it is not done.
|
||||
|
||||
Your report is the only thing this run leaves behind, so finish your
|
||||
investigation and write it in the reply that ends the turn. There is no
|
||||
later message to put it in.
|
||||
|
||||
The task is `{stage}/{filename}`. Its content:
|
||||
|
||||
--- TASK ---
|
||||
|
||||
@@ -4,6 +4,25 @@ You are in an isolated git worktree on branch `{branch}` created for this task.
|
||||
All your work happens here: commit to this branch, do not push, do not merge,
|
||||
and do not switch branches.
|
||||
|
||||
**This run is a single non-interactive turn.** Nobody is watching it and
|
||||
there is no second turn: when your reply ends, the process exits. Work
|
||||
you meant to finish afterwards is lost with it, and the board judges the
|
||||
run by what you actually left behind.
|
||||
|
||||
Two habits end runs early, so neither is allowed here:
|
||||
- Do not start something in the background and end your turn to wait for
|
||||
it. There is no monitor, no notification and no resume. If a check
|
||||
takes minutes, run it in the foreground and wait for it inside this
|
||||
turn.
|
||||
- Do not promise to come back to something. There is no coming back — do
|
||||
it now, or say plainly in your report that it is not done.
|
||||
|
||||
So commit early and commit often: work that is not committed dies with
|
||||
the process. Commit before you start anything long-running — the test
|
||||
suite especially — and commit again after it. A commit is cheap and a
|
||||
lost run is not, and an early commit can always be improved on later in
|
||||
the same turn.
|
||||
|
||||
Read AGENTS.md at the repo root first and follow it, including its
|
||||
definition of done — run whatever checks it names until they pass.
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""A board whose agents cannot run anything says so (task 46).
|
||||
|
||||
`BOARD_AGENT_COMMANDS` is the one setting a headless agent cannot work
|
||||
around: a test runner missing from it is a test the work agent cannot run.
|
||||
Since the install stopped asking for it, a project the detector does not
|
||||
recognise starts with it empty — and until this card nothing mentioned that
|
||||
until a run had already ended with an agent explaining it could not verify
|
||||
its work.
|
||||
|
||||
Three places have to agree on *empty*: the splitter (core's, and the
|
||||
standalone copies each adapter carries), the state payload the page reads,
|
||||
and the launch that says it in the ticker. So the suite is one class each,
|
||||
plus the page's own function run under node.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORE = REPO / "manager" / "core"
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import agents # noqa: E402
|
||||
import config # noqa: E402
|
||||
|
||||
from tests.test_phase_runs import PhaseCase, card, git # noqa: E402
|
||||
|
||||
BOARD = CORE / "board.html"
|
||||
NODE = shutil.which("node")
|
||||
ALONE = "77-alone.md"
|
||||
PR_URL = "https://github.com/acme/widget/pull/7"
|
||||
|
||||
# The forms of "nothing configured" a person can actually produce: never
|
||||
# set, cleared, left as whitespace, or reduced to the separator.
|
||||
NOTHING = ("", " ", "\t", ",", " , ", ",,", " ,\t,")
|
||||
SOMETHING = {"npm test": ["npm test"],
|
||||
" npm test ": ["npm test"],
|
||||
"npm test, make check": ["npm test", "make check"],
|
||||
"python3 -m unittest,": ["python3 -m unittest"]}
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class WhatCountsAsNothing(unittest.TestCase):
|
||||
"""`config.agent_commands()` is the board's half of the split the
|
||||
adapters already do. If the two disagreed, a board could show a chip a
|
||||
launch contradicts, or stay silent about a run with nothing to run."""
|
||||
|
||||
def commands(self, raw: str) -> list[str]:
|
||||
saved = config.AGENT_COMMANDS
|
||||
self.addCleanup(setattr, config, "AGENT_COMMANDS", saved)
|
||||
config.AGENT_COMMANDS = raw
|
||||
return config.agent_commands()
|
||||
|
||||
def test_every_shape_of_empty_is_empty(self):
|
||||
for raw in NOTHING:
|
||||
self.assertEqual(self.commands(raw), [],
|
||||
f"{raw!r} configures nothing")
|
||||
|
||||
def test_a_value_survives_stripped(self):
|
||||
for raw, expected in SOMETHING.items():
|
||||
self.assertEqual(self.commands(raw), expected)
|
||||
|
||||
def test_it_agrees_with_the_adapters(self):
|
||||
"""Both shipped adapters carry a standalone `split_commands()` (the
|
||||
hooks run outside the board's imports). Same answers, or the board
|
||||
is guessing about somebody else's rules."""
|
||||
splitters = [
|
||||
_load("claude_hook_settings",
|
||||
CORE / "adapters" / "claude" / "hook_settings.py").split_commands,
|
||||
_load("opencode_permission_config",
|
||||
CORE / "adapters" / "opencode" / "permission_config.py").split_commands,
|
||||
]
|
||||
for raw in (*NOTHING, *SOMETHING):
|
||||
for split in splitters:
|
||||
self.assertEqual(self.commands(raw), split(raw),
|
||||
f"core and an adapter disagree about {raw!r}")
|
||||
|
||||
|
||||
class TheStatePayloadSaysWhichItIs(unittest.TestCase):
|
||||
"""One boolean, beside `hasDriver` — the same shape of fact, answered by
|
||||
the server so the page never re-parses the setting itself."""
|
||||
|
||||
def flag(self, raw: str) -> bool:
|
||||
import httpd
|
||||
saved = config.AGENT_COMMANDS
|
||||
self.addCleanup(setattr, config, "AGENT_COMMANDS", saved)
|
||||
config.AGENT_COMMANDS = raw
|
||||
return httpd.state_payload()["hasAgentCommands"]
|
||||
|
||||
def test_empty_is_reported_as_empty(self):
|
||||
for raw in NOTHING:
|
||||
self.assertFalse(self.flag(raw), f"{raw!r} is nothing configured")
|
||||
|
||||
def test_a_configured_board_reports_true(self):
|
||||
self.assertTrue(self.flag("npm test"))
|
||||
self.assertTrue(self.flag("python3 -m unittest"))
|
||||
|
||||
def test_the_page_does_not_read_the_raw_setting(self):
|
||||
html = BOARD.read_text(encoding="utf-8")
|
||||
self.assertNotIn("state.agentCommands", html)
|
||||
self.assertIn("hasAgentCommands", html)
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "node is needed to run the page's own rules")
|
||||
class TheHeaderChip(unittest.TestCase):
|
||||
"""The indicator itself, run as the page runs it: a stub element in
|
||||
place of the DOM, a state payload in place of the server."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
match = re.search(r"function renderAgentCommands\(\) \{.*?\n\}",
|
||||
cls.html, re.S)
|
||||
if match is None:
|
||||
raise AssertionError("board.html no longer defines renderAgentCommands")
|
||||
cls.src = match.group(0)
|
||||
|
||||
def chip(self, state: dict | None) -> dict:
|
||||
script = (
|
||||
"const el = {hidden: null, title: '', innerHTML: ''};\n"
|
||||
"function $(sel) { if (sel !== '#cmdchip') "
|
||||
"throw new Error('unexpected ' + sel); return el; }\n"
|
||||
"var S = " + json.dumps({"state": state}) + ";\n"
|
||||
+ self.src + "\nrenderAgentCommands();\n"
|
||||
"console.log(JSON.stringify(el));\n")
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
def test_an_empty_setting_is_said_once_naming_setting_and_file(self):
|
||||
chip = self.chip({"hasAgentCommands": False})
|
||||
|
||||
self.assertFalse(chip["hidden"])
|
||||
self.assertIn("BOARD_AGENT_COMMANDS", chip["innerHTML"] + chip["title"])
|
||||
self.assertIn("manager/local/.env", chip["title"],
|
||||
"a reader who has never opened that file has to be told "
|
||||
"which file it is")
|
||||
|
||||
def test_a_configured_board_shows_nothing_at_all(self):
|
||||
chip = self.chip({"hasAgentCommands": True})
|
||||
|
||||
self.assertTrue(chip["hidden"])
|
||||
self.assertEqual(chip["innerHTML"], "",
|
||||
"nothing is drawn, not even hidden")
|
||||
|
||||
def test_a_payload_that_does_not_say_is_not_guessed_at(self):
|
||||
"""An older server, or the first frame before any state: the board
|
||||
says nothing rather than accusing a project of being unconfigured."""
|
||||
self.assertTrue(self.chip(None)["hidden"])
|
||||
self.assertTrue(self.chip({})["hidden"])
|
||||
|
||||
def test_it_is_not_an_alarm(self):
|
||||
"""Nothing is failing — something is unconfigured. `--alarm` is
|
||||
reserved for blocked, failed or HIGH."""
|
||||
self.assertNotIn("--alarm", self.src)
|
||||
self.assertNotIn("--accent", self.src, "and nothing here is working")
|
||||
self.assertIn("var(--idle)", self.src)
|
||||
|
||||
def test_the_chip_exists_and_is_drawn_every_frame(self):
|
||||
self.assertIn('id="cmdchip"', self.html)
|
||||
render = re.search(r"function render\(\) \{.*?\n\}", self.html, re.S).group(0)
|
||||
self.assertIn("renderAgentCommands();", render)
|
||||
|
||||
|
||||
class ALaunchWithNothingToRun(PhaseCase):
|
||||
"""The sharper half: the moment it matters is the launch. It is a note
|
||||
in the ticker beside the run's own line — never a refusal, because an
|
||||
agent that only edits files is still useful."""
|
||||
|
||||
SETTING = "BOARD_AGENT_COMMANDS in manager/local/.env"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.write(ALONE, card("77 — On its own", status="In Progress"),
|
||||
"in-progress")
|
||||
|
||||
def said(self) -> list[str]:
|
||||
return [s for s in self.summaries() if self.SETTING in s]
|
||||
|
||||
def test_a_work_launch_says_it_and_still_runs(self):
|
||||
self.patch(AGENT_COMMANDS="")
|
||||
|
||||
agents.start_agent(ALONE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
self.assertEqual(len(self.said()), 1, self.summaries())
|
||||
self.assertIn("cannot run this project's tests", self.said()[0])
|
||||
self.assertEqual(self.stage_of(ALONE), "review",
|
||||
"the launch was not blocked: it ran, committed and landed")
|
||||
|
||||
def test_a_configured_board_never_mentions_it(self):
|
||||
self.patch(AGENT_COMMANDS="npm test")
|
||||
|
||||
agents.start_agent(ALONE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
self.assertEqual(self.said(), [])
|
||||
self.assertFalse([s for s in self.summaries()
|
||||
if "BOARD_AGENT_COMMANDS" in s])
|
||||
|
||||
def test_whitespace_and_a_lone_comma_count_as_nothing_here_too(self):
|
||||
for raw in (" ", ","):
|
||||
with self.subTest(raw=raw):
|
||||
self.patch(AGENT_COMMANDS=raw)
|
||||
self.assertIsNotNone(agents._no_commands_note())
|
||||
|
||||
def test_the_note_rides_beside_the_branch_point_note(self):
|
||||
"""Two things worth saying about one launch, one line: the note is
|
||||
appended, it does not replace what the launch already said."""
|
||||
self.patch(AGENT_COMMANDS="")
|
||||
|
||||
agents.start_agent(ALONE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
line = self.said()[0]
|
||||
self.assertIn(f"started on {ALONE}", line)
|
||||
self.assertIn(f"task/{ALONE[:-3]}", line)
|
||||
|
||||
def test_acting_on_a_pr_says_it_too(self):
|
||||
"""↻ act on PR is a work agent with a push — same intent, same
|
||||
commands, same silence to break."""
|
||||
self.patch(AGENT_COMMANDS="")
|
||||
text = card("77 — On its own", status="Review").replace(
|
||||
"**Priority:** High\n", f"**Priority:** High\n**PR:** {PR_URL}\n")
|
||||
(self.tasks / "in-progress" / ALONE).unlink()
|
||||
self.write(ALONE, text, "review")
|
||||
git(self.repo, "branch", f"task/{ALONE[:-3]}")
|
||||
|
||||
agents.start_pr_fix(ALONE, "review")
|
||||
self.settle()
|
||||
|
||||
self.assertEqual(len(self.said()), 1, self.summaries())
|
||||
self.assertIn("acting on the review", self.said()[0])
|
||||
|
||||
def test_a_read_only_kind_says_nothing(self):
|
||||
"""`◔ still true?` never had the project's commands: telling it
|
||||
about them would be noise on every card in every stage."""
|
||||
self.patch(AGENT_COMMANDS="")
|
||||
self.adapter_is("#!/usr/bin/env python3\n"
|
||||
"print('RELEVANCE REVIEW: Still relevant')\n")
|
||||
|
||||
agents.start_review(ALONE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
self.assertEqual(self.said(), [])
|
||||
|
||||
|
||||
class TheDocumentedTrade(unittest.TestCase):
|
||||
"""The install stopped asking on purpose; AGENTS.md carries the reason,
|
||||
so it has to carry what the board now does about it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
||||
cls.flat = re.sub(r"\s+", " ", doc.replace("**", "").replace("`", ""))
|
||||
|
||||
def test_the_indicator_is_written_down(self):
|
||||
self.assertIn("no agent commands", self.flat)
|
||||
self.assertIn("BOARD_AGENT_COMMANDS", self.flat)
|
||||
|
||||
def test_it_says_the_launch_is_not_blocked(self):
|
||||
self.assertIn("never refuses a launch", self.flat)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,385 @@
|
||||
"""A work agent must refuse a phase card (task 54).
|
||||
|
||||
`▸ run phase` guards its own door — a card that is not a phase is refused
|
||||
there. This is the other half of that gate, on the neighbour's door: a
|
||||
phase card's body is a list of other cards, so a work agent handed one
|
||||
implements the table of contents, which is exactly what happened the first
|
||||
time a phase reached the board. The refusal is a server rule because the
|
||||
page that offers one action or the other can be stale or bypassed.
|
||||
|
||||
Which headless kinds a phase card may host is *decided* here rather than
|
||||
left to omission, so each of the four has a case saying which it is.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
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 phases # noqa: E402
|
||||
import state # noqa: E402
|
||||
|
||||
from tests.test_phase_runs import (ONE, PHASE, PHASE_BRANCH, PhaseCase, # noqa: E402
|
||||
card, git, wait_for)
|
||||
|
||||
BOARD = REPO / "manager" / "core" / "board.html"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
ALONE = "77-alone.md"
|
||||
PR_URL = "https://github.com/acme/widget/pull/7"
|
||||
|
||||
# Reads, says something, writes nothing — what a read-only kind does.
|
||||
REPORTS = """#!/usr/bin/env python3
|
||||
print("RELEVANCE REVIEW: Still relevant")
|
||||
"""
|
||||
|
||||
# Commits, then waits for a file beside the worktrees directory before
|
||||
# exiting — a run held open for as long as a test needs it in flight.
|
||||
BLOCKS = """#!/usr/bin/env python3
|
||||
import os, subprocess, time
|
||||
cwd = os.environ["AGENT_CWD"]
|
||||
open(os.path.join(cwd, "work.txt"), "w").write("work\\n")
|
||||
subprocess.run(["git", "-C", cwd, "add", "-A"], check=True)
|
||||
subprocess.run(["git", "-C", cwd, "-c", "user.email=a@b", "-c", "user.name=stub",
|
||||
"commit", "-q", "-m", "work"], check=True)
|
||||
go = os.path.join(os.path.dirname(os.path.dirname(cwd)), "worktrees", "go")
|
||||
for _ in range(1200):
|
||||
if os.path.exists(go):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
print("WORK REPORT: the work is committed")
|
||||
"""
|
||||
|
||||
|
||||
class LaunchCase(PhaseCase):
|
||||
"""PhaseCase's world — a real repo, a real adapter, a real phase card in
|
||||
in-progress/ — with the launches aimed at the card rather than the run."""
|
||||
|
||||
def only_in(self, filename: str, text: str, stage: str) -> None:
|
||||
"""One card, one stage — a rewrite that moves it rather than
|
||||
leaving the board reading the same number twice."""
|
||||
for slug in config.STAGE_DIRS:
|
||||
path = self.tasks / slug / filename
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
self.write(filename, text, stage)
|
||||
|
||||
def ordinary(self, stage: str = "in-progress", **kw) -> None:
|
||||
"""A card that is not a phase, in the stage a launch wants it in."""
|
||||
self.only_in(ALONE, card("77 — On its own",
|
||||
status=config.STAGE_LABELS[stage], **kw), stage)
|
||||
|
||||
def as_phase(self, filename: str, stage: str, listed: str | None = None) -> None:
|
||||
"""The same card, now typed Phase — what a person writes when they
|
||||
decide a card coordinates rather than builds."""
|
||||
self.only_in(filename, card("77 — On its own", kind="Phase",
|
||||
status=config.STAGE_LABELS[stage],
|
||||
cards=listed), stage)
|
||||
|
||||
def with_pr(self, filename: str, title: str, stage: str, **kw) -> None:
|
||||
text = card(title, status=config.STAGE_LABELS[stage], **kw)
|
||||
self.only_in(filename, text.replace(
|
||||
"**Priority:** High\n",
|
||||
f"**Priority:** High\n**PR:** {PR_URL}\n"), stage)
|
||||
|
||||
def running(self) -> list[dict]:
|
||||
return [r for r in state.AGENTS.values() if r["status"] == "running"]
|
||||
|
||||
def worktrees(self) -> list[str]:
|
||||
return [line.split(" ", 1)[1] for line in
|
||||
git(self.repo, "worktree", "list", "--porcelain").stdout.splitlines()
|
||||
if line.startswith("worktree ")]
|
||||
|
||||
|
||||
class TheWorkAgentRefusesAPhaseCard(LaunchCase):
|
||||
def test_a_phase_card_is_refused_and_told_what_to_run_instead(self):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
agents.start_agent(PHASE, "in-progress")
|
||||
|
||||
self.assertIn("▸ run phase", str(caught.exception))
|
||||
self.assertIn("phase card", str(caught.exception))
|
||||
|
||||
def test_the_refusal_leaves_nothing_behind(self):
|
||||
"""Refused with `_validate`, so it costs nothing: no branch, no
|
||||
worktree, no process, nothing for anyone to clean up."""
|
||||
with self.assertRaises(ValueError):
|
||||
agents.start_agent(PHASE, "in-progress")
|
||||
|
||||
self.assertFalse(self.branch_exists(f"task/{PHASE[:-3]}"))
|
||||
self.assertFalse((config.WORKTREES / PHASE[:-3]).exists())
|
||||
self.assertEqual(self.worktrees(), [str(self.repo)])
|
||||
self.assertEqual(state.AGENTS, {}, "no run was ever recorded")
|
||||
|
||||
def test_it_refuses_ahead_of_the_claim(self):
|
||||
"""The order the shape depends on: a refusal must not write the card
|
||||
it refused. In team mode a launch claims an unheld card, so an
|
||||
assignee appearing here would mean the guard ran too late."""
|
||||
self.patch(COMMIT_MOVES=True)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
agents.start_agent(PHASE, "in-progress")
|
||||
|
||||
self.assertNotIn("**Assignee:**", self.text(PHASE))
|
||||
|
||||
def test_a_takeover_is_refused_just_the_same(self):
|
||||
"""The deliberate second click reassigns a card; it does not make a
|
||||
table of contents into a brief."""
|
||||
self.patch(COMMIT_MOVES=True)
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
agents.start_agent(PHASE, "in-progress", takeover=True)
|
||||
|
||||
self.assertIn("▸ run phase", str(caught.exception))
|
||||
self.assertNotIn("**Assignee:**", self.text(PHASE))
|
||||
|
||||
def test_a_phase_card_with_no_list_yet_is_still_a_phase_card(self):
|
||||
"""`**Type:** Phase` is the whole of the reading, the same one
|
||||
`phases._phase_card` refuses a non-phase by. A list not written yet
|
||||
is an authoring mistake to fix, not an invitation to build it."""
|
||||
self.as_phase(ALONE, "in-progress")
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
agents.start_agent(ALONE, "in-progress")
|
||||
|
||||
self.assertIn("▸ run phase", str(caught.exception))
|
||||
|
||||
def test_an_ordinary_card_starts_work_exactly_as_it_did(self):
|
||||
self.ordinary()
|
||||
|
||||
agent = agents.start_agent(ALONE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
self.assertEqual(agent["branch"], f"task/{ALONE[:-3]}")
|
||||
self.assertTrue(self.branch_exists(f"task/{ALONE[:-3]}"))
|
||||
self.assertEqual(self.stage_of(ALONE), "review",
|
||||
"the ordinary path is untouched: commits, then review/")
|
||||
|
||||
def test_the_two_gates_are_mirror_images(self):
|
||||
"""The bug was an asymmetry, so the symmetry is the test: each door
|
||||
refuses the card the other one is for, and says so."""
|
||||
self.ordinary()
|
||||
|
||||
with self.assertRaises(ValueError) as work:
|
||||
agents.start_agent(PHASE, "in-progress")
|
||||
with self.assertRaises(ValueError) as run:
|
||||
phases.start_phase(ALONE, "in-progress")
|
||||
|
||||
self.assertIn("phase card", str(work.exception))
|
||||
self.assertIn("not a phase", str(run.exception))
|
||||
self.assertEqual(state.AGENTS, {})
|
||||
self.assertFalse(self.branch_exists(f"phase/{ALONE[:-3]}"))
|
||||
|
||||
|
||||
class ARunInFlightIsLeftAlone(LaunchCase):
|
||||
"""The guard is about starting. A card retyped under a running agent is
|
||||
a person's edit, not a reason to break the run underneath it."""
|
||||
|
||||
def test_a_card_that_becomes_a_phase_mid_run_still_lands(self):
|
||||
self.ordinary()
|
||||
self.adapter_is(BLOCKS)
|
||||
go = config.WORKTREES / "go"
|
||||
|
||||
agents.start_agent(ALONE, "in-progress")
|
||||
self.assertTrue(
|
||||
wait_for(lambda: (config.WORKTREES / ALONE[:-3] / "work.txt").is_file()),
|
||||
"the agent never got as far as its commit")
|
||||
# the edit, while the process is alive and the reaper has not run
|
||||
self.as_phase(ALONE, "in-progress")
|
||||
self.assertTrue(self.running(), "the run ended before the edit landed")
|
||||
go.write_text("done\n", encoding="utf-8")
|
||||
self.settle()
|
||||
|
||||
record = next(iter(state.AGENTS.values()))
|
||||
self.assertEqual(record["rc"], 0)
|
||||
self.assertEqual(record["status"], "done", "the run ended as it would have")
|
||||
self.assertEqual(self.stage_of(ALONE), "review",
|
||||
"and the card moved on, phase line or not")
|
||||
|
||||
def tearDown(self):
|
||||
# never leave a blocked adapter behind if an assertion jumped the wire
|
||||
(config.WORKTREES / "go").parent.mkdir(parents=True, exist_ok=True)
|
||||
(config.WORKTREES / "go").write_text("done\n", encoding="utf-8")
|
||||
wait_for(lambda: not self.running(), timeout=10)
|
||||
|
||||
|
||||
class WhichKindsAPhaseCardMayHost(LaunchCase):
|
||||
"""Four kinds, four deliberate answers — the two that would work on the
|
||||
card refuse it, the two that only read it are allowed."""
|
||||
|
||||
def test_still_true_is_allowed_on_a_phase_card(self):
|
||||
self.adapter_is(REPORTS)
|
||||
|
||||
agent = agents.start_review(PHASE, "in-progress")
|
||||
self.assertTrue(wait_for(lambda: not self.running()))
|
||||
|
||||
self.assertEqual(agent["mode"], "review")
|
||||
self.assertIsNone(agent["worktree"], "read-only: nothing is cut for it")
|
||||
self.assertIn("Relevance review", self.text(PHASE))
|
||||
|
||||
def test_reviewing_the_phase_pr_is_allowed_and_names_the_phase_branch(self):
|
||||
"""The prompt asks GitHub for the diff by branch, and a phase's PR
|
||||
is from its own branch — `task/<stem>` was never cut."""
|
||||
self.adapter_is(REPORTS)
|
||||
self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase",
|
||||
cards="- 31 — Stand up site/\n")
|
||||
|
||||
agent = agents.start_pr_review(PHASE, "review")
|
||||
self.assertTrue(wait_for(lambda: not self.running()))
|
||||
|
||||
self.assertEqual(agent["branch"], PHASE_BRANCH)
|
||||
|
||||
def test_an_ordinary_cards_pr_review_still_names_its_task_branch(self):
|
||||
self.adapter_is(REPORTS)
|
||||
self.with_pr(ONE, "31 — Stand up site/", "review")
|
||||
|
||||
agent = agents.start_pr_review(ONE, "review")
|
||||
self.assertTrue(wait_for(lambda: not self.running()))
|
||||
|
||||
self.assertEqual(agent["branch"], f"task/{ONE[:-3]}")
|
||||
|
||||
def test_acting_on_a_phase_pr_is_refused_and_says_where_it_belongs(self):
|
||||
"""↻ act on PR is the same work agent with a push, and a phase's PR
|
||||
carries its members' commits."""
|
||||
self.with_pr(PHASE, "40 — Ship the site", "review", kind="Phase",
|
||||
cards="- 31 — Stand up site/\n")
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
agents.start_pr_fix(PHASE, "review")
|
||||
|
||||
self.assertIn("phase card", str(caught.exception))
|
||||
self.assertIn("member", str(caught.exception))
|
||||
self.assertFalse((config.WORKTREES / PHASE[:-3]).exists())
|
||||
self.assertEqual(state.AGENTS, {})
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "node is needed to run the page's own rules")
|
||||
class TheCardSaysWhichStateItIsIn(unittest.TestCase):
|
||||
"""A phase card in in-progress/ that nobody has started used to look
|
||||
exactly like one mid-run. These run the page's own function over the
|
||||
snapshots the server sends."""
|
||||
|
||||
PARTS = (r"function phaseProgress\(p\) \{.*?\n\}",
|
||||
r"function phaseFlight\(task\) \{.*?\n\}")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
parts = []
|
||||
for pattern in cls.PARTS:
|
||||
match = re.search(pattern, cls.html, re.S)
|
||||
if match is None:
|
||||
raise AssertionError(f"board.html no longer defines {pattern!r}")
|
||||
parts.append(match.group(0))
|
||||
cls.src = "\n".join(parts)
|
||||
cls.card_fn = re.search(r"function cardFor\(task\) \{.*?\n\}",
|
||||
cls.html, re.S).group(0)
|
||||
|
||||
def flight(self, snapshot: dict | None, stage: str = "in-progress") -> object:
|
||||
task = {"file": PHASE, "stage": stage, "isPhase": True}
|
||||
script = (self.src + "\nvar S = { state: { phases: "
|
||||
+ json.dumps({PHASE: snapshot} if snapshot else {})
|
||||
+ " } };\nconsole.log(JSON.stringify(phaseFlight("
|
||||
+ json.dumps(task) + ")));\n")
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
def snapshot(self, **extra) -> dict:
|
||||
return {"file": PHASE, "members": [], "halted": None, "haltedAt": None,
|
||||
"haltedWhy": None, "running": False, "stopped": False,
|
||||
"started": False, **extra}
|
||||
|
||||
def test_a_phase_nobody_has_started_says_so(self):
|
||||
flight = self.flight(self.snapshot())
|
||||
|
||||
self.assertTrue(flight["idle"], "nothing is happening, so nothing breathes")
|
||||
self.assertEqual(flight["pill"], "not started")
|
||||
self.assertIn("▸ run phase", flight["line"])
|
||||
|
||||
def test_a_held_phase_is_its_own_state(self):
|
||||
flight = self.flight(self.snapshot(started=True, stopped=True))
|
||||
|
||||
self.assertTrue(flight["idle"])
|
||||
self.assertEqual(flight["pill"], "held")
|
||||
|
||||
def test_a_running_phase_is_unchanged(self):
|
||||
flight = self.flight(self.snapshot(
|
||||
started=True, running=True,
|
||||
members=[{"number": "31", "file": ONE, "title": "Stand up site/",
|
||||
"state": "running"}]))
|
||||
|
||||
self.assertFalse(flight.get("idle"), "a run is work: it keeps the accent")
|
||||
self.assertIn("on #31", flight["line"])
|
||||
|
||||
def test_a_halted_phase_is_unchanged(self):
|
||||
flight = self.flight(self.snapshot(started=True, halted="31: its CI is red",
|
||||
haltedAt="31", haltedWhy="its CI is red"))
|
||||
|
||||
self.assertTrue(flight["bad"])
|
||||
self.assertFalse(flight.get("idle"))
|
||||
|
||||
def test_a_phase_the_runner_has_not_read_says_nothing(self):
|
||||
"""Before the first beat there is no snapshot, and a card that
|
||||
guessed would be worse than one that waits."""
|
||||
self.assertIsNone(self.flight(None))
|
||||
|
||||
def test_a_settled_phase_card_is_not_told_it_never_started(self):
|
||||
"""review/ and done/ are past the question — the card there is
|
||||
waiting on a person, which its stage already says."""
|
||||
self.assertIsNone(self.flight(self.snapshot(started=True), stage="review"))
|
||||
self.assertIsNone(self.flight(self.snapshot(started=True), stage="done"))
|
||||
|
||||
def test_idle_wears_the_settled_register_and_never_the_accent(self):
|
||||
block = re.search(r"if \(flight && flight\.idle\) \{.*?\n \}",
|
||||
self.card_fn, re.S).group(0)
|
||||
self.assertIn("var(--idle)", block)
|
||||
for colour in ("--accent", "--alarm", "--calm"):
|
||||
self.assertNotIn(colour, block,
|
||||
"not started is neither work, an alarm nor a verdict")
|
||||
self.assertIn("!flight.bad && !flight.idle", self.card_fn,
|
||||
"an unstarted phase must not read as an agent working")
|
||||
self.assertIn("flight.bad || flight.idle ? '' :", self.card_fn,
|
||||
"…and nothing is still arriving, so there is no caret")
|
||||
|
||||
def test_the_halt_and_the_failure_still_outrank_it(self):
|
||||
idle = self.card_fn.index("if (flight && flight.idle)")
|
||||
self.assertLess(idle, self.card_fn.index("if (failure)"))
|
||||
self.assertLess(idle, self.card_fn.index("if (flight && flight.bad)"))
|
||||
|
||||
|
||||
class TheDocumentedGuard(unittest.TestCase):
|
||||
"""The doctrine this card restores: the file-carried gates exist because
|
||||
a UI layer can be stale, so AGENTS.md has to say this one is there."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
||||
# the doc is hard-wrapped, so read it as the sentence it is
|
||||
cls.flat = re.sub(r"\s+", " ", cls.doc.replace("**", ""))
|
||||
|
||||
def test_the_refusal_is_written_down_with_the_action_it_names(self):
|
||||
self.assertIn("It also refuses a phase card", self.flat)
|
||||
self.assertIn("the refusal names ▸ run phase", self.flat)
|
||||
|
||||
def test_the_kinds_a_phase_card_may_host_are_named(self):
|
||||
self.assertIn("▸ start work and ↻ act on PR refuse it", self.flat)
|
||||
self.assertIn("◔ still true? and ◔ review PR", self.flat)
|
||||
|
||||
def test_the_unstarted_card_is_described(self):
|
||||
self.assertIn("`not started`", self.doc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -416,10 +416,24 @@ class ThePhaseCardsSummary(BoardViewCase):
|
||||
|
||||
self.assertIsNone(self.flight())
|
||||
|
||||
def test_a_phase_whose_run_ended_has_nothing_to_say(self):
|
||||
def test_a_phase_in_progress_with_no_run_says_it_has_not_started(self):
|
||||
"""It said nothing here once, which made a phase nobody had started
|
||||
look exactly like one mid-run — the header chip is absent in both
|
||||
cases. Task 54 gave the card the distinction; it is quiet, and it is
|
||||
not work, so nothing about it breathes."""
|
||||
self.a_phase_of_two()
|
||||
|
||||
self.assertIsNone(self.flight(phases=self.snapshot(running=False)))
|
||||
flight = self.flight(phases=self.snapshot(running=False))
|
||||
|
||||
self.assertTrue(flight["idle"])
|
||||
self.assertEqual(flight["pill"], "not started")
|
||||
|
||||
def test_a_phase_that_was_held_says_that_instead(self):
|
||||
self.a_phase_of_two()
|
||||
|
||||
flight = self.flight(phases=self.snapshot(running=False, stopped=True))
|
||||
|
||||
self.assertEqual(flight["pill"], "held")
|
||||
|
||||
|
||||
class Wiring(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""A headless run is one non-interactive turn, and the prompts say so.
|
||||
|
||||
Every core prompt template carries the identical "this run is a single
|
||||
non-interactive turn" block, before the task body, naming the trap that
|
||||
killed card 47: backgrounding a long command and ending the turn to wait
|
||||
for it. Each template then says what its own run loses when the turn ends
|
||||
early — a commit, a push, a posted verdict, the report itself — and none
|
||||
of it disturbs the marker lines the board parses out of the same output.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
PROMPTS = REPO / "manager" / "core" / "prompts"
|
||||
TEMPLATES = ("work.md", "review.md", "review-pr.md", "act-pr.md")
|
||||
|
||||
# The shared block runs from the first line to the last; both ends are
|
||||
# fixed so a template that paraphrases either one fails here.
|
||||
START = "**This run is a single non-interactive turn.**"
|
||||
END = "or say plainly in your report that it is not done."
|
||||
|
||||
BODY = "--- TASK ---"
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (PROMPTS / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(name: str) -> str:
|
||||
text = _text(name)
|
||||
start = text.find(START)
|
||||
if start < 0:
|
||||
raise AssertionError(f"{name} never says the run is a single turn")
|
||||
end = text.find(END, start)
|
||||
if end < 0:
|
||||
raise AssertionError(f"{name} lost the end of the one-turn block")
|
||||
return text[start:end + len(END)]
|
||||
|
||||
|
||||
class SharedBlock(unittest.TestCase):
|
||||
def test_identical_across_all_templates(self):
|
||||
reference = _block(TEMPLATES[0])
|
||||
for name in TEMPLATES[1:]:
|
||||
self.assertEqual(_block(name), reference,
|
||||
f"{name} drifted from work.md's one-turn block")
|
||||
|
||||
def test_it_states_the_shape_of_the_run(self):
|
||||
block = " ".join(_block("work.md").split())
|
||||
self.assertIn("no second turn", block)
|
||||
self.assertIn("the process exits", block)
|
||||
|
||||
def test_it_names_the_trap(self):
|
||||
block = " ".join(_block("work.md").split())
|
||||
self.assertIn("Do not start something in the background and end "
|
||||
"your turn to wait for it", block)
|
||||
self.assertIn("no monitor", block)
|
||||
self.assertIn("run it in the foreground", block)
|
||||
self.assertIn("Do not promise to come back to something", block)
|
||||
|
||||
def test_it_comes_before_the_task_body(self):
|
||||
"""A brief read after the task is the one the agent skims."""
|
||||
for name in TEMPLATES:
|
||||
text = _text(name)
|
||||
self.assertLess(text.find(START), text.find(BODY),
|
||||
f"{name} says it after the task body")
|
||||
|
||||
def test_block_survives_str_format(self):
|
||||
"""Prompts are filled via str.format, so a literal brace here
|
||||
would break every launch."""
|
||||
block = _block("work.md")
|
||||
self.assertNotIn("{", block)
|
||||
self.assertNotIn("}", block)
|
||||
|
||||
|
||||
class WhatEachRunLoses(unittest.TestCase):
|
||||
"""The block is the reason; each template gives the instruction that
|
||||
follows from it for the run it drives."""
|
||||
|
||||
def test_work_commits_around_long_commands(self):
|
||||
text = " ".join(_text("work.md").split())
|
||||
self.assertIn("commit early and commit often", text)
|
||||
self.assertIn("Commit before you start anything long-running", text)
|
||||
self.assertIn("commit again after it", text)
|
||||
|
||||
def test_act_pr_commits_and_pushes(self):
|
||||
text = " ".join(_text("act-pr.md").split())
|
||||
self.assertIn("commit early and push often", text)
|
||||
self.assertIn("Commit before you start anything long-running", text)
|
||||
self.assertIn("never pushed never reaches the PR", text)
|
||||
|
||||
def test_review_pr_posts_its_verdict_inside_the_turn(self):
|
||||
text = " ".join(_text("review-pr.md").split())
|
||||
self.assertIn("post your verdict to GitHub during the turn", text)
|
||||
|
||||
def test_review_writes_its_report_inside_the_turn(self):
|
||||
text = " ".join(_text("review.md").split())
|
||||
self.assertIn("Your report is the only thing this run leaves behind",
|
||||
text)
|
||||
|
||||
|
||||
class MarkersUndisturbed(unittest.TestCase):
|
||||
"""agents.py parses the marker lines out of the same output; the new
|
||||
prose must not compete for 'the first line'."""
|
||||
|
||||
MARKERS = {
|
||||
"work.md": "NOT READY: <one-line reason>",
|
||||
"act-pr.md": "ADDRESSED: <one line on what changed>",
|
||||
"review-pr.md": "PR REVIEW: <APPROVE | REQUEST CHANGES>",
|
||||
"review.md": "RELEVANCE REVIEW: <Still relevant | Partly done | "
|
||||
"Already done | Needs rewrite>",
|
||||
}
|
||||
|
||||
def test_markers_still_follow_the_new_block(self):
|
||||
for name, marker in self.MARKERS.items():
|
||||
text = _text(name)
|
||||
self.assertIn(marker, text, f"{name} lost its marker line")
|
||||
self.assertLess(text.find(START), text.find(marker),
|
||||
f"{name} now states its marker before the block")
|
||||
|
||||
def test_not_ready_keeps_its_place_in_work(self):
|
||||
"""The NOT READY instruction is still the only thing in work.md
|
||||
claiming a reply's first line, and it still sits after the task."""
|
||||
text = _text("work.md")
|
||||
self.assertEqual(text.count("FIRST line"), 1)
|
||||
self.assertLess(text.find("--- END TASK ---"), text.find("FIRST line"))
|
||||
self.assertNotIn("FIRST line", _block("work.md"))
|
||||
self.assertNotIn("NOT READY", _block("work.md"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user