Skip to content

fix(gateway): spare supervised and recorded gateways from the orphan reaper on all platforms - #86658

Merged
teknium1 merged 4 commits into
mainfrom
fix/gateway-orphan-reaper-supervised
Aug 15, 2026
Merged

teknium1 merged 4 commits into
mainfrom
fix/gateway-orphan-reaper-supervised

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

The gateway orphan reaper (_reap_unsupervised_gateway_orphans()) no longer kills supervised or recorded-healthy gateways on Desktop backend (re)start — on any platform. Root cause: the reaper's "supervised" check only knew systemd, so macOS launchd gateways and Windows Scheduled-Task / Startup-VBS gateways (plus standalone launcher-started gateways with a valid runtime record) were indistinguishable from orphans and were SIGTERM'd/SIGKILL'd every time a HERMES_DESKTOP=1 serve backend started — leaving messaging (WeChat/QQ/Telegram/Signal/Discord) silently offline, or under launchd KeepAlive producing infinite kill loops and in-flight request loss.

Consolidated salvage of the two complementary open fixes, generalized to fix the whole class:

Why not #83720: @EvanProgramming's PR pioneered the supervisor-guard direction and drove the diagnosis on #83683 — credited. But its full-bail _gateway_has_active_supervisor() short-circuit disables orphan reaping entirely whenever any supervisor is present (regressing the #51325/#75936 duplicate-port protection), and it bundles an independent desktop-boot relaunch feature (new config key + env var + web_server lifespan hook) that belongs in its own reviewed change. The exclusion-based fix here keeps true-orphan reaping working while sparing everything supervised or recorded.

Changes

  • hermes_cli/gateway.py: _reap_unsupervised_gateway_orphans() — service-managed PIDs and the recorded gateway PID + parent chain are excluded from the orphan set on all platforms.
  • tests/hermes_cli/test_gateway.py: +2 Windows regression tests (from fix(gateway): spare Scheduled-Task-supervised gateway from orphan reaper on Windows #86100); existing macOS tests updated to pin get_running_pid for their scenario.
  • contributors/emails/: mapping for arccat-114.

Validation

Before After
launchd gateway on Desktop start SIGTERM'd (kill loop / left down) untouched
Scheduled-Task gateway + bootstrap both killed both spared
Recorded standalone gateway killed spared
True orphan (no record, no service) reaped still reaped

Targeted tests: test_gateway.py, test_spawn_gateway_restart_reap.py, test_gateway_windows.py, test_gateway_proc_fallback.py — 28 passed, 2 skipped. E2E with real imports in an isolated HERMES_HOME: recorded-PID spared / service-PID spared / true orphan reaped — all pass.

Fixes #83683. Also resolves #86287, #86098, #85738, #85368, #85344, #85044, #84855, #84824, #84200.

Supersedes #85743, #86100, #83720 (reap half; the desktop-boot relaunch half of #83720 can be proposed separately).

Infographic

Orphan reaper spares supervised gateways

RelaxJonh and others added 4 commits August 14, 2026 20:53
_reap_unsupervised_gateway_orphans() kills every gateway PID found by
find_gateway_pids() on hosts without systemd (macOS launchd, Windows
Scheduled Task). This includes service-managed gateways that are NOT
orphans — they are supervised by launchd/systemd and should never be
killed during a stale-process sweep.

Add own |= _get_service_pids() to the exclusion set before scanning,
so launchd/systemd-supervised gateways are preserved. True orphans
(reparented leftovers not present in launchctl/systemctl) are still
found and reaped, preserving the #77276 protection.

Fixes #85344 (macOS launchd gateway killed by desktop serve startup)
Fixes #85044 (Windows Scheduled Task gateway killed by desktop serve)
Fixes #84855 (Permission denied to kill orphaned gateway PID)
Fixes #85368 (gateway process repeatedly killed, messaging offline)
…per on Windows

The orphan reaper kills a healthy gateway (and its Scheduled-Task bootstrap
parent chain) every time the Desktop backend starts on Windows, because
_get_service_pids() only implements systemd/launchd and returns an empty
set on Windows — a supervised gateway is therefore indistinguishable from
an unsupervised orphan.

Exempt the recorded healthy gateway PID and its parent chain from the
orphan scan on Windows, mirroring the macOS launchd exemption (#85913).
The Scheduled-Task bootstrap's argv matches the gateway scan, so without
exempting the parent chain killing the bootstrap takes the detached
gateway down with it.

Fixes #86098
…r to all platforms

Compose the service-PID exclusion (#85743, RelaxJonh) and the recorded-PID +
parent-chain exemption (#86100, arccat-114) into one cross-platform rule:

- _get_service_pids() exclusion now runs unconditionally, not only under
  is_macos() — it is the authoritative "supervised" signal for launchd and
  any systemd unit visible on a host that got past the systemd gate.
- The recorded-healthy-gateway (get_running_pid()) + parent-chain exemption
  now runs on every platform, not only Windows. A recorded, liveness-verified
  gateway is by definition not an orphan "the pidfile/runtime record can't
  see", so the reaper must never target it — this covers Windows Scheduled
  Task / Startup VBS supervision, standalone launcher-started gateways
  (the case #85743 alone would miss), and macOS/WSL equivalents.

True orphans (no service registration, no valid runtime record) are still
found and reaped, preserving the #51325/#75936 duplicate-port protection.

Existing macOS regression tests updated to pin get_running_pid to None for
their scenario; Windows regression tests from #86100 carry over unchanged.

Bug class: #83683 (root), #86287, #86098, #85738, #85368, #85344, #85044,
#84855, #84824, #84200.
@fluxkapacitor

Copy link
Copy Markdown

Happy to test this once it merges — same setup as our #83683 verification: macOS 26.5 headless mini, gateway under launchd (ai.hermes.gateway, KeepAlive=true), dashboard as its own launchd job, Desktop app connecting remotely. We already verified the narrow macOS fix (ce20857) live on the original trigger.

Test plan against this PR:

  1. Original trigger: update → detached-dashboard spawn → confirm the reap no-ops (no SIGTERM on the launchd-supervised gateway, no "⚠️ Gateway shutting down" spam, gateway PID stable across the cycle).
  2. The loaded-but-dead edge (raised in the Desktop restart reaps the live gateway but never relaunches it (WeChat/QQ go silent) — regression #83683 thread): stop the gateway, hold the launchd job in a loaded-but-not-running state, then trigger a Desktop backend start — confirm a dead recorded gateway is still reaped and relaunched (liveness-gated exemption behaving as designed), rather than being skipped and left down.
  3. True-orphan regression: confirm an unrecorded, unsupervised stray gateway process still gets reaped — the duplicate-port protection ([Bug]: On WSL without systemd, gateway restart / dashboard / /restart leave an orphaned, untracked gateway holding the port; status reports "stopped" while it is running #51325/[Bug]: macOS manual gateway restart leaves orphan gateway processes — one Discord message gets N threads/N replies #75936) must survive.

Will report both results back here or on #83683.

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery platform/windows Native Windows-specific behavior or breakage sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows needs-decision Awaiting maintainer decision before any implementation labels Aug 15, 2026
@EvanProgramming

Copy link
Copy Markdown
Contributor

@teknium1 — fair points, and the consolidation is the right call. I've split the relaunch half out so #83683 is fully covered by two independent, composable fixes.

On the two critiques of #83720 — you're right on both:

  1. Full-bail vs exclusion. The exclusion approach here is genuinely better. My _gateway_has_active_supervisor() short-circuit disabled all orphan reaping whenever a supervisor was present, which would regress the [Bug]: On WSL without systemd, gateway restart / dashboard / /restart leave an orphaned, untracked gateway holding the port; status reports "stopped" while it is running #51325/[Bug]: macOS manual gateway restart leaves orphan gateway processes — one Discord message gets N threads/N replies #75936 duplicate-port protection on those very hosts. Excluding the service-managed PIDs + the recorded gateway PID (and its parent chain) instead keeps true-orphan reaping working while sparing everything supervised or recorded. More surgical, and it covers the standalone-launcher gateway (Desktop restart reaps the live gateway but never relaunches it (WeChat/QQ go silent) — regression #83683 comments) that a service-PID-only exclusion would miss.

  2. Relaunch bundled in. Agreed — the desktop-boot relaunch is a separate feature and shouldn't have ridden along with the reap fix. It made fix(gateway): never reap supervised gateway + relaunch on desktop (re)start (#83683) #83720 a bigger, riskier review.

On the parent-chain walk: psutil is already a hard dependency of the codebase (imported pervasively in hermes_cli/, including gateway.py), so the import psutil in the reaper is safe — no missing-dependency gap on Windows. Good.

Split done: I extracted the relaunch half into #86693_ensure_desktop_gateway_running() as a detached desktop-gateway-recover thread in the desktop lifespan, guarded to be idempotent, opt-out via gateway.relaunch_gateway_on_desktop_start=false or HERMES_DESKTOP_NO_GATEWAY_RELAUNCH=1, and only acting when a supervisor is installed (never spawns a gateway the user didn't opt into). 8 tests. So:

#83720 can be closed in favor of #86658 (reap) + #86693 (relaunch). Thanks for crediting the original diagnosis and for cherry-picking @RelaxJonh's and @arccat-114's work with authorship preserved — that's the right way to consolidate. Happy to help land #86693 or adjust either PR if reviewers want changes.

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on cda8373 — chore: add contributor email mapping for arccat-114

⚠️ Warnings

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 24m38s vs 28m33s (-13.7%). 9 job(s) slower, 13 faster, 2 unchanged.

  • Python tests / Run tests slice 4/12: +20.0s
  • Python tests / Run tests slice 8/12: -18.0s
  • Python tests / Run tests slice 7/12: -16.0s
  • Python tests / Run tests slice 11/12: -14.0s
  • Python tests / Run tests slice 5/12: +10.0s

@EvanProgramming

Copy link
Copy Markdown
Contributor

Reviewed this closely. The direction is right — excluding service-managed/recorded PIDs instead of a full-bail guard is the correct call, and it preserves the #51325/#75936 duplicate-port protection that my earlier _gateway_has_active_supervisor() full-bail would have disabled. Three concrete issues, none fatal but worth fixing:

1. Windows protection hinges on gateway.pid. On Windows _get_service_pids() returns an empty set (no systemd/launchd query), so the only thing sparing a Scheduled-Task gateway is get_running_pid() plus a parent-chain walk starting from that recorded PID. If the supervised gateway is alive but its pidfile is missing/stale, the reaper SIGTERMs it. macOS is robust (launchd list returns the PID directly); Windows was not. This is the real gap — it's exactly the #86098 class of bug, just narrower than the pidfile-present path.

2. Unbounded parent-chain walk. The code adds every ancestor of the recorded gateway (up to PID 1) to the exclude set. Only the immediate supervisor bootstrap matters (its argv matches the gateway scan); the rest is broader than needed and makes the exclusion depend on psutil importing cleanly at reap time. Bounding to one level is both safer and clearer.

3. Test gap. The Windows tests always mock get_running_pid() to return the recorded PID, so the pidfile-less path from (1) is never exercised — the gap is unguarded.

Better solution: I've pushed #86702, which keeps the service-PID + recorded-PID exclusions (preserving #51325) but (a) bounds the parent exclusion to the immediate supervisor, and (b) adds a supervisor-owned backstop in the orphan filter — any candidate whose parent chain reaches launchd / systemd / the Windows services.exe tree is spared even with no pidfile. That closes the Windows gap without depending on gateway.pid. New tests TestReapSparesSupervisorOwnedWithoutPidfile pin the pidfile-less Windows (Scheduled Task) and macOS (launchd) cases.

Like this PR, #86702 only fixes the reap half; the "never relaunches" half of #83683 is tracked separately in #86693. So the complete, robust fix is #86702 (reap) + #86693 (relaunch). Happy to fold the relaunch in if reviewers prefer a single PR, but keeping them separate matches the earlier feedback about not bundling.

(Note: psutil is already a hard dependency of the codebase, so the parent-chain imports in both PRs are safe — not a concern.)

@teknium1
teknium1 merged commit 169ff2d into main Aug 15, 2026
47 checks passed
@teknium1
teknium1 deleted the fix/gateway-orphan-reaper-supervised branch August 15, 2026 04:44
@fluxkapacitor

Copy link
Copy Markdown

Tested the merged fix live on our macOS headless-mini setup — results posted on #83683: dashboard/desktop-backend start no longer triggers the reap (gateway PID stable, zero shutdown spam), restart path clean on the new code. The "never relaunches" half (#86693) is the remaining piece for full closure.

kshitijk4poor pushed a commit that referenced this pull request Aug 15, 2026
…han reaper on Windows (#83683)

On Windows _get_service_pids() is empty (no systemd/launchd query), so a
Scheduled-Task-supervised gateway whose gateway.pid record is missing or
stale is invisible to both the service-PID and recorded-PID exclusions the
reaper already applies (#86658) — and gets SIGTERM'd on every desktop open
(#86098 class, pidfile-less path).

Add a Windows-only backstop: any reaper candidate whose parent chain
reaches services.exe (the Task Scheduler launches tasks under the services
tree) is spared even with no pidfile.

The backstop is deliberately inert on POSIX: every process there has PID 1
(launchd/init/systemd) in its ancestry — and a genuine orphan is reparented
directly to PID 1 — so supervisor-name ancestry carries zero supervision
signal and would disable the reaper entirely on macOS/WSL (#51325, #75936).
POSIX supervised gateways are already covered pidfile-independently by the
_get_service_pids() exclusion.

Known limitation (fail-open, documented): if the Task-launched bootstrap
parent has already exited, Windows does not reparent the gateway, the chain
breaks before services.exe, and the gateway is treated as an orphan.

Salvaged from #86702 by @EvanProgramming (authorship preserved); reduced to
the genuinely-new Windows backstop — the PR's other two hunks were already
merged on main via #86658 (one in a strictly stronger full-parent-chain
form) and its POSIX ancestry checks were dropped as unsound (verified
empirically: a true double-fork orphan's psutil parent IS launchd).
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
…han reaper on Windows (NousResearch#83683)

On Windows _get_service_pids() is empty (no systemd/launchd query), so a
Scheduled-Task-supervised gateway whose gateway.pid record is missing or
stale is invisible to both the service-PID and recorded-PID exclusions the
reaper already applies (NousResearch#86658) — and gets SIGTERM'd on every desktop open
(NousResearch#86098 class, pidfile-less path).

Add a Windows-only backstop: any reaper candidate whose parent chain
reaches services.exe (the Task Scheduler launches tasks under the services
tree) is spared even with no pidfile.

The backstop is deliberately inert on POSIX: every process there has PID 1
(launchd/init/systemd) in its ancestry — and a genuine orphan is reparented
directly to PID 1 — so supervisor-name ancestry carries zero supervision
signal and would disable the reaper entirely on macOS/WSL (NousResearch#51325, NousResearch#75936).
POSIX supervised gateways are already covered pidfile-independently by the
_get_service_pids() exclusion.

Known limitation (fail-open, documented): if the Task-launched bootstrap
parent has already exited, Windows does not reparent the gateway, the chain
breaks before services.exe, and the gateway is treated as an orphan.

Salvaged from NousResearch#86702 by @EvanProgramming (authorship preserved); reduced to
the genuinely-new Windows backstop — the PR's other two hunks were already
merged on main via NousResearch#86658 (one in a strictly stronger full-parent-chain
form) and its POSIX ancestry checks were dropped as unsound (verified
empirically: a true double-fork orphan's psutil parent IS launchd).
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…han reaper on Windows (NousResearch#83683)

On Windows _get_service_pids() is empty (no systemd/launchd query), so a
Scheduled-Task-supervised gateway whose gateway.pid record is missing or
stale is invisible to both the service-PID and recorded-PID exclusions the
reaper already applies (NousResearch#86658) — and gets SIGTERM'd on every desktop open
(NousResearch#86098 class, pidfile-less path).

Add a Windows-only backstop: any reaper candidate whose parent chain
reaches services.exe (the Task Scheduler launches tasks under the services
tree) is spared even with no pidfile.

The backstop is deliberately inert on POSIX: every process there has PID 1
(launchd/init/systemd) in its ancestry — and a genuine orphan is reparented
directly to PID 1 — so supervisor-name ancestry carries zero supervision
signal and would disable the reaper entirely on macOS/WSL (NousResearch#51325, NousResearch#75936).
POSIX supervised gateways are already covered pidfile-independently by the
_get_service_pids() exclusion.

Known limitation (fail-open, documented): if the Task-launched bootstrap
parent has already exited, Windows does not reparent the gateway, the chain
breaks before services.exe, and the gateway is treated as an orphan.

Salvaged from NousResearch#86702 by @EvanProgramming (authorship preserved); reduced to
the genuinely-new Windows backstop — the PR's other two hunks were already
merged on main via NousResearch#86658 (one in a strictly stronger full-parent-chain
form) and its POSIX ancestry checks were dropped as unsound (verified
empirically: a true double-fork orphan's psutil parent IS launchd).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P1 High — major feature broken, no workaround platform/windows Native Windows-specific behavior or breakage sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

5 participants