Files
hermes-agent/hermes_cli/update_cmd_fleet_gatewayless.py
Austin Pickett d94769da64 fix(update): a Desktop-only host settles an inventory-less restart obligation (#120740)
* refactor(update): one predicate for serve rows outside the gateway matrix

The inventory branch of `_marker_only_restart_obsolete` inlined the rule for which
serve/dashboard rows the gateway matrix neither covers nor needs to (supervisor-owned,
or a manual serve handed to its own reminder). The inventory-less branch needs the same
rule for #118742, so it moves to `update_cmd_fleet_gatewayless.runtime_outside_gateway_evidence`
and both branches will read one definition. No behaviour change.

* fix(update): a Desktop-only host settles an inventory-less restart obligation

A host that runs no gateway (the Desktop app alone) can be left with an inventory-less
fleet-restart obligation: an updater that died before recording its inventory, or the
pre-inventory writer. With no owed set, the live gateway matrix is its only evidence, and
on that host the matrix is empty forever, so `_marker_only_restart_obsolete` never settled
and every later `hermes update` exited 1 with "gateways are still off the checkout code"
(#118742).

An empty fleet alone cannot tell that host from one whose gateway the dying update stopped,
so the inventory-less branch now asks the live host, never a historical receipt:
`host_owes_no_gateway_restart` settles only when no profile's `gateway_state.json` claims a
state other than stopped/startup_failed (a gateway that went away without a clean stop keeps
the obligation) and every live runtime sits outside the gateway matrix
(`runtime_outside_gateway_evidence`, shared with the inventory branch). HEAD must still
contain the pulled SHA (`checkout_contains`, same rule as #119367). Probe failures keep it.

Tests: the scoped-reconciliation matrix now holds its host at "the update stopped a gateway"
so it keeps pinning receipt independence; a new host-evidence matrix covers Desktop-only,
clean stop, carried commit, stopped gateway in a named profile, unclassified and
unidentified serves, a gateway row without fleet identity, and a diverged checkout. Two
manual-serve tests that assumed an empty fleet always stays pending now stub the live host
and assert the manual reminder survives the gateway obligation settling.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>

---------

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-23 18:48:33 -05:00

50 lines
2.5 KiB
Python

"""Gateway-less host evidence for the update-restart obligation (``update_cmd_fleet`` sibling).
An inventory-less obligation (the pre-inventory writer, or a tail that died before recording one)
has no owed set, so the live gateway matrix is its whole evidence. On a host that runs no gateway
(the Desktop app alone, #118742) that matrix is empty forever and the obligation could never
settle. An empty probe cannot tell that host from one whose gateway the dying update stopped, so
these readers ask the live host directly. A historical receipt is never consulted: it can belong
to an older update and says nothing about what runs now.
"""
from __future__ import annotations
from dataclasses import asdict
def runtime_outside_gateway_evidence(runtime: dict) -> bool:
"""A serve/dashboard row the gateway matrix neither covers nor needs to.
Its supervisor owns the restart (Desktop backend, launchd/systemd unit, Windows service), or it
is a manual serve whose restart ``defer_manual_serve`` has handed to its own durable reminder.
Unclassified backends and failed transfers stay evidence against settlement (#115090, #111494).
"""
from hermes_cli.update_cmd_fleet import _SUPERVISOR_OWNED_SERVE_BACKENDS
from hermes_cli.update_serve_obligations import defer_manual_serve
return runtime.get("kind") in ("serve", "dashboard") and (
defer_manual_serve(runtime) or runtime.get("supervisor") in _SUPERVISOR_OWNED_SERVE_BACKENDS
)
def host_owes_no_gateway_restart() -> bool:
"""True when no profile expects a gateway to be running and every live runtime is outside the matrix.
A ``gateway_state.json`` that does not say ``stopped``/``startup_failed`` belongs to a gateway
that went away without a clean stop, which is what an update that died mid-restart leaves
behind; it keeps the obligation. Profiles that never ran a gateway have no record at all.
"""
from gateway.status import read_runtime_status
from hermes_cli.update_inventory import collect_runtime_inventory
from hermes_cli.update_receipt import _NOT_EXPECTED_STATES, _profile_homes
for _profile, home in _profile_homes():
record = read_runtime_status(home / "gateway_state.json")
if record is None:
continue
state = record.get("gateway_state") if isinstance(record, dict) else None
if not (isinstance(state, str) and state in _NOT_EXPECTED_STATES):
return False
return all(runtime_outside_gateway_evidence(asdict(runtime)) for runtime in collect_runtime_inventory().runtimes)