792 lines
35 KiB
Python
792 lines
35 KiB
Python
"""覆盖率 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
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(REPO_ROOT))
|
||
|
||
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,
|
||
)
|
||
|
||
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()
|