Files
codex-app-server/tests/test_host_daemon_passthrough.py
T
host 72d343b2c7 fix(host): daemon 循环把显式全局 flag 透传给 recover/run-due 子进程
上一轨 #266 只修了 --state,本轨补其余项:--codex-home、
--codex-bin、--model、--model-provider、--effort、--sandbox、
--approval-policy、--approval、--turn-timeout、--progress-interval。
旧循环只传 --config,子进程重解析回落到配置/默认值,与 daemon
本体跑 turn 时的 CLI 覆盖值不是同一套(run-due 的 Server 与
dispatch/run_task_turn 全读这些值)。
修法:配置解析覆盖 args 之前先快照显式 CLI 值,循环里只把显式
值原样放进子进程 argv;数值 flag 用 is not None 判定(0 是合法
显式值:turn-timeout 0 不限时、progress-interval 0 关进度);
相对路径原样透传(daemonize 不 chdir,子进程 cwd 一致)。
有意不传:--config(已传解析后 config_path)、daemon 专属
--interval/--foreground(子命令不接受)、--ignore-host-budget
(定给云端 worker 路径,且 recover/run-due 都不查预算,传了
无行为差异、只留将来预算门继承口)。
--state 透传沿用 #266 语义原样(显式 is not None 才传),未改动。
回归:tests/test_host_daemon_passthrough.py 4 项。
2026-11-03 02:49:26 +00:00

212 lines
9.0 KiB
Python
Raw 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.
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()