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:
+129
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
from agentd.core import build_prompt, handoff_summary
|
||||
|
||||
ISSUE = {"name": "T-1 do it", "description_html": "<p>goal & 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()
|
||||
@@ -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 & 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 < 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<b><br>c</p><pre><x></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 < 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()
|
||||
Reference in New Issue
Block a user