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

+66 -1
View File
@@ -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):
+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()