Files
codex-app-server/tests/test_host_coverage85_2.py
host 4cd9b5eaa4 coverage85-step3: 运行器统一加根路径,tests/ E402 清零
新增 run_tests.py:三套件统一入口,只加仓库根(deploy 套件
再加 deploy/ 目录在根之前),各套件走独立子进程;verify 作
业改走运行器,coverage 作业因子进程测不到覆盖率保持原
discover 命令。tests/ 13 个文件的 sys.path 前置 import 全部
改回正常 import(含 test_host_trace 的 try/except 回落);
deploy 的 deploy 目录插入与 verify-multiuser 的 spec 加载按
陷阱 2 如实保留(仅删 verify-multiuser 冗余的根插入)。
.coveragerc omit 加 */run_tests.py,使新运行器文件不计入
生产覆盖率。门槛未动(仍 75%),未补新测试。
2026-11-03 02:48:54 +00:00

791 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""覆盖率 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()