diff --git a/gateway/restart.py b/gateway/restart.py index 8e31ab713b..a4a5bcee6c 100644 --- a/gateway/restart.py +++ b/gateway/restart.py @@ -358,6 +358,9 @@ def resolve_systemd_timeout_stop_sec( def resolve_restart_exit_wait_budget( drain_timeout: float, after_turn_timeout: float, cron_drain_timeout: float = 0.0, *, headroom: float = 15.0, ) -> float: - """Seconds a CLI should wait for the gateway PID to exit after SIGUSR1: in-band restart may - defer ``stop()`` until turns finish, then spend the longer chat or cron drain inside it.""" - return max(_seconds(drain_timeout), _seconds(cron_drain_timeout)) + _seconds(after_turn_timeout) + _seconds(headroom) + """Observer budget for in-band deferral, the full stop envelope, and replacement startup. + + The stop envelope includes cron cleanup reserve, supervisor headroom and the floor; + deferral precedes it, while observer headroom follows it. Cron zero opts out. + """ + return _seconds(after_turn_timeout) + resolve_systemd_timeout_stop_sec(drain_timeout, cron_drain_timeout) + _seconds(headroom) diff --git a/tests/gateway/test_restart_after_turn.py b/tests/gateway/test_restart_after_turn.py index dd06e69515..b79a8b0cf1 100644 --- a/tests/gateway/test_restart_after_turn.py +++ b/tests/gateway/test_restart_after_turn.py @@ -4,6 +4,7 @@ from gateway.restart import ( DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT, parse_restart_after_turn_timeout, resolve_restart_exit_wait_budget, + resolve_systemd_timeout_stop_sec, ) from gateway.run import GatewayRunner @@ -20,12 +21,15 @@ def test_parse_restart_after_turn_timeout_defaults_and_clamps(): def test_resolve_restart_exit_wait_budget_covers_both_phases(): - assert resolve_restart_exit_wait_budget(0, 0, headroom=15) == 15.0 - assert resolve_restart_exit_wait_budget(180, 21600, headroom=15) == 180 + 21600 + 15 - assert resolve_restart_exit_wait_budget(2, 3, 80, headroom=15) == 3 + 80 + 15 - assert resolve_restart_exit_wait_budget(80, 3, 2, headroom=15) == 3 + 80 + 15 - assert resolve_restart_exit_wait_budget(2, 3, 0, headroom=15) == 3 + 2 + 15 - assert resolve_restart_exit_wait_budget("bad", "bad", headroom="x") == 0.0 + assert resolve_restart_exit_wait_budget(0, 0, 0, headroom=15) == resolve_systemd_timeout_stop_sec(0, 0) + 15 + assert resolve_restart_exit_wait_budget(180, 21600, 0, headroom=15) == 21600 + resolve_systemd_timeout_stop_sec(180, 0) + 15 + for chat, cron in ((2, 80), (80, 2), (2, 0), (0, 0)): + stop_envelope = resolve_systemd_timeout_stop_sec(chat, cron) + assert resolve_restart_exit_wait_budget(chat, 3, cron, headroom=15) == 3 + stop_envelope + 15 + # A bounded observer uses the longer stop path, not the sum of independent drains. + assert resolve_restart_exit_wait_budget(80, 3, 80, headroom=15) == 3 + resolve_systemd_timeout_stop_sec(80, 80) + 15 + assert resolve_restart_exit_wait_budget(2, 3, 0, headroom=15) < resolve_restart_exit_wait_budget(2, 3, 80, headroom=15) + assert resolve_restart_exit_wait_budget("bad", "bad", 0, headroom="x") == 60.0 def test_cli_restart_wait_covers_configured_cron_drain(tmp_path, monkeypatch): @@ -38,7 +42,12 @@ def test_cli_restart_wait_covers_configured_cron_drain(tmp_path, monkeypatch): "agent:\n restart_drain_timeout: 2\n restart_after_turn_timeout: 3\n cron_drain_timeout: 80\n", encoding="utf-8", ) - assert gateway_cli._get_restart_exit_wait_budget() >= 3 + 80 + 15 + assert gateway_cli._get_restart_exit_wait_budget() == 3 + resolve_systemd_timeout_stop_sec(2, 80) + 15 + (tmp_path / "config.yaml").write_text( + "agent:\n restart_drain_timeout: 2\n restart_after_turn_timeout: 3\n cron_drain_timeout: 0\n", + encoding="utf-8", + ) + assert gateway_cli._get_restart_exit_wait_budget() == 3 + resolve_systemd_timeout_stop_sec(2, 0) + 15 def test_load_restart_after_turn_timeout_preserves_zero(tmp_path, monkeypatch):