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:
+93
-4
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user