diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 08c7d8c019c94..d28a62d5ce1df 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -484,6 +484,13 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st for argv in variants: code, out, err = _exec_schtasks(argv) if code == 0: + limit_ok, limit_detail = _disable_scheduled_task_time_limit(task_name) + if not limit_ok: + return ( + False, + f"Created Scheduled Task {task_name!r}, but failed to disable the default " + f"72-hour time limit: {limit_detail}", + ) return (True, f"Created Scheduled Task {task_name!r}") last_code, last_err = code, (err or out or "") if delete_detail and "cannot find" not in delete_detail.lower(): @@ -491,6 +498,49 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}") +def _disable_scheduled_task_time_limit(task_name: str) -> tuple[bool, str]: + """Disable Windows Task Scheduler's default 72-hour execution time limit.""" + _assert_windows() + powershell = shutil.which("powershell.exe") or shutil.which("pwsh.exe") + if powershell is None: + return (False, "PowerShell not found on PATH") + + script = ( + "$ErrorActionPreference = 'Stop'; " + "$task = Get-ScheduledTask -TaskName $env:HERMES_TASK_NAME; " + "$task.Settings.ExecutionTimeLimit = 'PT0S'; " + "Set-ScheduledTask -InputObject $task | Out-Null" + ) + env = os.environ.copy() + env["HERMES_TASK_NAME"] = task_name + try: + proc = subprocess.run( + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + capture_output=True, + text=True, + encoding=_schtasks_encoding(), + errors="replace", + timeout=_SCHTASKS_TIMEOUT_S, + creationflags=0x08000000, # CREATE_NO_WINDOW + env=env, + ) + except subprocess.TimeoutExpired: + return (False, f"PowerShell timed out after {_SCHTASKS_TIMEOUT_S}s") + except OSError as e: + return (False, f"PowerShell invocation failed: {e}") + + detail = (proc.stderr or proc.stdout or "").strip() + if proc.returncode != 0: + return (False, detail or f"PowerShell exited with code {proc.returncode}") + return (True, "ExecutionTimeLimit set to PT0S") def _install_startup_entry(script_path: Path) -> Path: diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index 43f2b01dbf953..857575502f67d 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -241,6 +241,11 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat script_path = tmp_path / "Hermes_Gateway_alice.cmd" monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr( + gateway_windows, + "_disable_scheduled_task_time_limit", + lambda task_name: calls.append(("disable_limit", task_name)) or (True, "ok"), + ) def fake_schtasks(args): calls.append(tuple(args)) @@ -259,6 +264,59 @@ def fake_schtasks(args): assert calls[1][0] == "/Create" assert "/SC" in calls[1] assert "ONLOGON" in calls[1] + assert ("disable_limit", "Hermes_Gateway_alice") in calls + + +def test_install_scheduled_task_fails_if_time_limit_cannot_be_disabled(monkeypatch, tmp_path): + """A gateway task with Windows' default 72h limit is not a valid install.""" + script_path = tmp_path / "Hermes_Gateway_alice.cmd" + + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr( + gateway_windows, + "_exec_schtasks", + lambda args: (0, "SUCCESS", "") if args[0] in {"/Delete", "/Create"} else (1, "", "unexpected"), + ) + monkeypatch.setattr( + gateway_windows, + "_disable_scheduled_task_time_limit", + lambda task_name: (False, "Set-ScheduledTask failed"), + ) + + ok, detail = gateway_windows._install_scheduled_task("Hermes_Gateway_alice", script_path) + + assert ok is False + assert "72-hour time limit" in detail + assert "Set-ScheduledTask failed" in detail + + +def test_disable_scheduled_task_time_limit_sets_pt0s(monkeypatch): + """PT0S disables the Task Scheduler execution time limit.""" + captured: dict[str, object] = {} + + class _FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured.update(kwargs) + return _FakeCompleted() + + monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) + monkeypatch.setattr(gateway_windows.shutil, "which", lambda name: r"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + monkeypatch.setattr(gateway_windows.subprocess, "run", fake_run) + + ok, detail = gateway_windows._disable_scheduled_task_time_limit("Hermes_Gateway_alice") + + assert ok is True + assert "PT0S" in detail + command = captured["cmd"] + assert isinstance(command, list) + assert "Set-ScheduledTask" in command[-1] + assert "ExecutionTimeLimit = 'PT0S'" in command[-1] + assert captured["env"]["HERMES_TASK_NAME"] == "Hermes_Gateway_alice" def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):