Skip to content

feat(gateway): scale-to-zero idle detection + dormant-quiesce (Phase 0) - #52243

Merged
teknium1 merged 2 commits into
mainfrom
hermes/hermes-276885f8
Jun 25, 2026
Merged

feat(gateway): scale-to-zero idle detection + dormant-quiesce (Phase 0)#52243
teknium1 merged 2 commits into
mainfrom
hermes/hermes-276885f8

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvage of #52207 onto current main. Phase 0 of scale-to-zero: the gateway-side behaviour layer that lets a relay-only hosted instance suspend while idle and wake on demand without dropping inbound, consuming the Phase 5 relay primitives already on main (#51572, #51595).

The gateway decides it is idle, drains the relay via a new go_dormant() transport mode, and goes dormant so the platform (Fly autostop:"suspend") can suspend the now-traffic-idle machine; it wakes on the connector's wakeUrl poke and the reconnect supervisor drains the buffered backlog. The gateway never calls a vendor API — platform suspend/wake is the NAS/Fly layer's job (a later phase).

What's in this PR (Phase 0, gateway-side only)

  • go_dormant() — a third relay close mode. Distinct from disconnect() (terminal — cancels the reconnect supervisor) and an unexpected close (re-dials immediately): sends going_idle→ack then closes the socket while preserving the reconnect supervisor so the wake path stays armed. The supervisor then polls on a dormant cadence rather than fighting the suspend window.
  • gateway/scale_to_zero.py — pure, unit-testable idle/arm logic. Idle = no in-flight agent turn AND no inbound for N minutes (config, default 5) AND no live background work (backgrounded subagents / kanban / terminal(background=true)).
  • Arm-gate — the idle watcher only starts when opted in (the HERMES_SCALE_TO_ZERO Labs stamp), messaging is relay-only or absent, and a wakeUrl is registered. A non-opted instance behaves exactly as today.
  • Gateway-scoped last-inbound clock stamped at the single _handle_message chokepoint (user-originated inbound only).
  • process_registry.has_any_active() — cross-session live-process check for the background-work conjunct.

Changes vs the original PR

  • Dropped 26 lines of unrelated peer: true churn in package-lock.json (npm-version artifact); diff is now exactly the 9 substantive files.

Validation

Result
Cherry-pick onto current main clean, base 0 behind
Targeted tests 56/56 pass (test_scale_to_zero, test_scale_to_zero_watcher, test_relay_going_idle)
Pure-logic E2E + py_compile clean
Opt-in fail-safe non-stamped instance never arms

Original PR #52207 by @benbarclay. Authorship preserved via rebase-merge.

Infographic

scale-to-zero-phase-0

Net-new WebSocketRelayTransport.go_dormant() + RelayAdapter.go_dormant() —
the third transport mode the scale-to-zero behaviour layer needs, distinct
from both disconnect() and an unexpected close (decisions.md D12/F14):

- disconnect() sets _closing=True and CANCELS the reconnect supervisor
  (terminal "shutting down for good") -> a suspended machine never re-dials
  on wake, stranding its buffered backlog.
- an unexpected close re-dials IMMEDIATELY -> the socket never stays down,
  so the platform proxy never suspends the machine.

go_dormant(): going_idle->ack (reuse go_idle), then close the socket WITHOUT
setting _closing, so the reader's fall-through still arms the reconnect
supervisor (wake path stays live) but on the longer _dormant_redial_s
cadence so it doesn't fight the platform suspend window. A successful re-dial
clears _dormant. Honors the §3.4 wake->reconnect->drain contract.

Tests: 6 new in test_relay_going_idle.py incl. the F14 regression guard
(routing dormancy through disconnect() fails exactly the 4 wake-path tests).
Full relay suite 140 passed.
The gateway-side BEHAVIOUR layer that consumes the relay scale-to-zero
primitives (gateway-gateway Phase 5): the gateway decides it is idle and
drives the relay transport dormant so the platform (Fly autostop:"suspend")
can suspend the now-traffic-idle machine, which wakes on the connector's
wakeUrl poke (decisions.md Q3=C', D1-D13).

- gateway/scale_to_zero.py: pure helpers — scale_to_zero_enabled (the NAS
  Labs HERMES_SCALE_TO_ZERO stamp, D11/Q8=A), parse_idle_timeout_seconds
  (config.yaml gateway.scale_to_zero.idle_timeout_minutes, D2),
  messaging_is_relay_only_or_absent (F6/D1), should_arm (D1/D11/§3.4(1)),
  is_idle (D2/D3/F7).
- gateway/run.py: _last_inbound_at clock stamped on user inbound in
  _handle_message (F13); the arm-gate + idle predicate + the
  _scale_to_zero_watcher dormant sequence (mark draining -> adapter
  go_dormant() -> cooldown), started only when armed. Deliberately NOT the
  stop path and NOT mark_resume_pending (F12/D13).
- tools/process_registry.py: has_any_active() for the bg-work guard (D3/F7).
- hermes_cli/config.py: gateway.scale_to_zero.idle_timeout_minutes default 5.

Tests: 38 pure-logic + 6 watcher (incl. bg-work regression guard proven RED).
Full relay + scale-to-zero suites: 184 passed. The 20 unrelated failures in
the broader run are PRE-EXISTING on origin/main (custom-provider/tools tests),
confirmed via a pristine baseline worktree.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-276885f8 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11247 on HEAD, 11244 on base (🆕 +3)

🆕 New issues (3):

Rule Count
unresolved-import 2
invalid-argument-type 1
First entries
tests/gateway/relay/test_relay_going_idle.py:434: [invalid-argument-type] invalid-argument-type: Argument to `RelayAdapter.__init__` is incorrect: Expected `RelayTransport | None`, found `_StubTransport`
tests/gateway/test_scale_to_zero.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_scale_to_zero_watcher.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 5937 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jun 25, 2026
@teknium1
teknium1 merged commit d1cac0e into main Jun 25, 2026
27 checks passed
@teknium1
teknium1 deleted the hermes/hermes-276885f8 branch June 25, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants