diff --git a/agentd/cli.py b/agentd/cli.py new file mode 100644 index 0000000..ede2331 --- /dev/null +++ b/agentd/cli.py @@ -0,0 +1,210 @@ +"""agentd command line. Tokens come from the environment (agent-access.py exec --secret-env ...).""" +import argparse +import os +import re +import sys +import time + +from . import config as config_mod +from .core import Agentd +from .plane import Plane, brief_to_html, html_to_text, text_to_html +from .store import Busy + + +def identity(cfg, wanted=None): + """Terminal whose token the CLI uses: --as, then agentd.cli_terminal, then the first with a token.""" + for term in [wanted, cfg.agentd.get("cli_terminal")] + list(cfg.terminals): + if term and term in cfg.terminals and cfg.token(term): + return term + sys.exit("no Plane token in the environment; run under agent-access.py exec %s" % " ".join(cfg.secret_env_args())) + + +def client(cfg, term): + return Plane(cfg.plane["base_url"], cfg.plane["workspace"], cfg.token(term), cfg.plane.get("timeout", 60)) + + +def seq_of(ref): + m = re.search(r"(\d+)$", ref) + if not m: + sys.exit("bad work item reference %r (use 12 or GW-12)" % ref) + return int(m.group(1)) + + +def cmd_tick(cfg, a): + d = Agentd(cfg) + if a.loop: + d.loop(a.interval or cfg.agentd.get("interval", 120)) + try: + d.tick(project=a.project) + except Busy as exc: + print("skip: %s" % exc) + + +def cmd_new(cfg, a, pl=None, quiet=False): + p = cfg.project(a.project) + pl = pl or client(cfg, identity(cfg, a.as_)) + text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8").read() + body = {"name": a.title, "description_html": brief_to_html(text), "state": pl.states(p["id"])[a.state]} + if a.labels: + labels = pl.labels(p["id"]) + body["labels"] = [labels[x] for x in a.labels.split(",")] + if a.assign: + body["assignees"] = [pl.members()[a.assign]] + issue = pl.create_issue(p["id"], body) + if a.module: + mods = pl.modules(p["id"]) + mid = mods.get(a.module) or pl.create_module(p["id"], a.module)["id"] + pl.add_to_module(p["id"], mid, issue["id"]) + if not quiet: + print("created %s-%s %s" % (p.get("key_prefix", a.project), issue["sequence_id"], issue["id"])) + return issue + + +def cmd_show(cfg, a): + p = cfg.project(a.project) + pl = client(cfg, identity(cfg, a.as_)) + i = pl.find_issue(p["id"], seq_of(a.ref)) + sn = {v: k for k, v in pl.states(p["id"]).items()} + ln = {v: k for k, v in pl.labels(p["id"]).items()} + un = {v: k for k, v in pl.members().items()} + print("%s-%s %s | state=%s | assignees=%s | labels=%s" % ( + p.get("key_prefix", a.project), i["sequence_id"], i["name"], sn.get(i.get("state"), i.get("state")), + [un.get(x, x) for x in i.get("assignees") or []], [ln.get(x, x) for x in i.get("labels") or []])) + cs = pl.list_comments(p["id"], i["id"]) + for c in cs[-a.n:] if a.n else []: + who = c.get("actor") or c.get("created_by") + print("--- %s (%s)" % (c.get("created_at"), un.get(who, who))) + print(html_to_text(c.get("comment_html"))[:2500]) + + +def cmd_comment(cfg, a): + p = cfg.project(a.project) + pl = client(cfg, identity(cfg, a.as_)) + i = pl.find_issue(p["id"], seq_of(a.ref)) + text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8").read() + pl.post_comment(p["id"], i["id"], text_to_html(text)) + print("commented %s" % a.ref) + + +def cmd_state(cfg, a): + p = cfg.project(a.project) + pl = client(cfg, identity(cfg, a.as_)) + i = pl.find_issue(p["id"], seq_of(a.ref)) + name = p["states"].get(a.state, a.state) # accepts keys (todo) or Plane names (Todo) + pl.set_state(p["id"], i["id"], pl.states(p["id"])[name]) + print("%s -> %s" % (a.ref, name)) + + +def cmd_assign(cfg, a): + p = cfg.project(a.project) + pl = client(cfg, identity(cfg, a.as_)) + i = pl.find_issue(p["id"], seq_of(a.ref)) + pl.set_assignees(p["id"], i["id"], [pl.members()[a.user]]) + print("%s assigned %s" % (a.ref, a.user)) + + +SMOKE_BRIEF = ("
agentd 冒烟测试。不要读写任何文件,不要运行任何命令。
" + "你的最后一条消息必须恰好是下面这一行(不要加任何别的内容):
[review] ok") + + +def cmd_smoke(cfg, a): + p = cfg.project(a.project) + if a.terminal not in cfg.terminals: + sys.exit("unknown terminal %s" % a.terminal) + pl = client(cfg, identity(cfg, a.as_)) + user = cfg.terminals[a.terminal]["plane_user"] + body = {"name": "agentd smoke %s %s" % (a.terminal, time.strftime("%Y-%m-%d %H:%M")), + "description_html": SMOKE_BRIEF, "state": pl.states(p["id"])[p["states"]["todo"]], + "assignees": [pl.members()[user]]} + issue = pl.create_issue(p["id"], body) + key = "%s-%s" % (p.get("key_prefix", a.project), issue["sequence_id"]) + print("created %s; ticking every %ss (timeout %s min)" % (key, a.interval, a.timeout)) + d = Agentd(cfg) + states = pl.states(p["id"]) + done_states = {states[p["states"][k]] for k in ("review", "waiting")} + deadline = time.time() + a.timeout * 60 + state = None + while time.time() < deadline: + try: + d.tick(project=a.project, only_issue=issue["id"]) + except Busy as exc: + print("skip: %s" % exc) + state = pl.get_issue(p["id"], issue["id"]).get("state") + if state in done_states: + break + time.sleep(a.interval) + last = "" + for c in reversed(pl.list_comments(p["id"], issue["id"])): + text = html_to_text(c.get("comment_html")) + if not text.startswith("[worker]"): + last = text + break + ok = state == states[p["states"]["review"]] and re.search(r"(?m)^\[review\] ok\s*$", last) + print("%s %s: state=%s\n%s" % ("PASS" if ok else "FAIL", key, {v: k for k, v in states.items()}.get(state, state), last[-800:])) + if ok: + pl.set_state(p["id"], issue["id"], states[p["states"]["done"]]) + sys.exit(0 if ok else 1) + + +def cmd_check_config(cfg, a): + print("config %s is valid" % cfg.path) + print("projects: %s" % ", ".join(cfg.projects)) + for name, t in cfg.terminals.items(): + env = cfg.token_env(name) + print("terminal %-10s %-13s %-8s executors=%s token %s: %s" % ( + name, t["transport"], t["os"], ",".join(t["executors"]), env, "set" if os.environ.get(env) else "MISSING")) + print("cron/exec secret args: %s" % " ".join(cfg.secret_env_args())) + + +def parser(): + ap = argparse.ArgumentParser(prog="agentd", description=__doc__) + ap.add_argument("--config", help="YAML config (default: $AGENTD_CONFIG, config/agentd.local.yaml, config/agentd.yaml)") + sub = ap.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("tick", help="run the dispatcher once or in a loop") + g = s.add_mutually_exclusive_group() + g.add_argument("--once", action="store_true") + g.add_argument("--loop", action="store_true") + s.add_argument("--interval", type=int) + s.add_argument("--project") + s.set_defaults(fn=cmd_tick) + + def item(name, fn, help_): + s = sub.add_parser(name, help=help_) + s.add_argument("project") + s.add_argument("ref", help="sequence number or KEY-n") + s.add_argument("--as", dest="as_", help="terminal identity to act as") + s.set_defaults(fn=fn) + return s + + s = sub.add_parser("new", help="create a work item from a brief file") + s.add_argument("project") + s.add_argument("title") + s.add_argument("--file", required=True, help="brief file (HTML passes through, text goes in
); - = stdin")
+ s.add_argument("--assign")
+ s.add_argument("--labels", default="")
+ s.add_argument("--module")
+ s.add_argument("--state", default="Backlog")
+ s.add_argument("--as", dest="as_")
+ s.set_defaults(fn=cmd_new)
+ item("show", cmd_show, "show a work item and its last n comments").add_argument("n", nargs="?", type=int, default=1)
+ item("comment", cmd_comment, "post a comment from a file (- = stdin)").add_argument("file")
+ item("state", cmd_state, "set state (Plane name or config key)").add_argument("state")
+ item("assign", cmd_assign, "set the assignee").add_argument("user")
+ s = sub.add_parser("smoke", help="end-to-end round on a terminal: expects '[review] ok'")
+ s.add_argument("project")
+ s.add_argument("terminal")
+ s.add_argument("--interval", type=int, default=30)
+ s.add_argument("--timeout", type=int, default=30, help="minutes")
+ s.add_argument("--as", dest="as_")
+ s.set_defaults(fn=cmd_smoke)
+ sub.add_parser("check-config", help="validate the config and list token env vars").set_defaults(fn=cmd_check_config)
+ return ap
+
+
+def main(argv=None):
+ a = parser().parse_args(argv)
+ try:
+ cfg = config_mod.load(a.config)
+ except config_mod.ConfigError as exc:
+ sys.exit(str(exc))
+ a.fn(cfg, a)
diff --git a/agentd/core.py b/agentd/core.py
new file mode 100644
index 0000000..0572f75
--- /dev/null
+++ b/agentd/core.py
@@ -0,0 +1,512 @@
+"""Scheduling core. One tick per project:
+ (a) poll in-flight runs; on finish post the result, classify its first line and move state/assignee;
+ on failure post a structured note and retry (transient) or hand over (Waiting); kill on timeout;
+ (b) start the lowest-sequence Todo items whose terminal has a free slot and whose serialize group is idle;
+ (c) write the heartbeat.
+Everything is deterministic; models only run inside executors."""
+import os
+import re
+import time
+from collections import Counter
+from pathlib import Path
+
+from . import ROOT
+from .executors import ExecResult, Run, make_executor
+from .executors.base import fill
+from .plane import Plane, html_to_text, text_to_html
+from .store import Store
+from .transports import make_transport
+
+TAGS = ("review", "question", "blocked", "progress")
+WORKER = "[worker]"
+DEFAULT_TRANSIENT = [
+ r"time[sd]?[ -]?out",
+ r"connection (refused|reset)",
+ r"banner exchange",
+ r"\b429\b|too many requests|rate.?limit",
+ r"\b50[0-4]\b|internal server error|bad gateway|service unavailable",
+ r"stream (disconnected|error)",
+]
+DEFAULT_INTRO = ("你是 {terminal} 上的 {executor},仓库 {repo}。先读仓库根目录 AGENTS.md。下面是看板协作协议"
+ "(看板是 Plane,工作项编号 {prefix}-k),然后是分派给你的工作项。"
+ "你的最后一条消息会被原样贴回工作项评论,首行必须是 [review]/[question]/[blocked]/[progress] 之一。")
+OUTPUT_RULE = "输出要求:最后一条消息首行为 [review]/[question]/[blocked]/[progress] 之一,内容按协议;不要等待人工回复。"
+
+
+# ---------------------------------------------------------------- pure helpers (unit-tested)
+def classify_first_line(text):
+ """Tag of the first non-empty line (`[review]` etc.), tolerating markdown emphasis; None if untagged."""
+ first = next((l.strip() for l in (text or "").splitlines() if l.strip()), "")
+ m = re.match(r"\[(\w+)\]", first.lstrip("*_#> `").lower())
+ return m.group(1) if m and m.group(1) in TAGS else None
+
+
+def matches_any(text, patterns):
+ return any(re.search(p, text or "", re.I) for p in patterns)
+
+
+def classify_failure(result, timed_out, retry_cfg):
+ """-> (kind, transient). kind in timeout | env | crash | logic (design §4)."""
+ if timed_out:
+ return "timeout", False
+ text = "%s\n%s" % (result.tail or "", result.last_message or "")
+ if matches_any(text, retry_cfg.get("env_patterns", [])):
+ return "env", False
+ kind = "logic" if result.exit_code == 0 else "crash"
+ return kind, matches_any(text, retry_cfg.get("transient_patterns", DEFAULT_TRANSIENT))
+
+
+def parse_budget(label):
+ """`budget:2h` / `budget:90m` / `budget:1.5h` -> hours."""
+ m = re.fullmatch(r"budget:(\d+(?:\.\d+)?)([hm]?)", label)
+ if not m:
+ return None
+ value = float(m.group(1))
+ return value / 60 if m.group(2) == "m" else value
+
+
+def handoff_summary(rec, reason):
+ s = "本工作项此前已进行 %d 轮,执行器线程已重建(原因:%s)。各轮首行标签:%s。" % (
+ rec.get("runs", 0), reason or "上一线程不可续", ", ".join(rec.get("tags", [])) or "无")
+ if rec.get("last_message"):
+ s += "\n\n上一轮最后消息:\n" + rec["last_message"]
+ return s
+
+
+def build_prompt(key, issue, comments, seen, resumed, protocol="", intro="", handoff=None, retry_note=None):
+ """Fresh thread: intro + protocol + work item + handoff + all comments.
+ Resumed thread: only comments not seen before. `[worker]` comments are always skipped."""
+ parts = []
+ if resumed:
+ parts.append("工作项 %s 有新消息,请在同一线程里继续:" % key)
+ new = [c for c in comments if c.get("id") not in seen]
+ else:
+ if intro:
+ parts.append(intro)
+ if protocol:
+ parts.append("## 协议\n" + protocol.strip())
+ parts.append("## 工作项 %s: %s\n\n%s" % (key, issue.get("name", ""), html_to_text(issue.get("description_html"))))
+ if handoff:
+ parts.append("## 交接摘要\n" + handoff)
+ new = comments
+ shown = 0
+ for c in new:
+ body = html_to_text(c.get("comment_html"))
+ if not body or body.startswith(WORKER):
+ continue
+ parts.append("### 评论(%s)\n%s" % (c.get("created_at", ""), body))
+ shown += 1
+ if retry_note:
+ parts.append(retry_note)
+ elif resumed and not shown:
+ parts.append("(没有新评论;请从上次停下的地方继续推进。)")
+ parts.append(OUTPUT_RULE)
+ return "\n\n".join(parts)
+
+
+def new_rec(seq):
+ return {"seq": seq, "runs": 0, "retries": 0, "tags": [], "seen_comments": [], "thread_rounds": 0}
+
+
+# ---------------------------------------------------------------- per-project view for one tick
+class Ctx:
+ def __init__(self, name, pcfg, reader):
+ self.name, self.cfg, self.reader = name, pcfg, reader
+ self.pid = pcfg["id"]
+ self.prefix = pcfg.get("key_prefix", name)
+ self.states = reader.states(self.pid)
+ self.labels = reader.labels(self.pid)
+ self.members = reader.members()
+ self.label_names = {v: k for k, v in self.labels.items()}
+ self.member_names = {v: k for k, v in self.members.items()}
+ self.issues = {i["id"]: i for i in reader.list_issues(self.pid)}
+
+ def state_id(self, key):
+ return self.states[self.cfg["states"][key]]
+
+ def key(self, seq):
+ return "%s-%s" % (self.prefix, seq)
+
+
+class Agentd:
+ def __init__(self, cfg, store=None, plane_factory=None, executor_factory=None, transport_factory=None, now=time.time):
+ self.cfg = cfg
+ self.store = store or Store(cfg.state_dir)
+ self.now = now
+ self.log = self.store.log
+ self.plane_factory = plane_factory or (lambda term: Plane(
+ cfg.plane["base_url"], cfg.plane["workspace"], cfg.token(term), cfg.plane.get("timeout", 60)))
+ self.transport_factory = transport_factory or (lambda term: make_transport(cfg, term))
+ self.executor_factory = executor_factory or (lambda term, name: make_executor(cfg, term, name, self.transport(term)))
+ self._reset_caches()
+
+ def _reset_caches(self):
+ self._clients, self._transports, self._executors, self._ready = {}, {}, {}, {}
+
+ # ---------------------------------------------------------------- caches
+ def client(self, terminal):
+ if terminal not in self._clients:
+ self._clients[terminal] = self.plane_factory(terminal)
+ return self._clients[terminal]
+
+ def transport(self, terminal):
+ if terminal not in self._transports:
+ self._transports[terminal] = self.transport_factory(terminal)
+ return self._transports[terminal]
+
+ def executor(self, terminal, name):
+ if (terminal, name) not in self._executors:
+ self._executors[(terminal, name)] = self.executor_factory(terminal, name)
+ return self._executors[(terminal, name)]
+
+ def reader(self, pcfg):
+ """Any identity with a token can read; prefer the project's own terminals."""
+ order = list(dict.fromkeys(list(pcfg["routing"]["by_assignee"].values()) + list(self.cfg.terminals)))
+ for term in order:
+ try:
+ return self.client(term)
+ except Exception:
+ continue
+ raise RuntimeError("no Plane token available for any terminal")
+
+ # ---------------------------------------------------------------- tick
+ def tick(self, project=None, only_issue=None):
+ with self.store.lock():
+ self._reset_caches()
+ data = self.store.load()
+ errors = []
+ for name in ([project] if project else list(self.cfg.projects)):
+ try:
+ self.tick_project(data, name, only_issue)
+ except Exception as exc:
+ errors.append("%s: %r" % (name, exc))
+ self.log("%s tick error: %r" % (name, exc))
+ finally:
+ self.store.save(data)
+ self.store.heartbeat({"errors": errors, "inflight": [
+ {"project": p, "run": r["inflight"]["run_id"], "terminal": r["inflight"]["terminal"]}
+ for p, ps in data["projects"].items() for r in ps["issues"].values() if r.get("inflight")]})
+ return data
+
+ def loop(self, interval):
+ from .store import Busy
+ while True:
+ try:
+ self.tick()
+ except Busy as exc:
+ self.log("skip: %s" % exc)
+ except Exception as exc:
+ self.log("tick error: %r" % (exc,))
+ time.sleep(interval)
+
+ def tick_project(self, data, name, only_issue=None):
+ pcfg = self.cfg.project(name)
+ pstate = data["projects"].setdefault(name, {"issues": {}})
+ ctx = Ctx(name, pcfg, self.reader(pcfg))
+ for iid, rec in list(pstate["issues"].items()):
+ if rec.get("inflight"):
+ try:
+ self.poll(ctx, data, iid, rec)
+ except Exception as exc:
+ self.log("%s poll error: %r" % (ctx.key(rec.get("seq")), exc))
+ self.pick(ctx, data, pstate, only_issue)
+
+ # ---------------------------------------------------------------- (a) poll / finish
+ def poll(self, ctx, data, iid, rec):
+ fl = rec["inflight"]
+ ex = self.executor(fl["terminal"], fl["executor"])
+ if ex.poll(fl["handle"]) == "running":
+ if self.now() < fl["deadline"]:
+ return
+ self.log("%s run %s exceeded its budget; killing" % (ctx.key(rec["seq"]), fl["run_id"]))
+ try:
+ ex.kill(fl["handle"])
+ except Exception as exc:
+ self.log("kill %s failed: %r" % (fl["run_id"], exc))
+ hours = (fl["deadline"] - fl["started"]) / 3600
+ result = ExecResult("", None, fl["handle"].get("thread_id"), [fl["handle"].get("run_dir", "")],
+ "超过时限 %.1f h 未结束,已结束任务进程。" % hours)
+ return self.finish(ctx, data, iid, rec, result, timed_out=True)
+ result = ex.collect(fl["handle"])
+ if hasattr(ex, "forget"):
+ ex.forget(fl["handle"])
+ self.finish(ctx, data, iid, rec, result)
+
+ def finish(self, ctx, data, iid, rec, result, timed_out=False):
+ """Post first, then forget the in-flight run: if Plane fails, the next tick collects again."""
+ fl = rec["inflight"]
+ term, exname = fl["terminal"], fl["executor"]
+ client = self.client(term)
+ key = ctx.key(rec["seq"])
+ issue = ctx.issues.get(iid)
+ still_ours = issue is None or issue.get("state") == ctx.state_id("running")
+ message = (result.last_message or "").strip()
+ tag = classify_first_line(message)
+ ok = not timed_out and message and (tag or result.exit_code == 0)
+ if not ok:
+ return self.fail(ctx, data, iid, rec, client, result, timed_out, still_ours)
+ if tag is None:
+ message, tag = "[review]\n(未按协议标注首行,按 review 处理)\n\n" + message, "review"
+ body = message
+ limit = self.cfg.agentd.get("comment_max_chars", 6000)
+ if len(body) > limit:
+ where = result.log_paths[-1] if result.log_paths else fl["handle"].get("run_dir", "")
+ body = body[:limit] + "\n…(已截断,全文见 %s 上 %s)" % (term, where)
+ header = "[%s] 第 %d 轮结束(exit=%s,run=%s,终端 %s,日志 %s)" % (
+ exname, fl["round"], result.exit_code, fl["run_id"], term, fl["handle"].get("run_dir", ""))
+ self.post(ctx, client, iid, rec, header, body)
+ self.close_run(rec, result)
+ rec["tags"].append(tag)
+ rec.update(last_message=message[:4000], last_tag=tag, retries=0)
+ if still_ours:
+ self.guarded(key, self.transition, ctx, client, iid, tag, self.cfg.terminals[term]["plane_user"])
+ else:
+ self.log("%s was moved during the run; not changing its state" % key)
+ self.log("%s run %s finished exit=%s tag=%s" % (key, fl["run_id"], result.exit_code, tag))
+
+ def close_run(self, rec, result):
+ fl = rec.pop("inflight")
+ rec["last_exit"] = result.exit_code
+ if result.thread_id and self.executor(fl["terminal"], fl["executor"]).resume:
+ rec["thread_id"], rec["thread_on"] = result.thread_id, [fl["terminal"], fl["executor"]]
+ return fl
+
+ def guarded(self, key, fn, *args):
+ try:
+ fn(*args)
+ except Exception as exc:
+ self.log("%s %s failed: %r" % (key, fn.__name__, exc))
+
+ def fail(self, ctx, data, iid, rec, client, result, timed_out, still_ours):
+ fl, pcfg = rec["inflight"], ctx.cfg
+ key = ctx.key(rec["seq"])
+ retry_cfg = pcfg.get("retry", {})
+ max_n = retry_cfg.get("transient_max", 3)
+ backoff = retry_cfg.get("backoff_minutes", [2, 5, 10])
+ kind, transient = classify_failure(result, timed_out, retry_cfg)
+ n = rec.get("retries", 0)
+ who = pcfg.get("answerer") or pcfg["reviewer"]
+ if not still_ours:
+ plan, action = "none", "工作项已被移出 %s,不重试。" % pcfg["states"]["running"]
+ elif fl.get("resumed") and kind in ("crash", "logic") and not transient:
+ plan, action = "rebuild", "续线程失败,下一轮在新线程重跑(附交接摘要)。"
+ elif transient and n < max_n:
+ delay = backoff[min(n, len(backoff) - 1)]
+ n += 1
+ plan, action = "retry", "临时性错误,%g 分钟后自动重试(续原线程)。" % delay
+ else:
+ plan, action = "waiting", "不再自动重试,转 %s 并指派给 %s。" % (pcfg["states"]["waiting"], who)
+ if kind == "env" and fl.get("group"):
+ action += "串行组 %s 暂停派发,直到本项离开 %s。" % (fl["group"], pcfg["states"]["waiting"])
+ note = "%s 第 %d 轮失败:类型=%s%s,run=%s,exit=%s,重试 %d/%d。%s\n日志:%s" % (
+ WORKER, fl["round"], kind, "(临时性)" if transient else "", fl["run_id"], result.exit_code, n, max_n,
+ action, ", ".join(result.log_paths) or fl["handle"].get("run_dir", ""))
+ frame = result.tail or "(无输出)"
+ if result.last_message:
+ frame += "\n--- 最后消息\n" + result.last_message[:1500]
+ self.post(ctx, client, iid, rec, note, frame)
+ self.close_run(rec, result)
+ rec["last_tag"] = "failed:" + kind
+ if plan == "rebuild":
+ rec["rebuild"] = "续线程失败(%s)" % kind
+ rec.pop("thread_id", None)
+ rec.pop("thread_on", None)
+ rec["retry"] = {"at": self.now(), "reason": "rebuild"}
+ elif plan == "retry":
+ rec["retries"] = n
+ rec["retry"] = {"at": self.now() + delay * 60, "reason": kind}
+ elif plan == "waiting":
+ rec["retries"] = 0
+ if kind == "env" and fl.get("group"):
+ data["paused_groups"][fl["group"]] = {"project": ctx.name, "issue": iid, "key": key}
+ self.guarded(key, self.move, ctx, client, iid, "waiting", who)
+ self.log("%s run %s failed kind=%s transient=%s -> %s" % (key, fl["run_id"], kind, transient, plan))
+
+ def transition(self, ctx, client, iid, tag, terminal_user):
+ pcfg = ctx.cfg
+ if tag == "progress":
+ self.move(ctx, client, iid, "todo", terminal_user)
+ elif tag in ("question", "blocked"):
+ self.move(ctx, client, iid, "waiting", pcfg.get("answerer") or pcfg["reviewer"])
+ else:
+ self.move(ctx, client, iid, "review", pcfg["reviewer"])
+
+ def move(self, ctx, client, iid, state_key, user):
+ state, users = ctx.state_id(state_key), [ctx.members[user]]
+ client.set_state(ctx.pid, iid, state, users)
+ if iid in ctx.issues: # keep this tick's view consistent
+ ctx.issues[iid].update(state=state, assignees=users)
+
+ def post(self, ctx, client, iid, rec, text, pre=None):
+ c = client.post_comment(ctx.pid, iid, text_to_html(text, pre))
+ if isinstance(c, dict) and c.get("id"):
+ rec.setdefault("seen_comments", []).append(c["id"])
+
+ # ---------------------------------------------------------------- (b) pick / start
+ def pick(self, ctx, data, pstate, only_issue=None):
+ load, groups = Counter(), set()
+ for p in data["projects"].values():
+ for r in p["issues"].values():
+ fl = r.get("inflight")
+ if fl:
+ load[fl["terminal"]] += 1
+ if fl.get("group"):
+ groups.add(fl["group"])
+ for g, info in list(data["paused_groups"].items()):
+ if info["project"] == ctx.name:
+ issue = ctx.issues.get(info["issue"])
+ if issue is None or issue.get("state") != ctx.state_id("waiting"):
+ del data["paused_groups"][g]
+ self.log("serialize group %s resumed (%s left Waiting)" % (g, info.get("key")))
+ groups |= set(data["paused_groups"])
+ todo, running, now = ctx.state_id("todo"), ctx.state_id("running"), self.now()
+ cands = []
+ for iid, issue in ctx.issues.items():
+ if only_issue and iid != only_issue:
+ continue
+ rec = pstate["issues"].get(iid, {})
+ if rec.get("inflight"):
+ continue
+ if issue.get("state") == todo:
+ cands.append(issue)
+ elif rec.get("retry"):
+ if issue.get("state") != running:
+ rec.pop("retry")
+ elif rec["retry"]["at"] <= now:
+ cands.append(issue)
+ for issue in sorted(cands, key=lambda i: i.get("sequence_id", 0)):
+ route = self.route(ctx, issue)
+ if isinstance(route, str):
+ if route:
+ self.log("%s not dispatched: %s" % (ctx.key(issue.get("sequence_id")), route))
+ continue
+ term, group = route["terminal"], route["group"]
+ if load[term] >= self.cfg.terminals[term].get("concurrency", 1) or (group and group in groups):
+ continue
+ if not self.terminal_ready(term, load[term]):
+ continue
+ try:
+ self.start(ctx, pstate, issue, route)
+ except Exception as exc:
+ self.log("%s start failed on %s: %r" % (ctx.key(issue.get("sequence_id")), term, exc))
+ continue
+ load[term] += 1
+ if group:
+ groups.add(group)
+
+ def terminal_ready(self, term, our_load):
+ """Optional guard against processes we did not start (e.g. a manual codex session)."""
+ if term not in self._ready:
+ names = self.cfg.terminals[term].get("busy_processes", [])
+ ready = True
+ for name in names:
+ count = self.transport(term).process_count(name)
+ if count is None:
+ self.log("%s unreachable; skipping" % term)
+ ready = False
+ elif count > 0 and our_load == 0:
+ self.log("%s busy (%d %s running that agentd did not start)" % (term, count, name))
+ ready = False
+ self._ready[term] = ready
+ return self._ready[term]
+
+ def route(self, ctx, issue):
+ """Work item -> {terminal, executor, model, effort, tier, group, timeout_hours, script}; a str = not routable."""
+ pcfg, routing = ctx.cfg, ctx.cfg["routing"]
+ users = [ctx.member_names.get(a, a) for a in issue.get("assignees") or []]
+ term = next((routing["by_assignee"][u] for u in users if u in routing["by_assignee"]), None)
+ if term is None:
+ return "" # not assigned to a terminal: silently ignored
+ tcfg = self.cfg.terminals[term]
+ r = {"terminal": term, "executor": routing.get("default_executor", {}).get(term) or tcfg["executors"][0],
+ "model": None, "effort": None, "tier": None, "group": None, "script": None,
+ "timeout_hours": pcfg.get("timeout_hours", 4)}
+ caps = []
+ for label in [ctx.label_names.get(l, l) for l in issue.get("labels") or []]:
+ spec = routing.get("by_label", {}).get(label)
+ if spec:
+ for k, v in spec.items():
+ r["group" if k == "serialize_group" else k] = v
+ elif label.startswith("exec:") and label[5:] in self.cfg.executors:
+ r["executor"] = label[5:]
+ elif label.startswith("model:"):
+ r["model"] = label[6:]
+ elif label.startswith("effort:"):
+ r["effort"] = label[7:]
+ elif label.startswith("cap:"):
+ caps.append(label[4:])
+ elif parse_budget(label) is not None:
+ r["timeout_hours"] = parse_budget(label)
+ missing = [c for c in caps if c not in tcfg.get("capabilities", [])]
+ if missing:
+ return "terminal %s lacks capabilities %s" % (term, missing)
+ if r["executor"] not in tcfg["executors"]:
+ return "executor %s not available on %s" % (r["executor"], term)
+ ecfg = self.cfg.executors[r["executor"]]
+ r["model"] = r["model"] or (ecfg.get("models") or [None])[0]
+ r["effort"] = r["effort"] or ecfg.get("default_effort")
+ return r
+
+ def protocol(self, pcfg):
+ path = pcfg.get("protocol_file")
+ if not path:
+ return ""
+ p = Path(os.path.expanduser(path))
+ p = p if p.is_absolute() else ROOT / p
+ if not p.exists():
+ self.log("protocol file %s missing" % p)
+ return ""
+ return p.read_text(encoding="utf-8")
+
+ def start(self, ctx, pstate, issue, route):
+ iid, seq = issue["id"], issue["sequence_id"]
+ key = ctx.key(seq)
+ rec = pstate["issues"].setdefault(iid, new_rec(seq))
+ term, exname = route["terminal"], route["executor"]
+ tcfg, pcfg = self.cfg.terminals[term], ctx.cfg
+ ex = self.executor(term, exname)
+ is_retry = rec.pop("retry", None) is not None and issue.get("state") == ctx.state_id("running")
+ if not is_retry:
+ rec["retries"] = 0
+ k = rec["runs"] + 1
+ run_id = "%s-%d-%d" % (ctx.prefix, seq, k)
+ thread, reason = rec.get("thread_id"), rec.pop("rebuild", None)
+ max_rounds = pcfg.get("max_rounds_per_thread") or self.cfg.agentd.get("max_rounds_per_thread", 8)
+ if thread and not ex.resume:
+ thread = None
+ elif thread and rec.get("thread_on") != [term, exname]:
+ thread, reason = None, "换了终端/执行器"
+ elif thread and rec.get("thread_rounds", 0) >= max_rounds:
+ thread, reason = None, "线程已续 %d 轮(上限 %d)" % (rec["thread_rounds"], max_rounds)
+ handoff = handoff_summary(rec, reason) if not thread and rec["runs"] > 0 else None
+ retry_note = None
+ if is_retry and thread:
+ retry_note = "(上一轮因临时性错误中断,请在同一线程从中断处继续。)"
+ comments = ctx.reader.list_comments(ctx.pid, iid)
+ seen = set(rec.get("seen_comments", []))
+ intro = fill(ctx.cfg.get("intro", DEFAULT_INTRO),
+ dict(terminal=term, executor=exname, repo=tcfg["repo"], prefix=ctx.prefix, key=key))
+ prompt = build_prompt(key, issue, comments, seen, bool(thread), self.protocol(pcfg), intro, handoff, retry_note)
+ run = Run(run_id, tcfg["repo"], prompt, route["model"], route["effort"], route["tier"], thread, route["script"])
+ handle = ex.start(run)
+ now = self.now()
+ rec.update(runs=k, thread_rounds=rec.get("thread_rounds", 0) + 1 if thread else 1, inflight={
+ "run_id": run_id, "terminal": term, "executor": exname, "handle": handle, "round": k,
+ "started": now, "deadline": now + route["timeout_hours"] * 3600, "resumed": bool(thread),
+ "group": route["group"], "model": route["model"], "effort": route["effort"]})
+ if not thread:
+ rec.pop("thread_id", None)
+ rec.pop("thread_on", None)
+ rec["seen_comments"] = sorted(seen | {c["id"] for c in comments if c.get("id")})
+ rec["last_comment_at"] = max([c.get("created_at", "") for c in comments] + [rec.get("last_comment_at", "")])
+ client = self.client(term)
+ mode = ("续线程 %s" % thread[:12]) if thread else ("新线程(重建:%s)" % reason if reason else "新线程")
+ if is_retry:
+ mode += ",重试 %d" % rec.get("retries", 0)
+ detail = " / ".join(x for x in (exname, route["model"], route["effort"], route["tier"]) if x)
+ try:
+ self.move(ctx, client, iid, "running", tcfg["plane_user"])
+ self.post(ctx, client, iid, rec, "%s 第 %d 轮开始:%s @ %s,%s,run=%s。" % (WORKER, k, detail, term, mode, run_id))
+ except Exception as exc:
+ self.log("%s bookkeeping after start failed: %r" % (key, exc))
+ self.log("%s run %s started on %s (%s, %s)" % (key, run_id, term, detail, mode))
diff --git a/agentd/store.py b/agentd/store.py
new file mode 100644
index 0000000..2772df8
--- /dev/null
+++ b/agentd/store.py
@@ -0,0 +1,61 @@
+"""JSON state store under state/: one file for all projects, an exclusive tick lock, heartbeat and log.
+
+state/agentd-state.json:
+ {"projects": {PROJECT: {"issues": {issue_uuid: rec}}}, "paused_groups": {group: {...}}}
+rec: seq, runs, thread_id, thread_on [terminal, executor], thread_rounds, tags [...], last_message,
+ seen_comments [...], last_comment_at, retries, retry {at, reason}, rebuild, inflight {...}"""
+import contextlib
+import fcntl
+import json
+import os
+import time
+from pathlib import Path
+
+
+class Busy(Exception):
+ pass
+
+
+class Store:
+ def __init__(self, state_dir, echo=True):
+ self.dir = Path(state_dir)
+ self.echo = echo
+ self.path = self.dir / "agentd-state.json"
+
+ def load(self):
+ if self.path.exists():
+ data = json.loads(self.path.read_text(encoding="utf-8"))
+ else:
+ data = {}
+ data.setdefault("projects", {})
+ data.setdefault("paused_groups", {})
+ return data
+
+ def save(self, data):
+ self.dir.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
+ os.replace(tmp, self.path)
+
+ @contextlib.contextmanager
+ def lock(self):
+ self.dir.mkdir(parents=True, exist_ok=True)
+ with open(self.dir / "agentd.lock", "w") as fh:
+ try:
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ raise Busy("another tick holds %s" % (self.dir / "agentd.lock")) from None
+ yield
+
+ def heartbeat(self, info):
+ self.dir.mkdir(parents=True, exist_ok=True)
+ info = dict(info, ts=time.time(), at=time.strftime("%Y-%m-%dT%H:%M:%S"))
+ (self.dir / "heartbeat.json").write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
+
+ def log(self, msg):
+ self.dir.mkdir(parents=True, exist_ok=True)
+ line = time.strftime("%Y-%m-%dT%H:%M:%S") + " " + msg
+ if self.echo:
+ print(line, flush=True)
+ with open(self.dir / "agentd.log", "a", encoding="utf-8") as fh:
+ fh.write(line + "\n")
diff --git a/cli/agentd b/cli/agentd
new file mode 100755
index 0000000..948ee48
--- /dev/null
+++ b/cli/agentd
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+"""Entry point: agentd tick|new|show|comment|state|assign|smoke|check-config (see agentd/cli.py)."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from agentd.cli import main # noqa: E402
+
+main()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fakes.py b/tests/fakes.py
new file mode 100644
index 0000000..b72b9fe
--- /dev/null
+++ b/tests/fakes.py
@@ -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="do it
"):
+ 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()
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..9c15cc6
--- /dev/null
+++ b/tests/test_config.py
@@ -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()
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..f7262dd
--- /dev/null
+++ b/tests/test_core.py
@@ -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, "old context
")
+ self.h.tick()
+ self.codex().finish("[question] A or B?")
+ self.h.tick()
+ self.p.add_comment(iid, "B please
")
+ 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()
diff --git a/tests/test_local_runner.py b/tests/test_local_runner.py
new file mode 100644
index 0000000..1d81d4a
--- /dev/null
+++ b/tests/test_local_runner.py
@@ -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()
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
new file mode 100644
index 0000000..ce24692
--- /dev/null
+++ b/tests/test_prompt.py
@@ -0,0 +1,51 @@
+import unittest
+
+from agentd.core import build_prompt, handoff_summary
+
+ISSUE = {"name": "T-1 do it", "description_html": "goal & scope
"}
+COMMENTS = [
+ {"id": "c1", "created_at": "t1", "comment_html": "please also check X
"},
+ {"id": "c2", "created_at": "t2", "comment_html": "[worker] 第 1 轮开始
"},
+ {"id": "c3", "created_at": "t3", "comment_html": "[codex] 第 1 轮结束
[question] A or B?
"},
+ {"id": "c4", "created_at": "t4", "comment_html": "B, please
"},
+]
+
+
+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()
diff --git a/tests/test_text.py b/tests/test_text.py
new file mode 100644
index 0000000..f4cbffb
--- /dev/null
+++ b/tests/test_text.py
@@ -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("a & b
c
d
"), "a & b\nc\nd")
+ self.assertEqual(html_to_text("x < y\nz"), "x < y\nz") + self.assertEqual(html_to_text(None), "") + self.assertEqual(html_to_text("
a
\n\n\n\nb
"), "a\n\nb") + + def test_text_to_html_escapes(self): + self.assertEqual(text_to_html("a\nc", pre="a<b>
c
<x>") + self.assertEqual(html_to_text(text_to_html("[worker] hi & bye")), "[worker] hi & bye") + + def test_brief(self): + self.assertEqual(brief_to_html("
x
"), "x
") + self.assertEqual(brief_to_html("a < b"), "a < b") + + +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()