上一轨 #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 项。
212 lines
9.0 KiB
Python
212 lines
9.0 KiB
Python
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()
|