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:
1 parent
24adb568da
commit
72d343b2c7
2 files changed
+277
-1
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user