Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions hermes_cli/gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,28 @@ def _wait_for_gateway_ready(
return []


def run_scheduled_task_and_wait(*, timeout_s: float = 30.0, all_profiles: bool = False) -> list[int]:
"""Job-Object-free relaunch fallback: run the registered Scheduled Task, then wait for a live gateway.

The updater's ``_spawn_detached`` / ``launch_detached_*`` children are created inside the updater's
own Job Object; when breakaway is denied they are reaped on its teardown (#48820/#91675), so the
post-update ``✓`` never holds. ``schtasks /Run`` executes the gateway under the Task Scheduler
service — outside any caller Job Object — the one start path that survives (and exactly the manual
``schtasks /Run /TN Hermes_Gateway`` recovery we already print). Returns the live PIDs, or ``[]``
when no task is registered, ``schtasks`` refused, or nothing came up (caller reports as before).

ponytail: targets the current profile's task only (``get_task_name()``); a fully-down multi-profile
fleet recovers just the active profile here — plumb per-``home`` task names if that ever matters.
"""
if not is_task_registered():
return []
code, _out, err = _exec_schtasks(["/Run", "/TN", get_task_name()])
if code != 0:
logger.warning("Scheduled Task /Run relaunch fallback failed (code %s): %s", code, (err or "").strip())
return []
return _wait_for_gateway_ready(timeout_s=timeout_s, all_profiles=all_profiles)


# ---------------------------------------------------------------------------
# Start attestation — honest reporting for deaths AFTER the liveness poll
#
Expand Down
20 changes: 17 additions & 3 deletions hermes_cli/update_cmd_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,12 @@ def _cold_start_windows_gateway_after_update(token: dict | None = None) -> bool:
if not pid:
raise RuntimeError("Windows gateway cold-start did not return a process ID")
ready_pids = gateway_windows._wait_for_gateway_ready()
via = "cold-start after update"
if not ready_pids:
# Reaped by the updater's Job Object (#48820) — retry under Task Scheduler, which escapes it.
ready_pids = gateway_windows.run_scheduled_task_and_wait()
if ready_pids:
via = "cold-start after update (scheduled task)"
if not ready_pids:
raise RuntimeError(f"Windows gateway cold-start PID {pid} did not become ready")
# The dead attestation has done its job (it authorized this spawn under Desktop ownership). Consume
Expand All @@ -1043,9 +1049,9 @@ def _cold_start_windows_gateway_after_update(token: dict | None = None) -> bool:
# success without a gateway (#110020 review).
if generation:
gateway_windows._consume_start_attestation(generation)
print(f"\n✓ Gateway started via cold-start after update (PID: {', '.join(map(str, ready_pids))})")
print(f"\n✓ Gateway started via {via} (PID: {', '.join(map(str, ready_pids))})")
with suppress(Exception):
gateway_windows._write_start_attestation(ready_pids, "cold-start after update")
gateway_windows._write_start_attestation(ready_pids, via)
return True


Expand Down Expand Up @@ -1197,6 +1203,14 @@ def _verify_relaunched_gateways_alive(token: dict, profiles: dict, unmapped: lis
with _abort_on_error("Could not load Windows gateway liveness helpers"):
from hermes_cli import gateway_windows
ready_pids = gateway_windows._wait_for_gateway_ready(timeout_s=30.0, all_profiles=True)
via = "post-update relaunch"
if not ready_pids:
# The detached relaunch was reaped by the updater's Job Object (#48820). Retry via the Scheduled
# Task, which runs the gateway under Task Scheduler — outside any Job Object — so updates self-heal.
ready_pids = gateway_windows.run_scheduled_task_and_wait(all_profiles=True)
if ready_pids:
via = "post-update relaunch (scheduled task)"
print("\n ✓ Windows gateway relaunched via Scheduled Task (Job-Object-safe recovery)")
if not ready_pids:
token["profiles"] = dict(profiles)
token["unmapped"] = list(unmapped)
Expand All @@ -1207,7 +1221,7 @@ def _verify_relaunched_gateways_alive(token: dict, profiles: dict, unmapped: lis
)
raise RuntimeError("Windows gateway relaunch after update was not verified alive")
with suppress(Exception):
gateway_windows._write_start_attestation(ready_pids, "post-update relaunch")
gateway_windows._write_start_attestation(ready_pids, via)


def _resume_windows_gateways_after_update(token: dict | None) -> None:
Expand Down