Files
bench/manager/core/watch.py
T
istosandClaude Opus 5 d94b6ee423 A failed run is a state the card wears
An agent that exits non-zero was the least-handled outcome on the board:
one ticker line that scrolled away, a card that looked exactly as it did
before the launch, and the log's contents — usually the whole story — left
on disk. Three launches died in an API outage and the board said nothing a
person would notice.

So the outcome is recorded on the run: exit code, ended-at, and the
cleaned tail of its log as the excerpt (a launch that died before the
agent spoke says so rather than showing blank). From that the card takes
the alarm border and a `run failed` pill, with the excerpt on hover and in
full in the card sheet; a toast fires, because failures are rare and
actionable; and the ticker line stays, now naming what the log ended on.

Every headless kind lands in _finish, so work, act-pr, PR review and the
relevance check are all covered. The state is scoped to the run and the
stage: the next launch supersedes it, and the watcher drops it when the
card moves, so nothing follows a card into review/.

The way is cleared for the relaunch too: a failed run with no commits has
its worktree and empty branch removed — the reasoning a decline already
uses — so ▸ start work works without a hand `git worktree remove`. A
failed run with commits keeps its worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:10:26 +02:00

93 lines
3.4 KiB
Python

"""Disk watcher: the directories are the source of truth, so poll and narrate.
Catches moves the HTTP API never saw — a file dragged by hand, an agent,
another tool, or a pull from origin/main — and attributes them via the
expectations registered in state and the arrivals registered by sync.
Attribution is also the trigger gate. **State syncs; reactions don't**: a
move a pull applied is somebody else's action reaching this replica, so it
renders and narrates and nothing else — the side effects (opening a PR)
belong to the board whose user made the move. Every future automation hung
off a stage transition asks the same question here.
"""
from __future__ import annotations
import time
import agents
import config
import github
import state
import sync
def _board_sig() -> dict[str, set[str]]:
sig = {}
for slug in config.STAGE_DIRS:
directory = config.TASKS / slug
sig[slug] = {p.name for p in directory.glob("*.md")} if directory.is_dir() else set()
return sig
def _actor(filename: str, stage: str) -> tuple[str, bool]:
"""Who did this, and whether it happened somewhere else.
A move this board made is claimed from the expectations; one a pull
brought carries its commit author's name and is *remote* — this board
is only rendering it; a plain mv on this disk is nobody in particular,
but it is still this board's own disk, so it acts.
"""
actor = state.claim_expected(filename, stage)
if actor == "disk":
who = sync.arrived_actor(filename)
if who:
return who, True
return actor, False
def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
"""Two board signatures → the events between them."""
prev_loc = {f: s for s, files in prev.items() for f in files}
cur_loc = {f: s for s, files in cur.items() for f in files}
for f, stage in sorted(cur_loc.items()):
if f in prev_loc and prev_loc[f] != stage:
actor, remote = _actor(f, stage)
state.record_board_event({
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
"actor": actor, "remote": remote,
"summary": f"{f} moved {prev_loc[f]}{stage} ({actor})",
})
# a failed run is worn by the card in the stage it died in —
# wherever the card goes next, it arrives without the alarm
agents.forget_failure(f)
if stage == "review" and not remote:
# a card entering review with a work branch gets a PR — on
# the actor's board only, or the team gets one PR attempt
# per replica
github.open_pr_async(f)
elif f not in prev_loc:
actor, remote = _actor(f, stage)
state.record_board_event({
"kind": "new", "file": f, "to": stage, "actor": actor,
"remote": remote,
"summary": f"{f} appeared in {stage}/"
+ (f" ({actor})" if actor != "disk" else ""),
})
def watcher(interval: float | None = None) -> None:
interval = config.WATCH_INTERVAL if interval is None else interval
prev = _board_sig()
while True:
time.sleep(interval)
try:
cur = _board_sig()
except OSError:
continue
if cur == prev:
continue
narrate(prev, cur)
prev = cur
state.broadcast({"type": "board"})