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 项。
This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent 24adb568da
commit 72d343b2c7
2 files changed
+277 -1

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
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()