import contextlib import io import json import os import signal import sys import tempfile import threading import time import unittest from pathlib import Path from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) import host def _flag_value(call, flag): """返回 call 里 flag 后面跟的值;flag 不在时返回 None。""" if flag in call: idx = call.index(flag) if idx + 1 < len(call): return call[idx + 1] return None class DaemonFlagPassthroughTest(unittest.TestCase): """残留项第 2 项:daemon 循环的子进程是否继承显式全局 flag。 复核结论:应继承(#266 只修了 --state,本文件覆盖其余项)。 daemon 本体跑 turn 时用的 CLI 覆盖值,旧循环只给子进程传 --config,子进程重解析会回落到配置/默认值——run-due 的 Server(codex_bin/home、approval、progress_interval、 model_provider)与 dispatch/run_task_turn(model、effort、 sandbox、approval_policy、turn_timeout)读的都不是 daemon 本体那套值。修法:main() 在配置解析覆盖 args 之前先快照 显式 CLI 值,循环里只把显式值原样放进子进程 argv。 测试直接跑 main() 的 daemon --foreground 循环:子进程调用 被 mock 捕获(不真拉 host.py 子进程),循环由定时发给本进程 的 SIGTERM 停下(daemon 的 handler 只置 flag)。 """ def _run_daemon_once(self, argv): calls = [] def fake_run(cmd, **kw): calls.append(list(cmd)) return mock.Mock(returncode=0) def sender(): time.sleep(1.5) try: os.kill(os.getpid(), signal.SIGTERM) except Exception: pass old_term = signal.getsignal(signal.SIGTERM) old_int = signal.getsignal(signal.SIGINT) sender_thread = threading.Thread(target=sender, daemon=True) out = io.StringIO() try: sender_thread.start() with mock.patch.object(sys, "argv", argv): with mock.patch("subprocess.run", side_effect=fake_run): with contextlib.redirect_stdout(out): host.main() finally: sender_thread.join(timeout=5) signal.signal(signal.SIGTERM, old_term) signal.signal(signal.SIGINT, old_int) return calls, out.getvalue() def _write_cfg(self, tmp, **overrides): cfg_path = os.path.join(tmp, "cfg.json") cfg = { "state": "config-state.json", "model": "cfg-model", "effort": "cfg-effort", "sandbox": "cfg-sandbox", "approval_policy": "cfg-approval-policy", "approval": "deny", "codex_home": "cfg-codex-home", "codex_bin": "cfg-codex-bin", "turn_timeout": 111, "progress_interval": 11, } cfg.update(overrides) with open(cfg_path, "w", encoding="utf-8") as f: json.dump(cfg, f) return cfg_path def test_explicit_flags_passed_to_both_children(self): with tempfile.TemporaryDirectory() as tmp: cfg_path = self._write_cfg(tmp) cli_state = os.path.join(tmp, "cli-state.json") cli_home = os.path.join(tmp, "cli-home") cli_bin = os.path.join(tmp, "cli-bin") argv = ["host.py", "--config", cfg_path, "--state", cli_state, "--codex-home", cli_home, "--codex-bin", cli_bin, "--model", "cli-model", "--model-provider", "cli-provider", "--effort", "cli-effort", "--sandbox", "cli-sandbox", "--approval-policy", "cli-approval-policy", "--approval", "auto", # 0 是合法显式值(不限时 / 关闭进度), # 真值判定会把它吞掉,必须用 is not None。 "--turn-timeout", "0", "--progress-interval", "0", "daemon", "--foreground", "--interval", "60"] calls, out = self._run_daemon_once(argv) self.assertEqual(len(calls), 2) self.assertIn(f"state={cli_state}", out) expected = { "--state": cli_state, "--codex-home": cli_home, "--codex-bin": cli_bin, "--model": "cli-model", "--model-provider": "cli-provider", "--effort": "cli-effort", "--sandbox": "cli-sandbox", "--approval-policy": "cli-approval-policy", "--approval": "auto", "--turn-timeout": "0", # CLI 的 "0" 经 float 解析成 0.0 再 str 回 "0.0", # 子进程 float 解析后仍是 0.0,语义与 0 相同。 "--progress-interval": "0.0", } for call in calls: self.assertEqual(call[1], str(REPO_ROOT / "host.py")) self.assertEqual(call.count("--config"), 1) self.assertEqual(_flag_value(call, "--config"), cfg_path) for flag, value in expected.items(): self.assertEqual(_flag_value(call, flag), value, f"{flag} 应透传 {value},实得 {call}") # 配置值一个都不应漏进子进程 argv(否则子进程 # 跑的就不是 CLI 覆盖的那套配置)。 joined = " ".join(call) for cfg_value in ("config-state.json", "cfg-model", "cfg-effort", "cfg-sandbox", "cfg-approval-policy", "cfg-codex-home", "cfg-codex-bin"): self.assertNotIn(cfg_value, joined) self.assertEqual(calls[0][-3:], ["recover", "--older-than", "3600"]) self.assertEqual(calls[1][-1], "run-due") def test_no_explicit_flags_children_unchanged(self): with tempfile.TemporaryDirectory() as tmp: cfg_path = self._write_cfg(tmp) argv = ["host.py", "--config", cfg_path, "daemon", "--foreground", "--interval", "60"] calls, out = self._run_daemon_once(argv) self.assertEqual(len(calls), 2) self.assertIn("config-state.json", out) base = [calls[0][0], str(REPO_ROOT / "host.py"), "--config", cfg_path] self.assertEqual(calls[0], base + ["recover", "--older-than", "3600"]) self.assertEqual(calls[1], base + ["run-due"]) def test_partial_explicit_only_those_passed(self): # 只给两个 flag:其余即使配置里有值、daemon 解析后有值, # 也绝不能把解析值当显式值透传(否则配置文件在 daemon # 启动后被改,子进程还拿旧解析值,且“未显式”语义被改)。 with tempfile.TemporaryDirectory() as tmp: cfg_path = self._write_cfg(tmp) argv = ["host.py", "--config", cfg_path, "--model", "cli-model", "--turn-timeout", "600", "daemon", "--foreground", "--interval", "60"] calls, _ = self._run_daemon_once(argv) self.assertEqual(len(calls), 2) for call in calls: self.assertEqual(_flag_value(call, "--model"), "cli-model") self.assertEqual(_flag_value(call, "--turn-timeout"), "600") for flag in ("--state", "--codex-home", "--codex-bin", "--model-provider", "--effort", "--sandbox", "--approval-policy", "--approval", "--progress-interval"): self.assertNotIn(flag, call) def test_excluded_flags_never_passed(self): # daemon 专属 flag 与预算开关有意不传(见 host.py 循环注释): # --interval/--foreground 是 daemon 子命令自己的, # recover/run-due 不接受;--ignore-host-budget 定给云端 # worker 路径,且两条子命令路径都不查预算,传了无行为 # 差异、只留继承口。 with tempfile.TemporaryDirectory() as tmp: cfg_path = self._write_cfg(tmp) argv = ["host.py", "--config", cfg_path, "--ignore-host-budget", "daemon", "--foreground", "--interval", "60"] calls, _ = self._run_daemon_once(argv) self.assertEqual(len(calls), 2) for call in calls: self.assertNotIn("--ignore-host-budget", call) self.assertNotIn("--interval", call) self.assertNotIn("--foreground", call) self.assertEqual(call.count("--config"), 1) if __name__ == "__main__": unittest.main()