"""覆盖率 78%->85% 第 4 步:host.py main() 剩余分支的真实行为测试。 进程内调 host.main()(--config/--state 均指临时文件), app-server 一律用 FakeServer(不拉真实 codex 进程), git 操作走临时仓库;daemon 前台循环用信号停、interrupt 慢路径实测 10 秒等待;绝不跑模型。 """ import io import json import os import signal import subprocess import sys import tempfile import threading import time import unittest from contextlib import redirect_stdout from unittest import mock import host class FakeServer: def __init__(self, **kwargs): self.calls = [] self.results = { "thread/list": {"data": [{"id": "th-1"}]}, "thread/read": {"thread": {"id": "th-1"}}, "thread/name/set": {}, "thread/archive": {}, "thread/unarchive": {"thread": {"id": "th-1"}}, "thread/compact/start": {}, "skills/list": {}, "app/list": {}, "mcpServerStatus/list": {}, "config/mcpServer/reload": {}, "mcpServer/oauth/login": {}, } self.error_methods = set() self.raise_systemexit = set() self.raise_runtime = set() self.interrupt_result = {"result": {}} self.trace_path = None def initialize(self): self.calls.append(("initialize",)) return {} def close(self): self.calls.append(("close",)) def set_trace_file(self, state_path, task_name): self.trace_path = os.path.join( os.path.dirname(state_path), "host-traces", task_name + ".jsonl") def request(self, method, params=None, timeout=120): self.calls.append(("request", method, params)) if method in self.raise_systemexit: raise SystemExit(3) if method in self.raise_runtime: raise RuntimeError("boom-" + method) if method in self.error_methods: return {"error": {"message": "boom"}} return {"result": self.results.get(method, {})} def resume_thread(self, thread_id): self.calls.append(("resume", thread_id)) return {"thread": {"id": thread_id}} def archive_thread(self, thread_id): self.calls.append(("archive", thread_id)) if "thread/archive" in self.raise_systemexit: raise SystemExit(3) if "thread/archive" in self.raise_runtime: raise RuntimeError("boom-thread/archive") return {"result": {}} def list_threads(self): return [{"id": "th-1"}] def create_thread(self, *args, **kwargs): self.calls.append(("create_thread", args, kwargs)) return "th-new" def fork_thread(self, thread_id, **kwargs): self.calls.append(("fork", thread_id, kwargs)) return "th-fork" def interrupt_turn(self, thread_id, turn_id): self.calls.append(("interrupt", thread_id, turn_id)) return self.interrupt_result def start_review(self, thread_id, target): self.calls.append(("review", thread_id, target)) return {"review": "ok"} class Main3Base(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.config_path = os.path.join(self.tmp.name, "cfg.json") self.state_path = os.path.join(self.tmp.name, "host-state.json") with open(self.config_path, "w") as f: json.dump({}, f) self._old_argv = sys.argv def tearDown(self): sys.argv = self._old_argv def _save_state(self, state): host.save_state(self.state_path, state) def _load_state(self): with open(self.state_path) as f: return json.load(f) def _run(self, fake, *argv, **patches): sys.argv = ["host.py", "--config", self.config_path, "--state", self.state_path] + list(argv) out = io.StringIO() code = 0 ctxs = [mock.patch.object(host, "Server", lambda **kw: fake)] for name, val in patches.items(): ctxs.append(mock.patch.object(host, name, val)) with ctxs[0]: if len(ctxs) > 1: with ctxs[1]: if len(ctxs) > 2: with ctxs[2]: code, out = self._invoke(out) else: code, out = self._invoke(out) else: code, out = self._invoke(out) return code, out.getvalue() def _invoke(self, out): code = 0 with redirect_stdout(out): try: host.main() except SystemExit as exc: code = exc.code if isinstance(exc.code, int) else 1 except RuntimeError: code = "runtime-error" return code, out def _git(self, *args, cwd=None): subprocess.run(["git"] + list(args), cwd=cwd, check=True, capture_output=True) def _make_repo(self): repo = os.path.join(self.tmp.name, "repo") os.makedirs(repo) self._git("init", "-q", cwd=repo) self._git("config", "user.email", "t@t.test", cwd=repo) self._git("config", "user.name", "t", cwd=repo) with open(os.path.join(repo, "f.txt"), "w") as f: f.write("hello\n") self._git("add", ".", cwd=repo) self._git("commit", "-qm", "init", cwd=repo) return repo class EarlyBranchesTest(Main3Base): def test_dispatch_without_repo_raises(self): sys.argv = ["host.py", "--config", self.config_path, "--state", self.state_path, "dispatch", "--name", "x", "--task", "y"] with self.assertRaises(RuntimeError) as ctx: host.main() self.assertIn("缺少 --repo", str(ctx.exception)) def test_install_cron_new_and_existing(self): calls = [] def fake_run(cmd, **kw): calls.append(cmd) if cmd[:2] == ["crontab", "-l"]: return mock.Mock(stdout="") return mock.Mock(stdout="", returncode=0) with mock.patch.object(host.subprocess, "run", fake_run): code, out = self._run(FakeServer(), "automations", "--install-cron") self.assertEqual(code, 0) self.assertIn("已安装 crontab", out) self.assertIn("codex-host-run-due", out) def fake_run2(cmd, **kw): if cmd[:2] == ["crontab", "-l"]: return mock.Mock(stdout="*/30 * * * * x " "codex-host-run-due\n") raise AssertionError("不应再写 crontab") with mock.patch.object(host.subprocess, "run", fake_run2): code, out = self._run(FakeServer(), "automations", "--install-cron") self.assertIn("已有 codex-host-run-due", out) def test_github_workflow_success(self): repo = self._make_repo() with open(self.config_path, "w") as f: json.dump({"repo": repo}, f) code, out = self._run(FakeServer(), "github-workflow") self.assertEqual(code, 0) doc = json.loads(out) self.assertTrue(doc["changed"]) self.assertTrue(os.path.exists(doc["path"])) code, out = self._run(FakeServer(), "github-workflow") self.assertFalse(json.loads(out)["changed"]) def test_pr_variants(self): self._save_state({"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed", "repo": "/tmp"}}, "automations": []}) code, out = self._run( FakeServer(), "pr", "--task", "t1", create_pr=lambda repo, t: {"url": "https://pr/1"}) self.assertEqual(code, 0) self.assertIn("https://pr/1", out) code, out = self._run( FakeServer(), "pr-checks", "--task", "t1", get_pr_checks=lambda t, repo=None: {"checks": []}) self.assertEqual(code, 0) self.assertIn("checks", out) code, out = self._run( FakeServer(), "pr-merge", "--task", "t1", merge_pr=lambda t, repo=None: (True, "merged ok")) self.assertEqual(code, 0) self.assertIn("merged", out) code, out = self._run( FakeServer(), "pr-merge", "--task", "t1", merge_pr=lambda t, repo=None: (False, "checks pending")) self.assertEqual(code, 1) self.assertIn("checks pending", out) def test_sleep_positive_and_keyboard_interrupt(self): code, out = self._run(FakeServer(), "sleep", "--seconds", "0.5") self.assertEqual(code, 0) self.assertAlmostEqual(json.loads(out)["slept_seconds"], 0.5, places=1) with mock.patch.object(host.time, "sleep", side_effect=KeyboardInterrupt): code, out = self._run(FakeServer(), "sleep", "--seconds", "5") self.assertEqual(code, 130) self.assertTrue(json.loads(out)["interrupted"]) def test_health_not_ok_exits_1(self): code, out = self._run(FakeServer(), "--codex-bin", "/nonexistent-bin", "health") self.assertEqual(code, 1) self.assertFalse(json.loads(out)["ok"]) def test_restore_state_failure_exits_1(self): with open(self.state_path, "w") as f: f.write("{broken json") code, out = self._run(FakeServer(), "restore-state") self.assertEqual(code, 1) def test_recover_corrupt_and_recovered(self): with open(self.state_path, "w") as f: f.write("{broken json") code, out = self._run(FakeServer(), "recover") self.assertEqual(code, 1) self.assertIn("state 已损坏", out) # save_state 拒绝覆盖损坏文件:先删掉再写新 state。 os.remove(self.state_path) self._save_state({"tasks": {"old": {"name": "old", "status": "running", "created_at": 1000}}, "automations": []}) code, out = self._run(FakeServer(), "recover", "--older-than", "0") self.assertEqual(code, 0) self.assertEqual(json.loads(out), {"recovered": ["old"]}) self.assertEqual( self._load_state()["tasks"]["old"]["status"], "interrupted") class DaemonBranchesTest(Main3Base): def _pidfile(self): return os.path.join(self.tmp.name, "host-daemon.pid") def _logfile(self): return os.path.join(self.tmp.name, "host-daemon.log") def test_daemon_status_alive_with_log_tail(self): with open(self._pidfile(), "w") as f: f.write(str(os.getpid())) with open(self._logfile(), "w") as f: f.write("\n".join("line-%d" % i for i in range(7))) code, out = self._run(FakeServer(), "daemon-status") self.assertEqual(code, 0) doc = json.loads(out) self.assertTrue(doc["alive"]) self.assertEqual(doc["pid"], os.getpid()) self.assertIn("line-6", doc["log_tail"]) self.assertNotIn("line-0", doc["log_tail"]) def test_daemon_stop_live_process(self): proc = subprocess.Popen(["sleep", "30"]) try: with open(self._pidfile(), "w") as f: f.write(str(proc.pid)) code, out = self._run(FakeServer(), "daemon-stop") self.assertEqual(code, 0) self.assertIn("daemon 已停", out) self.assertFalse(os.path.exists(self._pidfile())) self.assertIsNotNone(proc.poll()) finally: if proc.poll() is None: proc.kill() proc.wait() def test_daemon_already_running(self): with open(self._pidfile(), "w") as f: f.write(str(os.getpid())) code, out = self._run(FakeServer(), "daemon") self.assertEqual(code, 0) self.assertIn("daemon 已在运行", out) def test_daemon_foreground_loop_stops_on_sigterm(self): self._save_state({"tasks": {}, "automations": []}) # 注意:daemon 循环的子进程只带 --config 不带 --state, # 会写 host.py 所在目录的默认 host-state.json(含 .bak # 轮转)。测试前快照、跑后恢复/删除,不污染 worktree。 here = os.path.dirname(os.path.abspath(host.__file__)) leaked = [os.path.join(here, n) for n in ["host-state.json", "host-state.json.bak", "host-state.json.bak.1", "host-state.json.bak.2", "host-state.json.bak.3"]] snapshot = {} for path in leaked: if os.path.exists(path): with open(path, "rb") as f: snapshot[path] = f.read() old_term = signal.getsignal(signal.SIGTERM) old_int = signal.getsignal(signal.SIGINT) try: def _killer(): time.sleep(0.8) os.kill(os.getpid(), signal.SIGTERM) th = threading.Thread(target=_killer, daemon=True) th.start() code, out = self._run(FakeServer(), "daemon", "--foreground", "--interval", "0") self.assertEqual(code, 0) self.assertIn("host daemon stopped", out) self.assertIn("daemon tick", out) finally: signal.signal(signal.SIGTERM, old_term) signal.signal(signal.SIGINT, old_int) for path in leaked: if path in snapshot: with open(path, "wb") as f: f.write(snapshot[path]) elif os.path.exists(path): os.remove(path) class DispatchReviewMergeTest(Main3Base): def test_dispatch_budget_gate(self): today = time.strftime("%Y-%m-%d", time.localtime()) with open(self.config_path, "w") as f: json.dump({"max_daily_turns": 5}, f) self._save_state({"tasks": {}, "automations": [], "usage": {"date": today, "turns": 5}}) repo = self._make_repo() code, out = self._run(FakeServer(), "dispatch", "--name", "x", "--task", "y", "--repo", repo) self.assertEqual(code, 0) self.assertIn("预算已用完", out) def test_review_with_open_comments(self): repo = self._make_repo() with open(os.path.join(repo, "f.txt"), "a") as f: f.write("more\n") self._save_state({"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed", "cwd": repo, "review_comments": [ {"file": "a.py", "line": 3, "comment": "fix", "status": "open"}]}}, "automations": []}) code, out = self._run(FakeServer(), "review", "--task", "t1") self.assertEqual(code, 0) self.assertIn("open review comments", out) self.assertIn("a.py:3: fix", out) def test_merge_non_git_and_failed(self): self._save_state({"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed"}}, "automations": []}) code, out = self._run(FakeServer(), "merge", "--task", "t1") self.assertEqual(code, 0) self.assertIn("非 git worktree", out) self._save_state({"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed", "repo": "/tmp", "branch": "b", "cwd": "/tmp"}}, "automations": []}) code, out = self._run( FakeServer(), "merge", "--task", "t1", merge_task=lambda *a, **k: (False, "verify failed")) self.assertEqual(code, 0) self.assertIn("verify failed", out) self.assertEqual( self._load_state()["tasks"]["t1"]["status"], "merge-failed") def test_fix_review_completed_and_failed(self): base = {"name": "t1", "thread_id": "th-1", "status": "completed", "cwd": "/tmp", "review_comments": [{"file": "a.py", "line": 1, "comment": "fix", "status": "open"}]} self._save_state({"tasks": {"t1": dict(base)}, "automations": []}) code, out = self._run( FakeServer(), "fix-review", "--task", "t1", run_task_turn=lambda *a, **k: {"status": "completed"}) self.assertEqual(code, 0) self.assertIn("fix-review completed", out) state = self._load_state() self.assertEqual( state["tasks"]["t1"]["review_comments"][0]["status"], "resolved") self._save_state({"tasks": {"t1": dict(base)}, "automations": []}) code, out = self._run( FakeServer(), "fix-review", "--task", "t1", run_task_turn=lambda *a, **k: {"status": "failed", "text": "bad"}) self.assertEqual(code, 0) self.assertIn("fix-review failed", out) def test_discard_with_repo_branch(self): repo = self._make_repo() wt = os.path.join(self.tmp.name, "wt1") self._git("worktree", "add", wt, "-b", "host/t1", cwd=repo) self._save_state({"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed", "repo": repo, "branch": "host/t1", "cwd": wt}}, "automations": []}) code, out = self._run(FakeServer(), "discard", "--task", "t1") self.assertEqual(code, 0) self.assertIn("discarded t1", out) self.assertFalse(os.path.exists(wt)) class ThreadErrorBranchesTest(Main3Base): COMMANDS = [ (["thread-list"], "thread/list"), (["thread-read", "--thread-id", "th-1"], "thread/read"), (["thread-rename", "--thread-id", "th-1", "--name", "n"], "thread/name/set"), (["thread-archive", "--thread-id", "th-1"], "thread/archive"), (["thread-unarchive", "--thread-id", "th-1"], "thread/unarchive"), (["thread-compact", "--thread-id", "th-1"], "thread/compact/start"), (["skills-list"], "skills/list"), (["apps-list"], "app/list"), (["mcp-servers-list"], "mcpServerStatus/list"), (["mcp-servers-reload"], "config/mcpServer/reload"), (["mcp-server-oauth-login", "--server-name", "s1"], "mcpServer/oauth/login"), ] def test_thread_commands_systemexit_reraise(self): for argv, method in self.COMMANDS: with self.subTest(argv=argv): fake = FakeServer() fake.raise_systemexit = {method} code, out = self._run(fake, *argv) self.assertEqual(code, 3) def test_thread_commands_runtime_error_json(self): for argv, method in self.COMMANDS: with self.subTest(argv=argv): fake = FakeServer() fake.raise_runtime = {method} code, out = self._run(fake, *argv) self.assertEqual(code, 1) self.assertIn("boom-" + method, out) class CodeReviewInterruptDelegateGoalTest(Main3Base): def _task(self, **kw): d = {"name": "t1", "thread_id": "th-1", "status": "completed", "cwd": "/tmp", "verify_command": ""} d.update(kw) return d def test_code_review_targets(self): self._save_state({"tasks": {"t1": self._task()}, "automations": []}) fake = FakeServer() code, out = self._run(fake, "code-review", "--task", "t1", "--commit", "abc123") self.assertEqual(code, 0) self.assertEqual(fake.calls[-2][2]["type"], "commit") fake = FakeServer() code, out = self._run(fake, "code-review", "--task", "t1", "--base-branch", "main") self.assertEqual(code, 0) self.assertEqual(fake.calls[-2][2]["type"], "baseBranch") fake = FakeServer() code, out = self._run(fake, "code-review", "--task", "t1") self.assertEqual(code, 0) self.assertEqual(fake.calls[-2][2]["type"], "uncommittedChanges") def test_code_review_failure(self): self._save_state({"tasks": {"t1": self._task()}, "automations": []}) fake = FakeServer() def _boom(thread_id, target): raise RuntimeError("review 炸了") fake.start_review = _boom code, out = self._run(fake, "code-review", "--task", "t1") self.assertEqual(code, 1) self.assertIn("code-review failed", out) def test_interrupt_slow_path_waits_ten_seconds(self): self._save_state({"tasks": {"it": { "name": "it", "thread_id": "th-1", "status": "running", "turns": [{"result": {"turn_id": "turn-1", "status": "completed"}}]}}, "automations": []}) fake = FakeServer() fake.interrupt_result = {"error": {"message": "no active turn"}} started = time.time() code, out = self._run(fake, "interrupt", "--task", "it") elapsed = time.time() - started self.assertEqual(code, 0) self.assertGreaterEqual(elapsed, 9.5) self.assertIn("属主 10 秒内未响应", out) def test_interrupt_write_failure(self): self._save_state({"tasks": {"it": { "name": "it", "thread_id": "th-1", "status": "running", "turns": [{"result": {"turn_id": "turn-1", "status": "completed"}}]}}, "automations": []}) req = host._interrupt_request_path(self.state_path, "it") os.makedirs(req, exist_ok=True) fake = FakeServer() fake.interrupt_result = {"error": {"message": "no active turn"}} code, out = self._run(fake, "interrupt", "--task", "it") self.assertEqual(code, 0) self.assertIn("写中断请求失败", out) def test_delegate_report_to_parent(self): self._save_state({"tasks": {"pt": { "name": "pt", "thread_id": "th-1", "status": "running", "cwd": "/tmp"}}, "automations": []}) fake = FakeServer() code, out = self._run( fake, "delegate", "--parent-task", "pt", "--task", "sub", "--name", "sub1", "--report-to-parent", run_task_turn=lambda *a, **k: {"status": "completed", "text": "sub done"}) self.assertEqual(code, 0) self.assertIn("reported to parent", out) state = self._load_state() self.assertEqual(state["tasks"]["sub1"]["status"], "running") def test_goal_branch_wiring(self): repo = self._make_repo() code, out = self._run( FakeServer(), "goal", "--name", "g1", "--task", "达成目标", "--repo", repo, run_goal_loop=lambda *a, **k: None) self.assertEqual(code, 0) state = self._load_state() self.assertIn("g1", state["tasks"]) # 真实 dispatch 在 HERE/work 下建了 worktree + 分支, # 本测试负责清掉,不留脏 worktree。 wt = state["tasks"]["g1"]["cwd"] branch = state["tasks"]["g1"]["branch"] subprocess.run(["git", "-C", repo, "worktree", "remove", "--force", wt], capture_output=True) subprocess.run(["git", "-C", repo, "branch", "-D", branch], capture_output=True) def test_goal_resume_guards_and_rounds(self): # 已终局拒绝。 self._save_state({"tasks": {"t1": self._task( status="completed", goal="做周报")}, "automations": []}) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1") self.assertIn("已终局", out) # goal-running 但无 goal 记录拒绝。 self._save_state({"tasks": {"t1": self._task( status="goal-running")}, "automations": []}) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1") self.assertIn("没有 goal 记录", out) # 轮次来源三路:CLI 覆盖 / 任务原值 / 缺省 3。 seen = {} def _loop(srv, state, state_path, task, goal, verify, max_rounds, *a, **k): seen["max_rounds"] = max_rounds seen["start_round"] = k.get("start_round") self._save_state({"tasks": {"t1": self._task( status="goal-running", goal="做周报", goal_max_rounds=7, goal_rounds_done=2)}, "automations": []}) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1", run_goal_loop=_loop) self.assertEqual(seen["max_rounds"], 7) self.assertEqual(seen["start_round"], 3) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1", "--max-rounds", "2", run_goal_loop=_loop) self.assertEqual(seen["max_rounds"], 2) self._save_state({"tasks": {"t1": self._task( status="goal-running", goal="做周报")}, "automations": []}) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1", run_goal_loop=_loop) self.assertEqual(seen["max_rounds"], 3) # 旧验收形态 0 轮次钳制到 100。 self._save_state({"tasks": {"t1": self._task( status="goal-running", goal="做周报", verify_command="make test", goal_max_rounds=0)}, "automations": []}) code, out = self._run(FakeServer(), "goal-resume", "--task", "t1", run_goal_loop=_loop) self.assertEqual(seen["max_rounds"], 100) class ParallelAutomationsTest(Main3Base): def test_parallel_simple_and_blocked(self): jobs_path = os.path.join(self.tmp.name, "jobs.json") with open(jobs_path, "w") as f: json.dump([ {"name": "a", "task": "做 A"}, {"name": "b", "task": "做 B", "depends_on": ["a"]}, ], f) self._save_state({"tasks": {}, "automations": []}) def _run_turn(srv, state, task, prompt, model, effort, sandbox, **kw): if task["name"] == "a": task["status"] = "failed" return {"status": "failed", "text": "a bad"} task["status"] = "completed" return {"status": "completed", "text": "ok"} code, out = self._run( FakeServer(), "parallel", "--jobs", jobs_path, run_task_turn_with_retry=_run_turn) self.assertEqual(code, 0) self.assertIn("skipped (依赖失败)", out) state = self._load_state() self.assertEqual(state["tasks"]["b"]["status"], "skipped") def test_parallel_unpushable_remaining(self): # 依赖既未完成也未失败/跳过(如 interrupted):既不进 # blocked 也不进 ready,循环以“无法推进”收尾。 jobs_path = os.path.join(self.tmp.name, "jobs.json") with open(jobs_path, "w") as f: json.dump([ {"name": "a", "task": "做 A"}, {"name": "b", "task": "做 B", "depends_on": ["a"]}, ], f) self._save_state({"tasks": {}, "automations": []}) def _run_turn(srv, state, task, prompt, model, effort, sandbox, **kw): task["status"] = "interrupted" return {"status": "interrupted", "text": "cut"} code, out = self._run( FakeServer(), "parallel", "--jobs", jobs_path, run_task_turn_with_retry=_run_turn) self.assertEqual(code, 0) self.assertIn("无法推进", out) state = self._load_state() self.assertNotIn("b", state["tasks"]) def test_parallel_with_repo_dependency(self): repo = self._make_repo() jobs_path = os.path.join(self.tmp.name, "jobs.json") with open(jobs_path, "w") as f: json.dump([ {"name": "a", "task": "做 A", "repo": repo}, {"name": "b", "task": "做 B", "repo": repo, "depends_on": ["a"]}, ], f) self._save_state({"tasks": {}, "automations": []}) def _run_turn(srv, state, task, prompt, model, effort, sandbox, **kw): task["status"] = "completed" return {"status": "completed", "text": "ok"} code, out = self._run( FakeServer(), "parallel", "--jobs", jobs_path, run_task_turn_with_retry=_run_turn) self.assertEqual(code, 0) state = self._load_state() self.assertEqual(state["tasks"]["a"]["status"], "completed") self.assertEqual(state["tasks"]["b"]["status"], "completed") # 依赖分支派发后 b 的 worktree 与 a 的分支有血缘: # b 的 branch 应存在且不同于 a。 self.assertNotEqual(state["tasks"]["a"]["branch"], state["tasks"]["b"]["branch"]) # 清理 parallel 建的 worktree(HERE/work 下)。 for name in ("a", "b"): wt = state["tasks"][name]["cwd"] subprocess.run(["git", "-C", repo, "worktree", "remove", "--force", wt], capture_output=True) subprocess.run(["git", "-C", repo, "branch", "-D", state["tasks"][name]["branch"]], capture_output=True) def test_automations_add_list_and_run_due(self): self._save_state({"tasks": {}, "automations": []}) code, out = self._run(FakeServer(), "automations", "--add", "--name", "daily", "--task", "巡检") self.assertEqual(code, 0) self.assertIn("automation added", out) code, out = self._run(FakeServer(), "automations") self.assertIn("daily", out) # run-due:一条未到期跳过、一条到期跑(dispatch/turn 替身)。 state = self._load_state() state["automations"] = [ {"name": "soon", "task": "x", "repo": None, "interval": 86400, "last_run": time.time()}, {"name": "due", "task": "巡检", "repo": None, "interval": 60, "last_run": 0}, ] self._save_state(state) def _dispatch(srv, state, name, prompt, repo, model, effort, sandbox, approval_policy, **kw): task = {"name": name, "thread_id": "th-1", "cwd": "/tmp", "status": "running", "prompt": prompt} state["tasks"][name] = task return task code, out = self._run( FakeServer(), "run-due", dispatch=_dispatch, run_task_turn=lambda *a, **k: {"status": "failed", "text": "bad"}) self.assertEqual(code, 0) self.assertIn("automation due ->", out) state = self._load_state() self.assertGreater(state["automations"][1]["last_run"], 0) if __name__ == "__main__": unittest.main()