"""覆盖率 78%->85% 第 2 步:host.py 剩余缺口的真实行为测试。 分三类,全部用临时目录/临时 git 仓库,不跑真实模型: - 纯函数:merge_task(成功 / 验测回滚 / -X ours 自动解成功与丢改动 回滚)、run_health_check、notify_failure; - stub Server:dispatch / dispatch_from_branch / run_task_turn / run_task_turn_with_retry / run_goal_loop(goal-met / unmet / 预算中断 / 回合异常); - main() 进程内调用(--config/--state 均指临时文件,--codex-bin 指假 app-server 脚本,走真实 Server 子进程协议); daemonize 另用子进程脚本验证(双 fork 后写 marker)。 dispatch 建的 worktree 落在 HERE/work/host-worktrees(已 gitignored), tearDown 里逐一清理。 """ import io import json import os import subprocess import sys import tempfile import time import unittest from contextlib import redirect_stdout from pathlib import Path import host from host import ( dispatch, dispatch_from_branch, merge_task, notify_failure, run_goal_loop, run_health_check, run_task_turn, run_task_turn_with_retry, ) REPO_ROOT = Path(__file__).resolve().parents[1] HERE = os.path.dirname(os.path.abspath(host.__file__)) FAKE_SCRIPT = r'''#!/usr/bin/env python3 """假 app-server:只讲 JSON-RPC 行协议,不跑模型。""" import json import sys def send(obj): sys.stdout.write(json.dumps(obj) + "\n") sys.stdout.flush() def result_for(method): if method == "thread/start": return {"thread": {"id": "th-1"}} if method == "thread/list": return {"threads": [{"id": "th-1"}]} if method == "thread/resume": return {"resumed": True} if method == "review/start": return {"review": "ok"} if method == "thread/fork": return {"thread": {"id": "th-2"}} if method == "thread/archive": return {} if method == "turn/interrupt": return {"interrupted": True} if method == "initialize": return {"server": "fake"} if method == "turn/start": return {"turn": {"id": "turn-1"}} return {} def main(): for line in sys.stdin: line = line.strip() if not line: continue try: msg = json.loads(line) except ValueError: continue if "method" not in msg: continue send({"jsonrpc": "2.0", "id": msg.get("id"), "result": result_for(msg["method"])}) main() ''' USAGE = {"input_tokens": 3, "cached_input_tokens": 0, "cache_write_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": 0, "total_tokens": 4, "completions": 1} class StubServer: def __init__(self, results=None): self.results = list(results) if results is not None else [ {"status": "completed", "text": "done\n", "usage": dict(USAGE)}] self.created = [] def create_thread(self, cwd, model=None, sandbox=None, approval_policy=None): self.created.append(cwd) return "th-1" def run_turn(self, thread_id, prompt, model=None, effort=None, sandbox=None, timeout=None, interrupt_file=None): if not self.results: return {"status": "completed", "text": "done\n", "usage": dict(USAGE)} result = self.results.pop(0) if isinstance(result, Exception): raise result return result def _git(repo, *args, check=True): return subprocess.run(["git", "-C", repo] + list(args), check=check, capture_output=True, text=True) def _make_repo(path, content="l1\nl2\nl3\nl4\nl5\n"): subprocess.run(["git", "init", "-q", path], check=True, capture_output=True) _git(path, "config", "user.email", "t@t.local") _git(path, "config", "user.name", "T") with open(os.path.join(path, "f.txt"), "w") as f: f.write(content) _git(path, "add", ".") _git(path, "commit", "-qm", "base") return path class MergeTaskTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.repo = _make_repo(os.path.join(self.tmp.name, "repo")) def _worktree_for_branch(self, branch): wt = os.path.join(self.tmp.name, "wt-" + branch.replace("/", "_")) _git(self.repo, "worktree", "add", wt, "-b", branch, "HEAD") return wt def _head(self): return _git(self.repo, "rev-parse", "HEAD").stdout.strip() def test_merge_success_and_verify_rollback(self): wt = self._worktree_for_branch("host/t1") with open(os.path.join(wt, "f.txt"), "a") as f: f.write("task-line\n") ok, msg = merge_task(self.repo, wt, "host/t1", "t1", verify_command="true") self.assertTrue(ok, msg) with open(os.path.join(self.repo, "f.txt")) as f: self.assertIn("task-line", f.read()) self.assertEqual(_git(self.repo, "branch", "--list", "host/t1", check=False).stdout.strip(), "") # 验测失败:回滚到合并前 HEAD,分支与 worktree 保留。 wt2 = self._worktree_for_branch("host/t2") with open(os.path.join(wt2, "f.txt"), "a") as f: f.write("bad-line\n") pre = self._head() ok, msg = merge_task(self.repo, wt2, "host/t2", "t2", verify_command="false") self.assertFalse(ok) self.assertIn("验测失败", msg) self.assertEqual(self._head(), pre) self.assertIn("host/t2", _git(self.repo, "branch", "--list").stdout) _git(self.repo, "worktree", "remove", "--force", wt2) def test_merge_conflict_auto_resolve_keeps_branch_lines(self): # gap2 同款:主仓与分支在同一位置插入了不同第二行(冲突), # 分支的第二行是文件别处已有的行(L09),远处独有改动是 # L11-task。-X ours 解后分支新增行全在主仓里 -> 成功。 # 注意 base 必须与两侧共享绝大多数行,否则整文件算一个 # 冲突块,-X ours 会把远处改动也一起丢掉。 lfile = "".join("L%02d\n" % i for i in range(1, 13)) repo2 = _make_repo(os.path.join(self.tmp.name, "repo2"), content=lfile) with open(os.path.join(repo2, "f.txt"), "w") as f: f.write("L01\nL02\nSH\nM1\nL03\nL04\nL05\nL06\nL07\nL08\n" "L09\nL10\nL11\nL12\n") _git(repo2, "add", ".") _git(repo2, "commit", "-qm", "main advance") wt = os.path.join(self.tmp.name, "wt-t3") _git(repo2, "worktree", "add", wt, "-b", "host/t3", "HEAD~1") with open(os.path.join(wt, "f.txt"), "w") as f: f.write("L01\nL02\nSH\nL09\nL03\nL04\nL05\nL06\nL07\nL08\n" "L09\nL10\nL11-task\nL12\n") out = io.StringIO() with redirect_stdout(out): ok, msg = merge_task(repo2, wt, "host/t3", "t3", verify_command="true") self.assertTrue(ok, msg) self.assertIn("-X ours", out.getvalue()) with open(os.path.join(repo2, "f.txt")) as f: content = f.read() self.assertIn("L11-task", content) self.assertIn("SH", content) def test_merge_conflict_loses_branch_line_rolls_back(self): # 同一行互改、无其它改动:-X ours 解后分支行丢失 -> 回滚。 with open(os.path.join(self.repo, "f.txt"), "w") as f: f.write("l1\nl2\nl3-main\nl4\nl5\n") _git(self.repo, "add", ".") _git(self.repo, "commit", "-qm", "main advance") wt = os.path.join(self.tmp.name, "wt-t4") _git(self.repo, "worktree", "add", wt, "-b", "host/t4", "HEAD~1") with open(os.path.join(wt, "f.txt"), "w") as f: f.write("l1\nl2\nl3-task\nl4\nl5\n") pre = self._head() ok, msg = merge_task(self.repo, wt, "host/t4", "t4", verify_command="true") self.assertFalse(ok) self.assertIn("改动被丢弃", msg) self.assertEqual(self._head(), pre) class HealthNotifyTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.state_path = os.path.join(self.tmp.name, "host-state.json") host.save_state(self.state_path, {"tasks": {}, "automations": []}) def test_health_check_variants(self): class FakeSrv: def __init__(self, ok=True): self.ok = ok def initialize(self): if not self.ok: raise RuntimeError("init boom") def list_threads(self): return {"data": [{"id": "th-1"}, {"id": "th-2"}]} def close(self): pass bin_path = os.path.join(self.tmp.name, "codex-bin") with open(bin_path, "w") as f: f.write("#!/bin/sh\n") report = run_health_check(self.state_path, {}, bin_path, None, "deny", server_factory=lambda: FakeSrv(True)) self.assertTrue(report["ok"]) self.assertTrue(report["checks"]["codex_bin_exists"]) self.assertTrue(report["checks"]["state_valid"]) self.assertTrue(report["checks"]["server_ok"]) self.assertEqual(report["checks"]["server_threads"], 2) self.assertFalse(report["checks"]["backup_exists"]) with open(self.state_path + ".bak", "w") as f: f.write("{}") report = run_health_check(self.state_path, {}, bin_path, None, "deny", server_factory=lambda: FakeSrv(True)) self.assertTrue(report["checks"]["backup_exists"]) report = run_health_check(self.state_path, {}, "/nonexistent-bin", None, "deny", server_factory=lambda: FakeSrv(False)) self.assertFalse(report["ok"]) self.assertFalse(report["checks"]["codex_bin_exists"]) self.assertFalse(report["checks"]["server_ok"]) self.assertIn("init boom", report["checks"]["server_error"]) # 损坏的 state:state_valid 为 False,failed 列表为空。 with open(self.state_path, "w") as f: f.write("{broken") report = run_health_check(self.state_path, {}, bin_path, None, "deny", server_factory=lambda: FakeSrv(True)) self.assertFalse(report["checks"]["state_valid"]) self.assertEqual(report["checks"]["failed_tasks"], []) # 损坏的 pidfile:daemon_alive 为 False(先删掉坏 state 文件, # save_state 按设计拒绝在坏文件上重写)。 pidfile = os.path.join(self.tmp.name, "host-daemon.pid") with open(pidfile, "w") as f: f.write("not-a-pid") os.unlink(self.state_path) host.save_state(self.state_path, {"tasks": {}, "automations": []}) report = run_health_check(self.state_path, {}, bin_path, None, "deny", server_factory=lambda: FakeSrv(True)) self.assertFalse(report["checks"]["daemon_alive"]) self.assertIsNone(report["checks"]["daemon_pid"]) def test_notify_failure_variants(self): msg = notify_failure(self.state_path, {}, "t1", {"status": "failed", "text": "boom"}) self.assertTrue(msg.startswith("FAILED t1:")) log_path = os.path.join(self.tmp.name, "host-run.log") with open(log_path) as f: self.assertIn("FAILED t1:", f.read()) marker = os.path.join(self.tmp.name, "notified.txt") msg = notify_failure( self.state_path, {"notify_command": "echo {task} >> " + marker}, "t2", {"status": "failed", "text": "x"}) with open(marker) as f: self.assertIn("t2", f.read()) # 坏模板:不跑命令、记 template error。 marker2 = os.path.join(self.tmp.name, "notified2.txt") notify_failure( self.state_path, {"notify_command": "echo hi >> " + marker2 + " {unknown}"}, "t3", {"status": "failed", "text": "x"}) self.assertFalse(os.path.exists(marker2)) with open(log_path) as f: self.assertIn("template error", f.read()) # 失败命令:记 rc;注入文本不执行。 notify_failure(self.state_path, {"notify_command": "false"}, "t4", {"status": "failed", "text": "x"}) with open(log_path) as f: self.assertIn("notify command rc=1", f.read()) pwn = os.path.join(self.tmp.name, "pwned") notify_failure( self.state_path, {"notify_command": "echo {text} >> " + marker}, "t5", {"status": "failed", "text": "$(touch %s)" % pwn}) self.assertFalse(os.path.exists(pwn)) class DaemonizeTest(unittest.TestCase): def test_daemonize_double_fork_writes_pidfile_and_marker(self): script = os.path.join(tempfile.gettempdir(), "cov85_daemon_probe_%d.py" % os.getpid()) pidfile = os.path.join(tempfile.gettempdir(), "cov85_daemon_%d.pid" % os.getpid()) logfile = os.path.join(tempfile.gettempdir(), "cov85_daemon_%d.log" % os.getpid()) marker = os.path.join(tempfile.gettempdir(), "cov85_daemon_%d.marker" % os.getpid()) for p in (pidfile, logfile, marker): if os.path.exists(p): os.unlink(p) code = ( "import os, sys\n" "sys.path.insert(0, %r)\n" "import host\n" "cont = host.daemonize(%r, %r)\n" "if not cont:\n" " sys.exit(42)\n" "open(%r, 'w').write(str(os.getpid()))\n" % (str(REPO_ROOT), pidfile, logfile, marker)) with open(script, "w") as f: f.write(code) try: proc = subprocess.run([sys.executable, script], capture_output=True, text=True, timeout=30) self.assertEqual(proc.returncode, 42) deadline = time.time() + 5 while not os.path.exists(marker) and time.time() < deadline: time.sleep(0.05) self.assertTrue(os.path.exists(marker)) with open(pidfile) as f: pid_text = f.read().strip() with open(marker) as f: self.assertEqual(f.read().strip(), pid_text) self.assertTrue(os.path.exists(logfile)) finally: for p in (script, pidfile, logfile, marker): if os.path.exists(p): os.unlink(p) class DispatchGoalTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.repo = _make_repo(os.path.join(self.tmp.name, "repo")) self.state_path = os.path.join(self.tmp.name, "host-state.json") self.state = {"tasks": {}, "automations": []} self.created_names = [] def tearDown(self): for name in self.created_names: wt = os.path.join(HERE, "work", "host-worktrees", name) if os.path.exists(wt): subprocess.run(["git", "-C", self.repo, "worktree", "remove", "--force", wt], capture_output=True) subprocess.run(["git", "-C", self.repo, "branch", "-D", "host/" + name], capture_output=True) self.tmp.cleanup() def test_dispatch_and_existing_and_seed(self): srv = StubServer() task = dispatch(srv, self.state, "t1", "do thing", self.repo, None, None, None, "never") self.created_names.append("t1") self.assertEqual(task["thread_id"], "th-1") self.assertEqual(task["status"], "running") self.assertTrue(os.path.isdir(task["cwd"])) self.assertIs(self.state["tasks"]["t1"], task) with self.assertRaises(RuntimeError): dispatch(srv, self.state, "t1", "again", self.repo, None, None, None, "never") seed = os.path.join(self.tmp.name, "seed") os.makedirs(os.path.join(seed, "sub")) with open(os.path.join(seed, "a.txt"), "w") as f: f.write("seeded") with open(os.path.join(seed, "sub", "b.txt"), "w") as f: f.write("seeded-sub") task2 = dispatch(srv, self.state, "t2", "seeded task", self.repo, None, None, None, "never", seed_dir=seed) self.created_names.append("t2") with open(os.path.join(task2["cwd"], "a.txt")) as f: self.assertEqual(f.read(), "seeded") with open(os.path.join(task2["cwd"], "sub", "b.txt")) as f: self.assertEqual(f.read(), "seeded-sub") # seed-dir 没有 worktree 时 loud fail。 with self.assertRaises(RuntimeError): dispatch(srv, self.state, "t3", "x", self.repo, None, None, None, "never", use_worktree=False, seed_dir=seed) with self.assertRaises(RuntimeError): dispatch(srv, self.state, "t4", "x", None, None, None, None, "never", seed_dir=seed) def test_dispatch_from_branch(self): srv = StubServer() dep = dispatch(srv, self.state, "dep", "dep work", self.repo, None, None, None, "never") self.created_names.append("dep") with open(os.path.join(dep["cwd"], "dep.txt"), "w") as f: f.write("dep-change") task, err = dispatch_from_branch( srv, self.state, "child", "child work", self.repo, dep["branch"], dep["cwd"], None, None, None, "never") self.created_names.append("child") self.assertEqual(err, "") self.assertEqual(task["branch"], "host/child") with open(os.path.join(task["cwd"], "dep.txt")) as f: self.assertEqual(f.read(), "dep-change") task2, err2 = dispatch_from_branch( srv, self.state, "child", "again", self.repo, dep["branch"], dep["cwd"], None, None, None, "never") self.assertIsNone(task2) self.assertIn("已存在", err2) def test_run_task_turn_status_mapping_and_retry(self): state = {"tasks": {}, "automations": []} task = {"name": "r1", "thread_id": "th-1", "status": "running", "turns": []} srv = StubServer([{"status": "completed", "text": "ok", "usage": dict(USAGE)}]) result = run_task_turn(srv, state, task, "prompt", None, None) self.assertEqual(task["status"], "completed") self.assertEqual(result["status"], "completed") task2 = {"name": "r2", "thread_id": "th-1", "status": "running", "turns": []} srv2 = StubServer([{"status": "timeout", "text": "partial", "usage": dict(USAGE)}]) run_task_turn(srv2, state, task2, "prompt", None, None) self.assertEqual(task2["status"], "interrupted") task3 = {"name": "r3", "thread_id": "th-1", "status": "running", "turns": []} srv3 = StubServer([{"status": "failed", "text": "", "usage": dict(USAGE)}]) run_task_turn(srv3, state, task3, "prompt", None, None) self.assertEqual(task3["status"], "failed") # 重试:先失败再成功,usage 汇总且 retry_count 为 1。 task4 = {"name": "r4", "thread_id": "th-1", "status": "running", "turns": [], "max_retries": 1} srv4 = StubServer([{"status": "failed", "text": "", "usage": dict(USAGE)}, {"status": "completed", "text": "ok", "usage": dict(USAGE)}]) result = run_task_turn_with_retry(srv4, state, task4, "prompt", None, None) self.assertEqual(result["status"], "completed") self.assertEqual(task4["retry_count"], 1) self.assertEqual(result["usage"]["input_tokens"], 6) # 预算门:cfg 限 1 轮且已用 1 轮时直接失败,不跑 turn。 today = time.strftime("%Y-%m-%d", time.localtime()) state2 = {"tasks": {}, "automations": [], "usage": {"date": today, "turns": 1, "failed_turns": 0}} task5 = {"name": "r5", "thread_id": "th-1", "status": "running", "turns": [], "max_retries": 0} srv5 = StubServer() result = run_task_turn_with_retry( srv5, state2, task5, "prompt", None, None, cfg={"max_daily_turns": 1}) self.assertEqual(result["status"], "failed") self.assertIn("budget exceeded", result["error"]) self.assertEqual(task5["status"], "failed") def _goal_state(self, name): task = {"name": name, "thread_id": "th-1", "status": "running", "turns": [], "cwd": self.tmp.name} state = {"tasks": {name: task}, "automations": []} return state, task def test_goal_loop_met_by_verify_and_by_marker(self): state, task = self._goal_state("g1") out = io.StringIO() with redirect_stdout(out): res = run_goal_loop(StubServer(), state, self.state_path, task, "do it", "true", 3, None, None, None) self.assertTrue(res["ok"]) self.assertEqual(task["status"], "goal-met") self.assertEqual(res["rounds_done"], 1) self.assertIn("goal met after rounds", out.getvalue()) state, task = self._goal_state("g2") srv = StubServer([{"status": "completed", "text": "work done\n目标已达成\n", "usage": dict(USAGE)}]) res = run_goal_loop(srv, state, self.state_path, task, "do it", None, 3, None, None, None) self.assertTrue(res["ok"]) self.assertEqual(task["status"], "goal-met") def test_goal_loop_unmet_budget_and_exception(self): state, task = self._goal_state("g3") res = run_goal_loop(StubServer(), state, self.state_path, task, "do it", "false", 1, None, None, None) self.assertFalse(res["ok"]) self.assertEqual(task["status"], "goal-unmet") self.assertEqual(res["rounds_done"], 1) today = time.strftime("%Y-%m-%d", time.localtime()) state, task = self._goal_state("g4") state["usage"] = {"date": today, "turns": 5, "failed_turns": 0} res = run_goal_loop(StubServer(), state, self.state_path, task, "do it", "true", 3, None, None, None, cfg={"max_daily_turns": 3}) self.assertTrue(res["budget_stopped"]) self.assertEqual(task["status"], "interrupted") state, task = self._goal_state("g5") srv = StubServer([RuntimeError("turn boom")]) res = run_goal_loop(srv, state, self.state_path, task, "do it", "true", 3, None, None, None) self.assertTrue(res["interrupted"]) self.assertEqual(task["status"], "interrupted") self.assertIn("turn boom", task["goal_interrupted_reason"]) class MainCommandsTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.repo = _make_repo(os.path.join(self.tmp.name, "repo")) self.config_path = os.path.join(self.tmp.name, "host-config.json") with open(self.config_path, "w") as f: json.dump({}, f) self.state_path = os.path.join(self.tmp.name, "host-state.json") self.fake_bin = os.path.join(self.tmp.name, "fake-codex.py") with open(self.fake_bin, "w") as f: f.write(FAKE_SCRIPT) os.chmod(self.fake_bin, 0o755) self._old_argv = sys.argv self.created_names = [] def tearDown(self): sys.argv = self._old_argv for name in self.created_names: wt = os.path.join(HERE, "work", "host-worktrees", name) if os.path.exists(wt): subprocess.run(["git", "-C", self.repo, "worktree", "remove", "--force", wt], capture_output=True) subprocess.run(["git", "-C", self.repo, "branch", "-D", "host/" + name], capture_output=True) self.tmp.cleanup() def _main(self, *argv): sys.argv = ["host.py", "--config", self.config_path, "--state", self.state_path] + list(argv) out = io.StringIO() with redirect_stdout(out): host.main() return out.getvalue() def _main_code(self, *argv): sys.argv = ["host.py", "--config", self.config_path, "--state", self.state_path] + list(argv) out = io.StringIO() with redirect_stdout(out): try: host.main() except SystemExit as exc: return exc.code, out.getvalue() return 0, out.getvalue() def _load_state(self): with open(self.state_path) as f: return json.load(f) def test_init_config_doctor_failures(self): os.unlink(self.config_path) out = self._main("init", "--repo", self.repo) self.assertIn("配置文件已写", out) out = self._main("init") self.assertIn("配置文件已存在", out) out = self._main("config") cfg = json.loads(out) self.assertEqual(cfg["model"], "gpt-5.6-terra") out = self._main("doctor") doc = json.loads(out) self.assertEqual(doc["failed_tasks"], []) out = self._main("failures") self.assertEqual(json.loads(out), {"failed_tasks": []}) host.save_state(self.state_path, {"tasks": {"bad": {"name": "bad", "status": "failed"}}, "automations": []}) out = self._main("failures") self.assertEqual(json.loads(out), {"failed_tasks": ["bad"]}) def test_health_good_and_bad(self): out = self._main("--codex-bin", self.fake_bin, "health") report = json.loads(out) self.assertTrue(report["ok"]) self.assertTrue(report["checks"]["server_ok"]) code, out = self._main_code("--codex-bin", "/nonexistent-bin", "health") self.assertEqual(code, 1) report = json.loads(out) self.assertFalse(report["ok"]) def test_daemon_status_stop_and_sleep(self): out = self._main("daemon-status") doc = json.loads(out) self.assertFalse(doc["alive"]) self.assertIsNone(doc["pid"]) out = self._main("daemon-stop") self.assertIn("daemon 未运行", out) pidfile = os.path.join(self.tmp.name, "host-daemon.pid") with open(pidfile, "w") as f: f.write("garbage") out = self._main("daemon-stop") self.assertIn("pidfile 损坏", out) self.assertFalse(os.path.exists(pidfile)) out = self._main("sleep", "--seconds", "0") self.assertEqual(json.loads(out)["slept_seconds"], 0) code, _ = self._main_code("sleep", "--seconds", "61") self.assertEqual(code, 1) def test_github_workflow_pr_and_review_comment(self): code, _ = self._main_code("github-workflow") self.assertEqual(code, 1) code, _ = self._main_code("pr", "--task", "missing") self.assertEqual(code, 1) host.save_state(self.state_path, {"tasks": {"t1": {"name": "t1", "thread_id": "th-1", "status": "completed", "review_comments": []}}, "automations": []}) out = self._main("review-comment", "--task", "t1", "--file", "a.py", "--line", "3", "--comment", "fix this") comment = json.loads(out) self.assertEqual(comment["file"], "a.py") self.assertEqual(comment["line"], 3) state = self._load_state() self.assertEqual(len(state["tasks"]["t1"]["review_comments"]), 1) out = self._main("discard", "--task", "missing") self.assertIn("任务不存在", out) out = self._main("discard", "--task", "t1") self.assertIn("discarded t1", out) state = self._load_state() self.assertEqual(state["tasks"]["t1"]["status"], "discarded") def test_automations_cron_line_restore_recover(self): out = self._main("automations", "--cron-line") self.assertIn("codex-host-run-due", out) out = self._main("restore-state") self.assertIn("state 完好", out) out = self._main("recover") self.assertEqual(json.loads(out), {"recovered": []}) def test_dispatch_status_list_threads_and_run(self): self.created_names.append("dtask") out = self._main("--codex-bin", self.fake_bin, "--turn-timeout", "1", "dispatch", "--name", "dtask", "--task", "do thing", "--repo", self.repo) info = json.loads(out) self.assertEqual(info["task"], "dtask") self.assertEqual(info["thread_id"], "th-1") state = self._load_state() self.assertEqual(state["tasks"]["dtask"]["status"], "interrupted") out = self._main("status", "--task", "dtask") self.assertEqual(json.loads(out)["name"], "dtask") out = self._main("--codex-bin", self.fake_bin, "list") self.assertIn("dtask", out) out = self._main("--codex-bin", self.fake_bin, "thread-list") self.assertIn("th-1", out) out = self._main("--codex-bin", self.fake_bin, "skills-list") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "apps-list") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "mcp-servers-list") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "mcp-servers-reload") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "thread-read", "--thread-id", "th-1") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "thread-rename", "--thread-id", "th-1", "--name", "renamed") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "thread-archive", "--thread-id", "th-1") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "thread-unarchive", "--thread-id", "th-1") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "thread-compact", "--thread-id", "th-1") self.assertEqual(json.loads(out), {}) out = self._main("--codex-bin", self.fake_bin, "--turn-timeout", "1", "run", "--task", "dtask", "--prompt", "again") result = json.loads(out) self.assertEqual(result["status"], "timeout") out = self._main("--codex-bin", self.fake_bin, "compact", "--task", "dtask") self.assertIn("compact dtask", out) out = self._main("--codex-bin", self.fake_bin, "archive", "--task", "dtask") self.assertIn("archived dtask", out) out = self._main("--codex-bin", self.fake_bin, "code-review", "--task", "dtask", "--instructions", "look carefully") self.assertIn("code-review dtask", out) self.assertIn("review", out) def test_fork_delegate_interrupt_and_merge(self): self.created_names.append("ptask") self.created_names.append("ftask") self._main("--codex-bin", self.fake_bin, "--turn-timeout", "1", "dispatch", "--name", "ptask", "--task", "parent work", "--repo", self.repo) out = self._main("--codex-bin", self.fake_bin, "fork", "--task", "ptask", "--name", "ftask") self.assertIn("forked ptask -> ftask", out) self.assertIn("thread=th-2", out) out = self._main("--codex-bin", self.fake_bin, "--turn-timeout", "1", "delegate", "--parent-task", "ptask", "--task", "sub1", "--name", "sub1") doc = json.loads(out) self.assertEqual(doc["sub_task"], "sub1") # 中断:无 turn_id 与有 turn_id 两路。 host.save_state(self.state_path, {"tasks": {"it": {"name": "it", "thread_id": "th-1", "status": "running", "turns": []}}, "automations": []}) out = self._main("--codex-bin", self.fake_bin, "interrupt", "--task", "it") self.assertIn("没有可中断的 turn_id", out) host.save_state(self.state_path, {"tasks": {"it": {"name": "it", "thread_id": "th-1", "status": "running", "turns": [{"result": { "turn_id": "turn-1", "status": "completed"}}]}}, "automations": []}) out = self._main("--codex-bin", self.fake_bin, "interrupt", "--task", "it") self.assertIn("turn=turn-1", out) state = self._load_state() self.assertEqual(state["tasks"]["it"]["status"], "interrupted") # merge:ptask 的 worktree 无改动,合并即成功。 out = self._main("merge", "--task", "ptask") self.assertIn("merged", out) # goal-resume 对不存在的任务拒绝。 out = self._main("--codex-bin", self.fake_bin, "goal-resume", "--task", "missing") self.assertIn("拒绝", out) # pr 对有任务但无 gh 时退出 1。 code, _ = self._main_code("pr", "--task", "ptask") self.assertEqual(code, 1) if __name__ == "__main__": unittest.main()