Files
bench/tests/test_failed_run_visible.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

392 lines
16 KiB
Python

"""A failed agent run leaves a visible trace (task 11).
Three agents died in an API outage and the board's whole answer was one
ticker line that scrolled away. So: a dead run is recorded on its launch
record (exit code, ended-at, the log's cleaned tail), the person is told
once by toast, the card wears it until the next launch or the next stage,
and the untouched worktree a dead run left behind is cleared so ▸ start
work is one click again.
Real launches through a real (stub) adapter — the adapter contract is what
a dying agent actually comes through, so nothing is mocked but the SSE
fan-out and the adapter's own binary.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
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 state # noqa: E402
import watch # noqa: E402
BOARD = REPO / "manager" / "core" / "board.html"
FILENAME = "11-a-run-that-dies.md"
STEM = FILENAME[:-3]
BRANCH = f"task/{STEM}"
CARD = """# 11 — A run that dies
**Status:** In Progress
**Priority:** High
Body text, so the prompt has something to carry.
"""
# What the outage looked like from the board's side: a line of output on
# stdout and a non-zero exit.
DIES = """#!/usr/bin/env python3
import sys
print("thinking…")
print("API Error: 500 {\\"type\\":\\"error\\",\\"error\\":{\\"type\\":\\"api_error\\"}}")
sys.exit(1)
"""
# Same death, but it committed first: there is work to keep.
DIES_WITH_WORK = """#!/usr/bin/env python3
import os, subprocess, sys
cwd = os.environ["AGENT_CWD"]
open(os.path.join(cwd, "half.txt"), "w").write("half a feature\\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", "half"], check=True)
print("API Error: 529 overloaded")
sys.exit(1)
"""
SILENT_DEATH = """#!/usr/bin/env python3
import sys
sys.exit(1)
"""
LIVES = """#!/usr/bin/env python3
print("all good")
"""
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args],
capture_output=True, text=True)
def wait_for(pred, timeout: float = 20.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if pred():
return True
time.sleep(0.05)
return False
class Launches(unittest.TestCase):
"""One repo, one card, one adapter whose behaviour each test writes."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp(prefix="bench-failed-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
self.repo = self.tmp / "repo"
self.repo.mkdir()
git(self.repo, "init", "-q", "-b", "main")
git(self.repo, "config", "user.email", "t@t")
git(self.repo, "config", "user.name", "tester")
(self.repo / "code.txt").write_text("shipped\n", encoding="utf-8")
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", "root")
tasks = self.repo / "tasks"
for slug in config.STAGE_DIRS:
(tasks / slug).mkdir(parents=True)
(tasks / "in-progress" / FILENAME).write_text(CARD, encoding="utf-8")
local = self.tmp / "local"
(local / "adapters" / config.ADAPTER).mkdir(parents=True)
self.adapter = local / "adapters" / config.ADAPTER / "run"
self.patch(REPO=self.repo, TASKS=tasks, LOCAL=local,
WORKTREES=self.tmp / "worktrees",
AGENT_DIR=self.tmp / "agent",
SESSIONS_DIR=self.tmp / "sessions",
COMMIT_MOVES=False, SYNC=False)
state.AGENTS.clear()
state.BOARD_EVENTS.clear()
state.EXPECTED_MOVES.clear()
self.addCleanup(state.AGENTS.clear)
self.addCleanup(state.BOARD_EVENTS.clear)
self.sent: list[dict] = []
self.addCleanup(setattr, state, "broadcast", state.broadcast)
state.broadcast = self.sent.append
def patch(self, **values) -> None:
for attr, value in values.items():
self.addCleanup(setattr, config, attr, getattr(config, attr))
setattr(config, attr, value)
def adapter_is(self, script: str) -> None:
self.adapter.write_text(script, encoding="utf-8")
self.adapter.chmod(self.adapter.stat().st_mode | stat.S_IEXEC)
def run_agent(self, script: str, start=None) -> dict:
"""Launch, wait for the reaper to be done with it, return the record.
Every reaper's last act is the agents broadcast, so counting those
is the honest "it has finished" — the assertions then see a settled
record rather than one mid-reap."""
self.adapter_is(script)
ended = self.sent.count({"type": "agents"})
public = (start or (lambda: agents.start_agent(FILENAME, "in-progress")))()
record = state.AGENTS[public["id"]]
self.assertTrue(
wait_for(lambda: self.sent.count({"type": "agents"}) > ended),
f"the reaper never announced the ending (status {record['status']})")
return record
def summaries(self) -> list[str]:
return [e["summary"] for e in state.BOARD_EVENTS]
def toasts(self) -> list[dict]:
return [m for m in self.sent if m.get("type") == "toast"]
def stage_of(self, filename: str = FILENAME) -> str | None:
for slug in config.STAGE_DIRS:
if (config.TASKS / slug / filename).is_file():
return slug
return None
class TheOutcomeIsRecorded(Launches):
def test_a_dead_work_run_lands_on_its_record(self):
record = self.run_agent(DIES)
self.assertEqual(record["status"], "failed")
failure = record["failure"]
self.assertEqual(failure["rc"], 1)
self.assertIn("API Error: 500", failure["excerpt"])
self.assertEqual(failure["stage"], "in-progress")
self.assertGreaterEqual(failure["ended"], record["started"])
self.assertTrue(Path(failure["log"]).is_file(),
"the failure must name a log that exists")
def test_the_card_never_advances(self):
self.run_agent(DIES)
self.assertEqual(self.stage_of(), "in-progress")
def test_the_person_is_toasted_once(self):
self.run_agent(DIES)
toasts = self.toasts()
self.assertEqual(len(toasts), 1, "a failure is one toast, not none or two")
self.assertTrue(toasts[0]["error"], "a failure toast is an alarm")
self.assertIn("API Error: 500", toasts[0]["message"])
self.assertIn(FILENAME, toasts[0]["message"])
def test_the_ticker_line_still_records_it(self):
"""This card adds surfaces; it does not move the permanent record."""
self.run_agent(DIES)
line = [s for s in self.summaries() if "rc=1" in s]
self.assertTrue(line, "the event log lost the exit line")
self.assertIn(FILENAME, line[0])
self.assertIn("API Error: 500", line[0])
def test_the_public_payload_carries_it(self):
"""The card reads the API, not the board's memory."""
self.run_agent(DIES)
public = agents.list_public()[0]
self.assertEqual(public["status"], "failed")
self.assertIn("API Error: 500", public["failure"]["excerpt"])
self.assertIsNotNone(public["ended"])
def test_a_live_run_carries_no_failure(self):
record = self.run_agent(LIVES)
self.assertEqual(record["status"], "done")
self.assertIsNone(agents.list_public()[0]["failure"])
self.assertEqual(self.toasts(), [])
class TheWayIsClearedForRelaunch(Launches):
def test_an_untouched_worktree_goes(self):
"""Nothing of value is lost — the run committed nothing — and
▸ start work refuses while the worktree exists."""
record = self.run_agent(DIES)
self.assertFalse(Path(record["worktree"]).exists(),
"a dead run with no commits must not block the relaunch")
self.assertEqual(
git(self.repo, "rev-parse", "--verify", "--quiet", BRANCH).returncode, 1,
"the empty branch goes with the worktree")
self.assertTrue(any("worktree cleared" in s for s in self.summaries()))
def test_the_relaunch_actually_works(self):
self.run_agent(DIES)
state.BOARD_EVENTS.clear()
second = self.run_agent(LIVES)
self.assertEqual(second["status"], "done")
def test_a_run_with_commits_keeps_its_worktree(self):
record = self.run_agent(DIES_WITH_WORK)
self.assertTrue(Path(record["worktree"]).exists(),
"work that was committed is never thrown away")
self.assertIn("kept", " ".join(self.summaries()))
class EveryHeadlessKind(Launches):
def test_a_dead_relevance_check_surfaces_the_same_way(self):
"""No worktree, any stage — same state on the card."""
record = self.run_agent(
DIES, start=lambda: agents.start_review(FILENAME, "in-progress"))
self.assertEqual(record["status"], "failed")
self.assertIn("API Error: 500", record["failure"]["excerpt"])
self.assertEqual(record["failure"]["stage"], "in-progress")
self.assertEqual(len(self.toasts()), 1)
def test_a_death_before_the_agent_spoke_still_says_something(self):
record = self.run_agent(SILENT_DEATH)
self.assertIn("no output", record["failure"]["excerpt"])
self.assertIn("no output", self.toasts()[0]["message"])
class TheStateClears(Launches):
def test_a_relaunch_replaces_it(self):
"""Two records for one card, and the card reads the newest: the
failure is superseded rather than cleared."""
first = self.run_agent(DIES)
time.sleep(1.1) # agent ids are stamped to the second
second = self.run_agent(LIVES)
self.assertNotEqual(first["id"], second["id"])
latest = max(agents.list_public(), key=lambda a: a["started"])
self.assertEqual(latest["id"], second["id"])
self.assertIsNone(latest["failure"])
self.assertIsNotNone(first["failure"],
"the older run keeps its own history")
def test_a_stage_move_drops_it(self):
record = self.run_agent(DIES)
self.assertIsNotNone(record["failure"])
watch.narrate({"in-progress": {FILENAME}, "to-do": set()},
{"in-progress": set(), "to-do": {FILENAME}})
self.assertIsNone(record.get("failure"),
"a card arriving in a new stage wears no old alarm")
def test_forgetting_is_per_card(self):
record = self.run_agent(DIES)
self.assertFalse(agents.forget_failure("99-someone-else.md"))
self.assertIsNotNone(record["failure"], "another card's move cleared this one")
self.assertTrue(agents.forget_failure(FILENAME))
self.assertFalse(agents.forget_failure(FILENAME), "clearing twice is a no-op")
class ExcerptTests(unittest.TestCase):
"""What the card shows, from the log alone."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp(prefix="bench-excerpt-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
def log(self, text: str) -> str:
path = self.tmp / "run.log"
path.write_text(text, encoding="utf-8")
return str(path)
def test_the_tail_is_what_you_get(self):
excerpt = agents._failure_excerpt(
self.log("\n".join(f"line {i}" for i in range(40))), lines=6)
self.assertIn("line 39", excerpt)
self.assertNotIn("line 20", excerpt)
self.assertEqual(len(excerpt.splitlines()), 6)
def test_a_tiny_log_survives_whole(self):
"""The MultiEdit flag error was 91 bytes; the excerpt handles it."""
tiny = "error: unknown option '--allowedTools MultiEdit'\n"
self.assertIn("MultiEdit", agents._failure_excerpt(self.log(tiny)))
def test_hook_noise_is_stripped(self):
excerpt = agents._failure_excerpt(
self.log("PostToolUse hook failed with status 1\nAPI Error: 500\n"))
self.assertEqual(excerpt, "API Error: 500")
def test_nothing_at_all_says_so(self):
for empty in (self.log(""), self.log(" \n\n"), str(self.tmp / "gone.log"), None):
self.assertIn("no output", agents._failure_excerpt(empty))
def test_the_headline_is_the_last_line(self):
"""A dying process says why last."""
self.assertEqual(agents._headline("thinking…\nAPI Error: 500"), "API Error: 500")
self.assertEqual(agents._headline(""), "no output")
self.assertLessEqual(len(agents._headline("x" * 400)), 120)
class TheCardWearsIt(unittest.TestCase):
"""board.html is one file with inline JS and no frontend test runner, so
these are source-level invariants — the ones that, if broken, put the
failure back out of sight."""
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
def rule(self, selector: str) -> str:
m = re.search(re.escape(selector) + r"\{([^}]*)\}", self.html)
self.assertIsNotNone(m, f"board.html lost its {selector} rule")
return m.group(1)
def test_the_border_is_the_alarm_colour(self):
self.assertIn("--alarm", self.rule(".card.run-failed"),
"failed is terracotta — the design system's one word for it")
def test_the_pill_says_run_failed(self):
m = re.search(r"\{ text: 'run failed'[^}]*\}", self.html)
self.assertIsNotNone(m, "the status slot lost its `run failed` pill")
self.assertIn("--alarm", m.group(0))
def test_the_failure_is_scoped_to_its_card_and_stage(self):
"""The state belongs to the most recent run on THIS card, in the
stage it died in: no leaking sideways, none into review/."""
m = re.search(r"function failedRun\(task\) \{(.*?)\n\}", self.html, re.DOTALL)
self.assertIsNotNone(m, "failedRun went missing")
body = m.group(1)
self.assertIn("lastRunOn(task.file)", body)
self.assertIn("'failed'", body)
self.assertIn("failure.stage === task.stage", body)
def test_the_latest_run_is_a_max_not_a_find(self):
"""Records outlive their processes; picking the first match would
pin a card to whichever run happens to be first in the list."""
m = re.search(r"function lastRunOn\(file\) \{(.*?)\n\}", self.html, re.DOTALL)
self.assertIsNotNone(m, "lastRunOn went missing")
self.assertIn("started >", m.group(1))
def test_the_excerpt_is_one_hover_away(self):
"""On the card: the alarm well, the line it died on, the whole
excerpt in the tooltip."""
m = re.search(r'<div class="well bad" title="\$\{esc\(failure\.excerpt\)\}"',
self.html)
self.assertIsNotNone(m, "the card's failure well lost its excerpt tooltip")
self.assertIn("whyFailed(failure)", self.html)
def test_the_card_sheet_shows_the_whole_excerpt(self):
self.assertRegex(self.html, r"<pre>\$\{esc\(failure\.excerpt\)\}</pre>",
"the drawer must show the excerpt without opening files")
self.assertRegex(self.html, r"#drawer \.well\.bad pre\{[^}]*max-height",
"a long excerpt needs a scroll bound in the sheet")
def test_the_server_can_toast(self):
"""The failure toast rides the generic server-toast channel."""
self.assertIn("msg.type === 'toast'", self.html)
if __name__ == "__main__":
unittest.main()