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>
This commit is contained in:
istos
2026-07-30 13:10:26 +02:00
co-authored by Claude Opus 5
parent 3693b66574
commit d94b6ee423
5 changed files with 575 additions and 9 deletions
+93 -4
View File
@@ -84,6 +84,10 @@ def _agent_public(record: dict) -> dict:
# The model the launch was actually given; None = inherited the
# vendor's own default. Honesty for the Sessions/Focus views.
public["model"] = record.get("model")
public["ended"] = record.get("ended")
# The outcome a failed run leaves behind, for the card to wear (see
# _record_failure). None on every run that did not die.
public["failure"] = record.get("failure")
return public
@@ -368,6 +372,78 @@ def _discard_untouched_worktree(record: dict) -> bool:
return True
def _failure_excerpt(log_path: str | None, lines: int = 6, cap: int = 600) -> str:
"""The tail of a dead run's log, cleaned — usually the whole story
("API Error: 500 …"). A launch that died before the agent ever spoke
leaves a line or two, or nothing at all; say which rather than showing
an empty card."""
text = ""
if log_path:
try:
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
kept = [line.rstrip() for line in _clean_log(text, cap=8000).splitlines()
if line.strip()]
if not kept:
return "no output — the run died before the agent said anything"
return "\n".join(kept[-lines:])[-cap:]
def _headline(excerpt: str, cap: int = 120) -> str:
"""One line of an excerpt for a ticker line or a toast: the last one,
which is where a dying process says why."""
lines = [line for line in (excerpt or "").splitlines() if line.strip()]
return (lines[-1].strip()[:cap] if lines else "no output")
def _why(record: dict) -> str:
"""What a dead run's log ended on, for the ticker line that records it."""
return _headline((record.get("failure") or {}).get("excerpt", ""))
def _record_failure(record: dict, rc: int) -> dict:
"""A dead run is a state its card wears, not an event that scrolls by.
Every headless kind lands here, launches that died before the agent
spoke included: the outcome goes onto the run's record — exit code,
when it ended, the log's cleaned tail, and the stage the card was in —
so the board can show it, and a toast says it once to whoever is
looking. The stage is part of the state because the state is about
work in that stage: carried into review/ it would libel the next run.
"""
failure = {
"rc": rc,
"ended": record.get("ended") or time.time(),
"excerpt": _failure_excerpt(record.get("log")),
"stage": find_stage_of(record["task"]) or record.get("origin"),
"log": record.get("log"),
"mode": record.get("mode", "work"),
}
with state.LOCK:
record["failure"] = failure
name = record.get("name") or "the agent"
state.broadcast({
"type": "toast", "error": True,
"message": f"{name} failed on {record['task']} (rc={rc}) — "
f"{_headline(failure['excerpt'])}",
})
return failure
def forget_failure(filename: str) -> bool:
"""Drop a card's failed-run state. Called when the card moves stage:
the failure belonged to the work in the stage it died in, and no card
should arrive somewhere new already wearing an alarm. A relaunch needs
no call — the newer run is what the card reads."""
cleared = False
with state.LOCK:
for record in state.AGENTS.values():
if record["task"] == filename and record.pop("failure", None):
cleared = True
return cleared
def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool, int]:
rc = proc.wait()
log_file.close()
@@ -376,6 +452,10 @@ def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool
stopped = record["status"] == "stopped"
record["status"] = "stopped" if stopped else ("done" if rc == 0 else "failed")
record["rc"] = rc
record["ended"] = time.time()
failed = record["status"] == "failed"
if failed:
_record_failure(record, rc)
return record, stopped, rc
@@ -422,7 +502,14 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
elif stopped:
summary = f"{name} was held on {filename} — nothing is lost"
else:
summary = f"{name} exited on {filename} rc={rc} — see its log"
# The card now wears the failure; the ticker keeps the record of it
# and names what the log's tail said. A run that committed nothing
# also leaves nothing worth keeping, so the worktree goes and
# ▸ start work is one click again — same reasoning as a decline.
cleaned = _discard_untouched_worktree(record)
summary = (f"{name} exited on {filename} rc={rc}{_why(record)}"
+ (" (worktree cleared — relaunch when you have read it)" if cleaned
else f" (worktree {record['worktree']} kept: it has commits)"))
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "agents"})
@@ -534,7 +621,7 @@ def _reap_pr_fix(agent_id: str, proc: subprocess.Popen, log_file) -> None:
elif stopped:
summary = f"{name} was held while acting on {filename}'s PR"
else:
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — see its log"
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — {_why(record)}"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "board"})
@@ -561,7 +648,9 @@ def _reap_pr_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
if stopped:
summary = f"{name}'s PR review of {filename} was held"
elif rc != 0 or verdict is None:
elif rc != 0:
summary = f"{name}'s PR review of {filename} died (rc={rc}) — {_why(record)}"
elif verdict is None:
summary = f"{name}'s PR review of {filename} ended without a verdict — see its log"
else:
word = "approved it" if verdict == "APPROVE" else "asked for changes"
@@ -592,7 +681,7 @@ def _reap_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
if stopped:
summary = f"{name}'s check of {filename} was held"
elif rc != 0:
summary = f"{name}'s check of {filename} exited rc={rc}see its log"
summary = f"{name}'s check of {filename} exited rc={rc}{_why(record)}"
else:
summary = f"{name} on {filename}: {verdict[:140] if verdict else 'report appended to the task'}"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
+59 -2
View File
@@ -158,6 +158,9 @@
/* PR verdicts: pine when it settled, terracotta when it snagged */
.card.verdict-good{border-color:color-mix(in oklab, var(--calm) 55%, var(--border))}
.card.verdict-bad{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* the last run on this card died: the same terracotta, worn until the
next launch replaces it or the card moves stage */
.card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
/* tool chips: destinations, not statuses — they live in the card's footer,
never squeezed into the author row */
.chiprow{
@@ -437,6 +440,8 @@
padding:20px 20px 20px 6px;
}
#drawer .dhead{display:flex;align-items:center;gap:10px}
/* the dead run's excerpt, above the task itself: machine output, bounded */
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
#drawer .dbody{font-size:13px;line-height:1.6}
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
@@ -701,6 +706,30 @@ function agentFor(sid) { return (S.state?.agents || []).find(a => a.session ===
function agentOnTask(file) {
return (S.state?.agents || []).find(a => a.task === file && a.status === 'running');
}
/* The most recent run on a card. Records outlive their processes, so the
latest launch is a max-by-start question, not a find. */
function lastRunOn(file) {
return (S.state?.agents || []).reduce(
(best, a) => (a.task === file && (!best || a.started > best.started) ? a : best), null);
}
/* A dead run is a state the card wears: alarm border, `run failed` pill and
the log's tail, until the next launch replaces it (a newer run is the
latest one) or the card moves stage (the server drops the state, and the
stage stamp keeps the card honest in the seconds before the watcher
notices). Every headless kind counts — work, act-pr, PR review, relevance. */
function failedRun(task) {
const last = lastRunOn(task.file);
const failure = last && last.status === 'failed' ? last.failure : null;
return failure && failure.stage === task.stage ? failure : null;
}
/* The line a run died on: the excerpt's last, which is where a dying
process says why ("API Error: 500 …"). Bounded, so one enormous line of
machine output cannot grow the card — the whole excerpt is a hover away. */
function whyFailed(failure) {
const lines = (failure.excerpt || '').split('\n').filter(l => l.trim());
const why = lines.length ? lines[lines.length - 1].trim() : 'no output';
return why.length > 160 ? why.slice(0, 160) + '…' : why;
}
function sessionMeta(sid) { return (S.state?.sessions || []).find(m => m.id === sid); }
function isLiveSession(m) {
const fresh = (Date.now() / 1000 - (m.last || m.started || 0)) < 900;
@@ -888,10 +917,12 @@ function cardFor(task) {
const agent = agentOnTask(task.file);
const working = agent && agent.mode !== 'review';
const verdict = task.stage === 'review' ? prVerdict(task) : null;
const failure = failedRun(task);
el.className = 'card'
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
+ (working ? ' running' : '')
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
+ (failure ? ' run-failed' : '')
+ (task.stage === 'done' ? ' done-dim' : '');
el.draggable = true;
@@ -906,6 +937,13 @@ function cardFor(task) {
pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) };
tint = 'var(--alarm)';
}
if (failure) {
// the newest thing that happened here, and the only actionable one:
// it outranks a PR verdict from before the run died
pill = { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16),
title: failure.excerpt };
tint = 'var(--alarm)';
}
const high = (task.priority || '').toLowerCase() === 'high';
const top = [
`<span class="mark${working ? ' breathing' : ''}" style="background:${tint}"></span>`,
@@ -913,7 +951,8 @@ function cardFor(task) {
'<span class="spacer"></span>',
high ? '<span class="high">HIGH</span>' : '',
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span>`,
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}"` +
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
];
// two actions per state, max — whatever you'd actually do without opening the card
@@ -1010,6 +1049,12 @@ function cardFor(task) {
}
} else if (agent) {
liveLine = `<div class="well"><span class="lead">·</span><span class="wbody">warming up<span class="caret">▌</span></span></div>`;
} else if (failure) {
// "API Error: 500" one hover away instead of buried in a log file: the
// line the run died on here, the whole excerpt on hover and in the sheet
liveLine = `<div class="well bad" title="${esc(failure.excerpt)}">` +
`<span class="lead">·</span><span class="wbody">rc=${esc(failure.rc)} · ` +
`${esc(whyFailed(failure))}</span></div>`;
}
// tool chips: destinations, not statuses — they live in the card's footer
@@ -1375,16 +1420,28 @@ function renderDrawer() {
if (!S.selected) { panel.classList.remove('open'); body.innerHTML = ''; return; }
const t = S.selected;
const agent = agentOnTask(t.file);
const pill = agent && agent.mode === 'review'
const failure = failedRun(t);
const pill = failure
? { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) }
: agent && agent.mode === 'review'
? { text: 'reviewing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
: pillFor(t.stage, agent && agent.mode !== 'review');
const when = new Date(t.mtime * 1000).toLocaleString();
// the whole excerpt, not just the line the card shows — the sheet is
// where you read what killed the run without opening files on disk
const failBlock = failure
? `<div class="well bad"><span class="lead">·</span><div class="wbody">` +
`<b>run failed</b> · rc=${esc(failure.rc)} · ${esc(ago(failure.ended))} ago` +
`<pre>${esc(failure.excerpt)}</pre>` +
`<span style="color:var(--dim)">${esc(failure.log || '')}</span></div></div>`
: '';
body.innerHTML =
`<div class="dhead">` +
`<span class="mono" style="font-size:12px;color:var(--dim)">${t.number ? '#' + esc(t.number) : ''}</span>` +
`<span class="pill" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span>` +
`<span class="spacer"></span>` +
`<button id="closeDrawer">Close</button></div>` +
failBlock +
`<div class="dbody">${md(t.body)}</div>` +
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
panel.classList.add('open');
+4
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import time
import agents
import config
import github
import state
@@ -57,6 +58,9 @@ def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
"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