776 lines
32 KiB
Python
776 lines
32 KiB
Python
"""覆盖率 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()
|