From 72d343b2c7a3f4458549f325e5a638419e663a39 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 12:48:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(host):=20daemon=20=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E6=8A=8A=E6=98=BE=E5=BC=8F=E5=85=A8=E5=B1=80=20flag=20?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E7=BB=99=20recover/run-due=20=E5=AD=90?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轨 #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 项。 --- host.py | 67 +++++++- tests/test_host_daemon_passthrough.py | 211 ++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 tests/test_host_daemon_passthrough.py diff --git a/host.py b/host.py index 1ea91d5..a6c3e08 100644 --- a/host.py +++ b/host.py @@ -2505,6 +2505,25 @@ def main(): # 本进程内所有 budget_exceeded(state, cfg) 检查随之失效, # dispatch 前置门、重试前检查、goal 循环顶检查一并放行。 cfg = _effective_cfg(cfg, getattr(args, "ignore_host_budget", False)) + # daemon 循环透传用的显式 CLI 快照:必须在下面这组解析之前记, + # 解析完就分不清“CLI 给的”还是“配置/默认解析出的”了(#266 的 + # --state 同款问题,只是这里覆盖全部全局 flag)。只有显式给出的 + # 才进子进程 argv;未显式给时不加,子进程按原规则自行解析, + # 结果与 daemon 本体一致(且配置文件在 daemon 启动后被改时, + # 子进程能拿到新配置,而不是 daemon 启动时冻结的旧解析值)。 + # --config 不在这里:循环里本来就传解析后的 config_path。 + # --ignore-host-budget 不在这里:见 daemon 循环里的说明。 + explicit_codex_home = args.codex_home + explicit_codex_bin = args.codex_bin + explicit_state = args.state + explicit_model = args.model + explicit_model_provider = args.model_provider + explicit_effort = args.effort + explicit_sandbox = args.sandbox + explicit_approval_policy = args.approval_policy + explicit_approval = args.approval + explicit_turn_timeout = args.turn_timeout + explicit_progress_interval = args.progress_interval args.model = args.model or cfg.get("model") or "gpt-5.6-terra" args.effort = args.effort or cfg.get("effort") or "low" args.sandbox = args.sandbox or cfg.get("sandbox") @@ -2763,13 +2782,59 @@ def main(): while not stop["flag"]: ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) print(f"{ts} daemon tick: recover + run-due", flush=True) + # 子进程只带显式 CLI flag(原值原样透传,不传解析后的值): + # --state 是 #266 已定语义(显式才传),这里原样保留; + # 其余全局 flag 同理——daemon 本体跑 turn 时用的就是这些 + # CLI 覆盖值,不传则子进程回落到配置/默认值,与 daemon + # 本体不是同一套有效配置(codex_bin/home、model、 + # model_provider、sandbox、审批、超时、进度间隔全受影响; + # run-due 的 Server 与 dispatch/run_task_turn 都读它们, + # recover 读 state/codex 相关配置)。数值 flag 用 + # is not None 判定:--turn-timeout 0(不限时)、 + # --progress-interval 0(关闭进度)都是合法显式值, + # 用真值判定会把 0 吞掉。相对路径原样透传即可: + # daemonize 不 chdir,子进程 cwd 与 daemon 一致, + # 且子进程对 CLI 值同样原样使用(不经 _resolve_path)。 + # 有意不传的:--config(已传解析后的 config_path, + # 再传一次是重复);daemon 专属的 --interval、 + # --foreground(recover/run-due 不接受,传了会 argparse + # 报错致整轮空转);--ignore-host-budget(见下)。 + # --ignore-host-budget 不传:它的 help 本就定给云端 + # worker 路径,standalone 预算照常;且 recover 与 + # run-due 两条子命令路径都不查 budget_exceeded + #(run-due 调 run_task_turn,不走带预算门的分支), + # 传了当下也无行为差异,只会给将来某天子命令加预算门 + # 时埋一个“daemon 悄悄替自动化关预算”的继承口—— + # 预算关口应由真正跑 turn 的那次调用显式决定。 child_base = [sys.executable, os.path.join(HERE, "host.py"), "--config", config_path] if explicit_state is not None: child_base += ["--state", explicit_state] + if explicit_codex_home is not None: + child_base += ["--codex-home", explicit_codex_home] + if explicit_codex_bin is not None: + child_base += ["--codex-bin", explicit_codex_bin] + if explicit_model is not None: + child_base += ["--model", explicit_model] + if explicit_model_provider is not None: + child_base += ["--model-provider", explicit_model_provider] + if explicit_effort is not None: + child_base += ["--effort", explicit_effort] + if explicit_sandbox is not None: + child_base += ["--sandbox", explicit_sandbox] + if explicit_approval_policy is not None: + child_base += ["--approval-policy", explicit_approval_policy] + if explicit_approval is not None: + child_base += ["--approval", explicit_approval] + if explicit_turn_timeout is not None: + child_base += ["--turn-timeout", str(explicit_turn_timeout)] + if explicit_progress_interval is not None: + child_base += ["--progress-interval", + str(explicit_progress_interval)] for subcmd in (["recover", "--older-than", "3600"], ["run-due"]): try: - subprocess.run(child_base + subcmd, timeout=3600) + subprocess.run(child_base + subcmd, + timeout=3600) except Exception as e: print(f"{ts} daemon subcommand {subcmd} 失败:{e}", flush=True) for _ in range(args.interval): diff --git a/tests/test_host_daemon_passthrough.py b/tests/test_host_daemon_passthrough.py new file mode 100644 index 0000000..4dc5781 --- /dev/null +++ b/tests/test_host_daemon_passthrough.py @@ -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()