Scheduling core, state store, CLI and unit tests

One tick polls in-flight runs, posts results, classifies the first line and
moves state/assignee; failures get a structured note with transient retry and
backoff, timeouts are killed and handed over, resume failures and long threads
rebuild with a handoff summary. Then the lowest Todo items are dispatched within
terminal concurrency and serialize groups, and a heartbeat is written.
CLI: tick, new, show, comment, state, assign, smoke, check-config.
This commit is contained in:
jj.wang
2026-10-21 07:04:32 +00:00
parent 9535122c99
commit 31850c4a81
11 changed files with 1426 additions and 0 deletions
View File
+129
View File
@@ -0,0 +1,129 @@
"""In-memory fakes for Plane and executors (no network, no processes)."""
import copy
import tempfile
from agentd.config import Config
from agentd.core import Agentd
from agentd.executors import ExecResult
from agentd.store import Store
STATES = {"Backlog": "s-bl", "Todo": "s-todo", "In Progress": "s-run", "Waiting": "s-wait", "Review": "s-rev", "Done": "s-done"}
MEMBERS = {"window10": "u-w10", "oregon": "u-or", "kappiee": "u-k"}
LABELS = {"model:astra": "l-astra", "needs-game": "l-game", "exec:claude": "l-claude", "budget:30m": "l-b30", "cap:gpu": "l-gpu"}
def config_data():
return {
"plane": {"base_url": "https://plane.example", "workspace": "ws"},
"agentd": {"max_rounds_per_thread": 3},
"terminals": {
"win10": {"transport": "warpgate-ssh", "target": "win10", "os": "windows", "launch": "schtasks", "repo": "C:\\repo",
"plane_user": "window10", "token_key": "TOK_W10", "concurrency": 1, "capabilities": ["game"],
"executors": ["codex"]},
"oregon": {"transport": "local", "os": "linux", "launch": "subprocess", "repo": "/repo", "plane_user": "oregon",
"token_key": "TOK_OR", "concurrency": 2, "executors": ["claude-code", "shell"]},
},
"executors": {
"codex": {"run": "codex exec -", "resume": "codex exec resume {thread} -", "models": ["sol", "astra"], "default_effort": "medium"},
"claude-code": {"run": "claude -p", "resume": "claude -p --resume {thread}", "models": ["sonnet"]},
"shell": {"run": "{script}", "resume": False},
},
"projects": {"GWENT": {
"id": "proj", "key_prefix": "GW", "reviewer": "oregon", "answerer": "kappiee",
"states": {"todo": "Todo", "running": "In Progress", "waiting": "Waiting", "review": "Review", "done": "Done"},
"routing": {"by_assignee": {"window10": "win10", "oregon": "oregon"},
"by_label": {"model:astra": {"model": "astra", "effort": "high"}, "needs-game": {"serialize_group": "game"},
"exec:claude": {"executor": "claude-code"}},
"default_executor": {"win10": "codex", "oregon": "claude-code"}},
"retry": {"transient_max": 2, "backoff_minutes": [2, 5]},
"timeout_hours": 4,
}},
}
class FakePlane:
def __init__(self):
self.issues, self.comments, self.patches, self.n = {}, {}, [], 0
def add_issue(self, seq, state="Todo", assignee="window10", labels=(), desc="<p>do <b>it</b></p>"):
iid = "i%d" % seq
self.issues[iid] = {"id": iid, "sequence_id": seq, "name": "T-%d" % seq, "description_html": desc,
"state": STATES[state], "assignees": [MEMBERS[assignee]], "labels": [LABELS[l] for l in labels]}
self.comments[iid] = []
return iid
def add_comment(self, iid, html):
self.n += 1
c = {"id": "c%d" % self.n, "created_at": "2026-10-21T00:00:%02d" % self.n, "comment_html": html}
self.comments[iid].append(c)
return c
def texts(self, iid):
from agentd.plane import html_to_text
return [html_to_text(c["comment_html"]) for c in self.comments[iid]]
def state_of(self, iid):
return {v: k for k, v in STATES.items()}[self.issues[iid]["state"]]
def assignee_of(self, iid):
return {v: k for k, v in MEMBERS.items()}[self.issues[iid]["assignees"][0]]
# client API used by core
def states(self, pid): return dict(STATES)
def labels(self, pid): return dict(LABELS)
def members(self): return dict(MEMBERS)
def list_issues(self, pid): return copy.deepcopy(list(self.issues.values()))
def list_comments(self, pid, iid): return copy.deepcopy(self.comments[iid])
def post_comment(self, pid, iid, html): return copy.deepcopy(self.add_comment(iid, html))
def set_state(self, pid, iid, state, assignees=None):
self.patches.append((iid, state, assignees))
self.issues[iid]["state"] = state
if assignees is not None:
self.issues[iid]["assignees"] = assignees
class FakeExecutor:
def __init__(self, resume=True):
self.resume = resume
self.started, self.killed, self.done = [], [], {}
def start(self, run):
self.started.append(run)
return {"run_dir": "/runs/" + run.run_id, "run_id": run.run_id, "thread_id": run.thread_id}
def poll(self, handle):
return "finished" if handle["run_id"] in self.done else "running"
def collect(self, handle):
return self.done[handle["run_id"]]
def kill(self, handle):
self.killed.append(handle["run_id"])
def finish(self, message, exit_code=0, thread="th-1", tail=""):
run = self.started[-1]
self.done[run.run_id] = ExecResult(message, exit_code, thread, ["/runs/%s/last.md" % run.run_id], tail)
class Harness:
def __init__(self, data=None):
self.cfg = Config(data or config_data())
self.plane = FakePlane()
self.execs = {}
self.clock = [1_000_000.0]
self.tmp = tempfile.TemporaryDirectory()
self.d = Agentd(self.cfg, store=Store(self.tmp.name, echo=False), plane_factory=lambda term: self.plane,
executor_factory=self.executor, now=lambda: self.clock[0])
def executor(self, term, name):
return self.execs.setdefault((term, name), FakeExecutor(resume=name != "shell"))
def tick(self):
return self.d.tick()
def rec(self, iid):
return self.d.store.load()["projects"]["GWENT"]["issues"][iid]
def close(self):
self.tmp.cleanup()
+71
View File
@@ -0,0 +1,71 @@
import copy
import json
import os
import unittest
from unittest import mock
from agentd import ROOT, config
from tests.fakes import config_data
SCHEMA = json.loads((ROOT / "config" / "schema.json").read_text())
class ConfigTest(unittest.TestCase):
def test_example_config_is_valid(self):
cfg = config.load(ROOT / "config" / "agentd.example.yaml")
self.assertIn("GWENT", cfg.projects)
self.assertEqual(cfg.token_env("win10"), "PLANE_WINDOW10_API_TOKEN")
def test_fixture_is_valid_with_both_validators(self):
self.assertEqual(config.validate(config_data()), [])
self.assertEqual(config.mini_validate(config_data(), SCHEMA), [])
def check_both(self, data, fragment):
for errs in (config.validate(data), config.mini_validate(data, SCHEMA) or config.semantic_errors(data)):
self.assertTrue(errs, "expected an error mentioning %r" % fragment)
self.assertTrue(any(fragment in e for e in errs), errs)
def test_missing_required(self):
d = config_data()
del d["terminals"]["win10"]["token_key"]
self.check_both(d, "token_key")
def test_bad_enum(self):
d = config_data()
d["terminals"]["oregon"]["transport"] = "telnet"
self.check_both(d, "telnet")
def test_unknown_key(self):
d = config_data()
d["projects"]["GWENT"]["routing"]["by_label"]["needs-game"]["serialise"] = "x"
self.check_both(d, "serialise")
def test_wrong_type(self):
d = config_data()
d["terminals"]["oregon"]["concurrency"] = "two"
self.check_both(d, "concurrency")
def test_semantic_routing_to_unknown_terminal(self):
d = config_data()
d["projects"]["GWENT"]["routing"]["by_assignee"]["mac"] = "mac"
self.assertTrue(any("unknown terminal 'mac'" in e for e in config.validate(d)))
def test_semantic_default_executor_not_on_terminal(self):
d = config_data()
d["projects"]["GWENT"]["routing"]["default_executor"]["win10"] = "shell"
self.assertTrue(any("not in that terminal's executors" in e for e in config.validate(d)))
def test_semantic_unknown_executor_on_terminal(self):
d = copy.deepcopy(config_data())
d["terminals"]["oregon"]["executors"].append("gemini")
self.assertTrue(any("unknown executor 'gemini'" in e for e in config.validate(d)))
def test_tokens_come_from_env_named_by_key(self):
cfg = config.Config(config_data())
with mock.patch.dict(os.environ, {"TOK_W10": "secret"}, clear=False):
self.assertEqual(cfg.token("win10"), "secret")
self.assertEqual(cfg.secret_env_args(), ["--secret-env TOK_OR=TOK_OR", "--secret-env TOK_W10=TOK_W10"])
if __name__ == "__main__":
unittest.main()
+223
View File
@@ -0,0 +1,223 @@
import unittest
from tests.fakes import Harness, config_data
class CoreTest(unittest.TestCase):
def setUp(self):
self.h = Harness()
self.p = self.h.plane
def tearDown(self):
self.h.close()
def codex(self):
return self.h.execs[("win10", "codex")]
def test_pick_starts_lowest_todo_and_marks_in_progress(self):
a, b = self.p.add_issue(7), self.p.add_issue(3)
self.p.add_issue(1, state="Backlog")
self.p.add_issue(2, assignee="kappiee") # not routed
self.h.tick()
runs = self.codex().started
self.assertEqual([r.run_id for r in runs], ["GW-3-1"]) # concurrency 1 on win10
self.assertEqual(self.p.state_of(b), "In Progress")
self.assertEqual(self.p.state_of(a), "Todo")
self.assertTrue(self.p.texts(b)[-1].startswith("[worker] 第 1 轮开始:codex / sol / medium @ win10,新线程"))
self.assertIsNone(runs[0].thread_id)
self.assertIn("do it", runs[0].prompt)
def test_review_question_progress_transitions(self):
for tag, state, who in (("review", "Review", "oregon"), ("question", "Waiting", "kappiee"),
("blocked", "Waiting", "kappiee"), ("progress", "Todo", "window10")):
with self.subTest(tag=tag):
h = Harness()
iid = h.plane.add_issue(1)
h.tick()
ex = h.execs[("win10", "codex")]
ex.finish("[%s] text" % tag)
h.tick()
if tag == "progress": # picked again in the same tick, resuming the thread
self.assertEqual(len(ex.started), 2)
self.assertEqual(ex.started[1].thread_id, "th-1")
self.assertEqual(h.plane.state_of(iid), "In Progress")
self.assertIn(("i1", "s-todo", ["u-w10"]), h.plane.patches)
else:
self.assertEqual((h.plane.state_of(iid), h.plane.assignee_of(iid)), (state, who))
texts = h.plane.texts(iid)
result = [t for t in texts if t.startswith("[codex]")][0]
self.assertIn("第 1 轮结束(exit=0,run=GW-1-1,终端 win10", result)
self.assertIn("[%s] text" % tag, result)
self.assertEqual(h.rec(iid)["tags"], [tag])
h.close()
def test_untagged_message_becomes_review(self):
iid = self.p.add_issue(1)
self.h.tick()
self.codex().finish("all done")
self.h.tick()
self.assertEqual(self.p.state_of(iid), "Review")
self.assertIn("未按协议标注首行", self.p.texts(iid)[-1])
def test_resume_sends_only_new_comments(self):
iid = self.p.add_issue(1)
self.p.add_comment(iid, "<p>old context</p>")
self.h.tick()
self.codex().finish("[question] A or B?")
self.h.tick()
self.p.add_comment(iid, "<p>B please</p>")
self.p.issues[iid].update(state="s-todo", assignees=["u-w10"])
self.h.tick()
run = self.codex().started[-1]
self.assertEqual(run.thread_id, "th-1")
self.assertIn("B please", run.prompt)
self.assertNotIn("old context", run.prompt)
self.assertNotIn("A or B?", run.prompt)
self.assertIn("续线程 th-1", self.p.texts(iid)[-1])
def test_transient_failure_retries_with_backoff_then_waits(self):
iid = self.p.add_issue(1)
self.h.tick()
self.codex().finish("", exit_code=1, tail="Connection reset by peer")
self.h.tick()
note = self.p.texts(iid)[-1]
self.assertIn("类型=crash(临时性)", note)
self.assertIn("重试 1/2", note)
self.assertEqual(self.p.state_of(iid), "In Progress")
self.assertEqual(len(self.codex().started), 1) # backoff 2 min not elapsed
self.h.clock[0] += 121
self.h.tick()
self.assertEqual(len(self.codex().started), 2)
self.assertEqual(self.codex().started[1].thread_id, "th-1") # resumes the original thread
self.assertIn("临时性错误中断", self.codex().started[1].prompt)
self.codex().finish("", exit_code=1, tail="HTTP 503 Service Unavailable")
self.h.tick()
self.h.clock[0] += 301
self.h.tick()
self.assertEqual(len(self.codex().started), 3)
self.codex().finish("", exit_code=1, tail="banner exchange failed")
self.h.tick()
self.assertEqual((self.p.state_of(iid), self.p.assignee_of(iid)), ("Waiting", "kappiee"))
self.assertIn("不再自动重试", self.p.texts(iid)[-1])
self.assertEqual(self.h.rec(iid)["retries"], 0)
def test_logic_failure_on_fresh_thread_goes_waiting(self):
iid = self.p.add_issue(1)
self.h.tick()
self.codex().finish("", exit_code=2, thread=None, tail="panic: bad config")
self.h.tick()
self.assertEqual(self.p.state_of(iid), "Waiting")
self.assertIn("类型=crash,run=GW-1-1,exit=2,重试 0/2", self.p.texts(iid)[-1])
self.assertIn("panic: bad config", self.p.texts(iid)[-1])
def test_resume_failure_rebuilds_thread_with_handoff(self):
iid = self.p.add_issue(1)
self.h.tick()
self.codex().finish("[progress] half way")
self.h.tick() # finishes round 1, starts round 2 resuming th-1
self.assertEqual(self.codex().started[1].thread_id, "th-1")
self.codex().finish("", exit_code=1, thread=None, tail="thread not found")
self.h.tick() # round 2 fails -> rebuild immediately
run = self.codex().started[2]
self.assertIsNone(run.thread_id)
self.assertIn("## 交接摘要", run.prompt)
self.assertIn("续线程失败", run.prompt)
self.assertIn("[progress] half way", run.prompt)
self.assertIn("新线程(重建:续线程失败(crash))", self.p.texts(iid)[-1])
def test_thread_rebuilt_after_max_rounds(self):
iid = self.p.add_issue(1)
self.h.tick()
for _ in range(3):
self.codex().finish("[progress] more")
self.h.tick()
runs = self.codex().started
self.assertEqual([r.thread_id for r in runs], [None, "th-1", "th-1", None])
self.assertIn("线程已续 3 轮(上限 3)", runs[3].prompt)
self.assertIn("progress, progress, progress", runs[3].prompt)
self.assertEqual(self.h.rec(iid)["thread_rounds"], 1)
def test_timeout_kills_and_waits(self):
iid = self.p.add_issue(1, labels=["budget:30m"])
self.h.tick()
self.h.clock[0] += 29 * 60
self.h.tick()
self.assertEqual(self.codex().killed, [])
self.h.clock[0] += 2 * 60
self.h.tick()
self.assertEqual(self.codex().killed, ["GW-1-1"])
self.assertEqual(self.p.state_of(iid), "Waiting")
self.assertIn("类型=timeout", self.p.texts(iid)[-1])
self.assertIn("0.5 h", self.p.texts(iid)[-1])
def test_serialize_group_and_concurrency(self):
a = self.p.add_issue(1, assignee="oregon", labels=["needs-game"])
b = self.p.add_issue(2, assignee="oregon", labels=["needs-game"])
c = self.p.add_issue(3, assignee="oregon")
d = self.p.add_issue(4, assignee="oregon")
self.h.tick()
started = [r.run_id for r in self.h.execs[("oregon", "claude-code")].started]
self.assertEqual(started, ["GW-1-1", "GW-3-1"]) # 2 slots; GW-2 waits for the game group
self.assertEqual([self.p.state_of(x) for x in (a, b, c, d)], ["In Progress", "Todo", "In Progress", "Todo"])
def test_label_routing(self):
self.p.add_issue(1, labels=["model:astra"])
self.p.add_issue(2, assignee="oregon", labels=["cap:gpu"]) # oregon lacks the capability
self.h.tick()
run = self.codex().started[0]
self.assertEqual((run.model, run.effort), ("astra", "high"))
self.assertNotIn(("oregon", "claude-code"), self.h.execs)
def test_human_moving_item_during_run_is_respected(self):
iid = self.p.add_issue(1)
self.h.tick()
self.p.issues[iid]["state"] = "s-done"
self.codex().finish("[review] ok")
self.h.tick()
self.assertEqual(self.p.state_of(iid), "Done")
self.assertIn("[review] ok", self.p.texts(iid)[-1])
def test_post_failure_keeps_run_in_flight(self):
iid = self.p.add_issue(1)
self.h.tick()
self.codex().finish("[review] ok")
orig = self.p.post_comment
self.p.post_comment = lambda *a: (_ for _ in ()).throw(RuntimeError("plane down"))
self.h.tick()
self.assertIn("inflight", self.h.rec(iid))
self.p.post_comment = orig
self.h.tick()
self.assertNotIn("inflight", self.h.rec(iid))
self.assertEqual(self.p.state_of(iid), "Review")
def test_heartbeat_written(self):
self.p.add_issue(1)
self.h.tick()
import json, pathlib
hb = json.loads((pathlib.Path(self.h.tmp.name) / "heartbeat.json").read_text())
self.assertEqual(hb["inflight"][0]["run"], "GW-1-1")
self.assertEqual(hb["errors"], [])
class EnvFailureTest(unittest.TestCase):
def test_env_failure_pauses_group_until_item_leaves_waiting(self):
data = config_data()
data["projects"]["GWENT"]["retry"]["env_patterns"] = ["foreground window lost"]
h = Harness(data)
a = h.plane.add_issue(1, labels=["needs-game"])
b = h.plane.add_issue(2, labels=["needs-game"])
h.tick()
ex = h.execs[("win10", "codex")]
ex.finish("", exit_code=1, tail="foreground window lost")
h.tick()
self.assertEqual(h.plane.state_of(a), "Waiting")
self.assertEqual(h.plane.state_of(b), "Todo") # group paused
self.assertIn("暂停派发", h.plane.texts(a)[-1])
h.plane.issues[a]["state"] = "s-done"
h.tick()
self.assertEqual(h.plane.state_of(b), "In Progress")
h.close()
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""Real LocalTransport + runner executors against throwaway scripts (local processes only, no network)."""
import os
import tempfile
import time
import unittest
from pathlib import Path
from agentd.config import Config
from agentd.executors import Run, make_executor
from agentd.transports.local import LocalTransport
from tests.fakes import config_data
FAKE_CLAUDE = """#!/usr/bin/env bash
prompt="$(cat)"
echo "args: $*" >&2
printf '{"type":"result","subtype":"success","is_error":false,"result":"[review] got %s","session_id":"sess-42"}' "${#prompt}"
"""
def wait(ex, handle, seconds=10):
end = time.time() + seconds
while time.time() < end:
if ex.poll(handle) == "finished":
return True
time.sleep(0.1)
return False
class LocalRunnerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
fake = root / "fake-claude"
fake.write_text(FAKE_CLAUDE)
fake.chmod(0o755)
data = config_data()
data["agentd"]["state_dir"] = str(root / "state")
data["terminals"]["oregon"]["bins"] = {"claude": str(fake)}
data["terminals"]["oregon"]["repo"] = str(root)
self.cfg = Config(data)
self.t = LocalTransport(self.cfg, "oregon")
self.root = root
def tearDown(self):
self.tmp.cleanup()
def test_shell_executor_round(self):
ex = make_executor(self.cfg, "oregon", "shell", self.t)
self.assertFalse(ex.resume)
run = Run("GW-1-1", str(self.root), "hello prompt", script="read line; echo \"[review] echo: $line\"")
h = ex.start(run)
self.assertTrue(wait(ex, h))
res = ex.collect(h)
self.assertEqual((res.last_message, res.exit_code, res.thread_id), ("[review] echo: hello prompt", 0, None))
def test_claude_executor_round_and_resume(self):
ex = make_executor(self.cfg, "oregon", "claude-code", self.t)
h = ex.start(Run("GW-2-1", str(self.root), "abc", model="sonnet"))
self.assertTrue(wait(ex, h))
res = ex.collect(h)
self.assertEqual((res.last_message, res.thread_id, res.exit_code), ("[review] got 3", "sess-42", 0))
h2 = ex.start(Run("GW-2-2", str(self.root), "abcd", model="sonnet", thread_id="sess-42"))
self.assertTrue(wait(ex, h2))
self.assertIn("--resume sess-42", self.t.read_text(os.path.join(h2["run_dir"], "stderr.txt")))
def test_kill_and_exit_code(self):
ex = make_executor(self.cfg, "oregon", "shell", self.t)
h = ex.start(Run("GW-3-1", str(self.root), "", script="sleep 30"))
time.sleep(0.5)
self.assertEqual(ex.poll(h), "running")
ex.kill(h)
time.sleep(0.3)
self.assertEqual(ex.poll(h), "running") # killed runner never writes exit.txt; core handles it as timeout
h = ex.start(Run("GW-3-2", str(self.root), "", script="echo partial; exit 3"))
self.assertTrue(wait(ex, h))
res = ex.collect(h)
self.assertEqual((res.exit_code, res.last_message), (3, "partial"))
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
import unittest
from agentd.core import build_prompt, handoff_summary
ISSUE = {"name": "T-1 do it", "description_html": "<p>goal &amp; scope</p>"}
COMMENTS = [
{"id": "c1", "created_at": "t1", "comment_html": "<p>please also check X</p>"},
{"id": "c2", "created_at": "t2", "comment_html": "<p>[worker] 第 1 轮开始</p>"},
{"id": "c3", "created_at": "t3", "comment_html": "<p>[codex] 第 1 轮结束</p><pre>[question] A or B?</pre>"},
{"id": "c4", "created_at": "t4", "comment_html": "<p>B, please</p>"},
]
class PromptTest(unittest.TestCase):
def test_fresh_prompt_has_everything_but_worker_comments(self):
p = build_prompt("GW-1", ISSUE, COMMENTS, set(), False, protocol="PROTO", intro="INTRO")
self.assertTrue(p.startswith("INTRO"))
self.assertIn("## 协议\nPROTO", p)
self.assertIn("## 工作项 GW-1: T-1 do it\n\ngoal & scope", p)
self.assertIn("please also check X", p)
self.assertIn("[question] A or B?", p)
self.assertNotIn("[worker]", p)
self.assertTrue(p.rstrip().endswith("不要等待人工回复。"))
def test_resumed_prompt_only_new_comments(self):
p = build_prompt("GW-1", ISSUE, COMMENTS, {"c1", "c2", "c3"}, True, protocol="PROTO", intro="INTRO")
self.assertNotIn("PROTO", p)
self.assertNotIn("INTRO", p)
self.assertNotIn("please also check X", p)
self.assertIn("B, please", p)
self.assertIn("同一线程", p)
def test_resumed_without_new_comments_asks_to_continue(self):
p = build_prompt("GW-1", ISSUE, COMMENTS, {"c1", "c2", "c3", "c4"}, True)
self.assertIn("没有新评论", p)
p = build_prompt("GW-1", ISSUE, COMMENTS, {"c1", "c2", "c3", "c4"}, True, retry_note="RETRY")
self.assertIn("RETRY", p)
self.assertNotIn("没有新评论", p)
def test_handoff(self):
rec = {"runs": 3, "tags": ["progress", "progress", "question"], "last_message": "[question] A or B?"}
h = handoff_summary(rec, "线程已续 3 轮")
self.assertIn("3 轮", h)
self.assertIn("progress, progress, question", h)
self.assertIn("[question] A or B?", h)
p = build_prompt("GW-1", ISSUE, COMMENTS, set(), False, handoff=h)
self.assertIn("## 交接摘要\n" + h, p)
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
import unittest
from agentd.core import classify_failure, classify_first_line, parse_budget
from agentd.executors import ExecResult
from agentd.executors.claude_code import parse_output
from agentd.plane import brief_to_html, html_to_text, text_to_html
class FirstLineTest(unittest.TestCase):
def test_tags(self):
self.assertEqual(classify_first_line("[review] done\nmore"), "review")
self.assertEqual(classify_first_line("\n\n [Question] which one?"), "question")
self.assertEqual(classify_first_line("**[blocked]** no device"), "blocked")
self.assertEqual(classify_first_line("[progress]"), "progress")
def test_untagged(self):
self.assertIsNone(classify_first_line("done, see [review] below"))
self.assertIsNone(classify_first_line("[done] finished"))
self.assertIsNone(classify_first_line(""))
self.assertIsNone(classify_first_line(None))
class HtmlTest(unittest.TestCase):
def test_html_to_text(self):
self.assertEqual(html_to_text("<p>a &amp; b</p><p>c<br/>d</p>"), "a & b\nc\nd")
self.assertEqual(html_to_text("<ul><li>one</li><li>two</li></ul>"), "- one\n- two")
self.assertEqual(html_to_text("<pre>x &lt; y\nz</pre>"), "x < y\nz")
self.assertEqual(html_to_text(None), "")
self.assertEqual(html_to_text("<p>a</p>\n\n\n\n<p>b</p>"), "a\n\nb")
def test_text_to_html_escapes(self):
self.assertEqual(text_to_html("a<b>\nc", pre="<x>"), "<p>a&lt;b&gt;<br>c</p><pre>&lt;x&gt;</pre>")
self.assertEqual(html_to_text(text_to_html("[worker] hi & bye")), "[worker] hi & bye")
def test_brief(self):
self.assertEqual(brief_to_html("<p>x</p>"), "<p>x</p>")
self.assertEqual(brief_to_html("a < b"), "<pre>a &lt; b</pre>")
class FailureTest(unittest.TestCase):
def kind(self, tail, exit_code=1, timed_out=False, cfg=None):
return classify_failure(ExecResult("", exit_code, None, [], tail), timed_out, cfg or {})
def test_transient_patterns(self):
for tail in ("request timed out", "Connection reset by peer", "connection refused",
"kex_exchange_identification: banner exchange: Connection to UNKNOWN port 65535: invalid format",
"HTTP 429 Too Many Requests", "unexpected status 502 Bad Gateway", "503 Service Unavailable",
"stream disconnected before completion"):
self.assertEqual(self.kind(tail), ("crash", True), tail)
def test_not_transient(self):
self.assertEqual(self.kind("SyntaxError: invalid syntax"), ("crash", False))
self.assertEqual(self.kind("", exit_code=0), ("logic", False))
def test_timeout_and_env(self):
self.assertEqual(self.kind("whatever", timed_out=True), ("timeout", False))
self.assertEqual(self.kind("window lost focus", cfg={"env_patterns": ["lost focus"]}), ("env", False))
def test_custom_transient_patterns_replace_defaults(self):
self.assertEqual(self.kind("timed out", cfg={"transient_patterns": ["flaky"]}), ("crash", False))
self.assertEqual(self.kind("flaky relay", cfg={"transient_patterns": ["flaky"]}), ("crash", True))
def test_budget(self):
self.assertEqual(parse_budget("budget:2h"), 2)
self.assertEqual(parse_budget("budget:30m"), 0.5)
self.assertEqual(parse_budget("budget:1.5"), 1.5)
self.assertIsNone(parse_budget("budget:soon"))
class ClaudeOutputTest(unittest.TestCase):
def test_parse(self):
obj = parse_output('{"type":"result","subtype":"success","is_error":false,"result":"[review] ok","session_id":"s1"}')
self.assertEqual((obj["result"], obj["session_id"]), ("[review] ok", "s1"))
self.assertEqual(parse_output('noise\n{"result":"x","session_id":"s2"}')["session_id"], "s2")
self.assertEqual(parse_output(""), {})
if __name__ == "__main__":
unittest.main()