fix(gateway): heal a dead reconnect watcher when the platform is already queued - #90448
fix(gateway): heal a dead reconnect watcher when the platform is already queued#90448jackulau wants to merge 1 commit into
Conversation
…ady queued _ensure_reconnect_watcher_running() exists for one situation: the reconnect watcher has exhausted _MAX_SUPERVISED_RESTARTS, so _spawn_supervised has logged "giving up restarts" and will never bring it back on its own (NousResearch#70344, and the supervised-restart half of NousResearch#71758). It had exactly one call site, inside the newly-queued branch of _queue_retryable_fatal_platform. That branch is unreachable for a platform already in _failed_platforms, which is the only kind of platform the watcher can have been retrying long enough to burn five rapid restarts on. So the backstop could not fire in the one state it was written for. The failure is silent by construction. The early return logs nothing, so there is no "queued for background reconnection" line. The stranded check in _handle_adapter_fatal_error_detached deliberately treats a queued platform as safe, so the gateway does not exit for the service manager either. With another platform still connected, self.adapters is non-empty and the "gateway staying alive, watcher will retry in background" branch is skipped too. A retryable fatal error can therefore produce a single ERROR line and then nothing: the platform sits in the queue that nobody is draining until someone restarts the process by hand (NousResearch#90386 reports 4h17m of that, with cron unaffected throughout). Call the ensure on the already-queued path as well. It is already idempotent and already cheap: it returns immediately unless the tracked task is done, and it routes through the same on_spawn handle tracking, so a live watcher is never duplicated. The queue entry itself is deliberately left untouched. Re-enqueueing would reset attempts and next_retry, restarting the backoff ladder on every fatal error and hammering a provider that is already refusing the connection.
|
Agreed, and I checked both since #71177 is closed and I wanted to be sure this was not a rehash of something a maintainer had already declined. It is not: #71177 is where The useful detail is where #71177 put the call: logger.info(
"%s queued for background reconnection",
adapter.platform.value,
)
+ self._ensure_reconnect_watcher_running()Inside That also makes the two landed pieces complementary rather than overlapping with this one:
Full-suite note for reviewers: |
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head d6c333742d696d9dc8aa71dabe9a99392640f57d against base aebab05f9ec08c29672dd709db2e639d00a8544a; current main is a72c9ca248a051b8c7e8a69ff422c7be5066cdc4, four commits beyond the base with no overlap in gateway/run.py. Exact-head CI, Docker, and Nix are green, and there were no prior formal reviews on this head.
The local hunk is sensible defense-in-depth: if a retryable fatal callback arrives for an already-queued platform, checking watcher liveness without resetting attempts / next_retry is strictly better than silently returning, and the two guard tests correctly prevent re-enqueue/backoff reset and duplicate spawning.
I do not think the current evidence proves the P1 is closed, though. There are two architecture blockers.
1. The healer is still event-coupled after the event source is gone
The predecessor architecture says this explicitly. #72366 (salvage of #71867 by @ygd58) restored _spawn_supervised because _ensure_reconnect_watcher_running() was only reached from a new fatal-error arrival; if the watcher died while a platform was already queued and no later platform failed, nothing noticed it. Supervision fixed that by autonomously respawning the watcher.
This PR correctly observes that supervision is finite (_MAX_SUPERVISED_RESTARTS). But after that finite budget is exhausted, the system is back in the exact state #72366 described: queued work exists, the watcher is dead, and no independent owner is checking the invariant. Adding _ensure...() to the already-queued branch of another fatal callback is still another event-coupled check.
The core regression test currently manufactures the missing event:
- pre-populate
_failed_platforms; - mark
_reconnect_watcher_taskdead; - synthesize a fresh fatal adapter;
- call
_queue_retryable_fatal_platform().
That proves the new branch, but it does not prove the claimed self-heal topology. In production, #81036 moved queue publication before disconnect and removes the failed adapter from the live adapter map; once the reconnect watcher has burned through its own restart budget, there is no guarantee any adapter remains that can emit the second fatal callback the test injects. If no new fatal event happens, this patch never runs.
Required witness: put one platform in _failed_platforms, make platform_reconnect_watcher fail rapidly enough to exhaust the supervised restart budget, emit no further fatal callbacks, then prove the system recovers automatically once the underlying condition clears. The durable invariant should be something like:
while
_runningand_failed_platformsis non-empty, either a reconnect watcher is live, a bounded respawn is scheduled, or the gateway has requested supervisor restart.
That ownership belongs at the watcher/supervisor boundary, not at a future platform event. A reasonable shape is a critical-task on_give_up path that either schedules a slower bounded retry tier or requests process restart when queued work still exists; an independent housekeeping reconciliation would also close it. Please do not turn this into an unbounded tight restart loop.
2. #90386 is not current-main proof for this branch
The issue reporter is on Hermes v0.19.0 dated 2026-07-20. The directly preceding same-symptom repair, #81036, merged 2026-08-07 (salvage of #80700, preserving @HexLab98) and specifically changed the fatal path to:
- queue retryable platforms before any disconnect await;
- put an outer hard deadline around fatal handling;
- best-effort queue on cancellation/exception;
- harden Telegram disconnect steps.
That PR fixed #80598, whose report had the same signature: fatal line, then no queue/reconnect log, gateway still alive. The #90386 runtime therefore predates the current-main protection that materially changes how to interpret its log silence. Its logs cannot establish that adapter.platform in _failed_platforms was the surviving branch on current main; the old build could still have been hitting the already-fixed pre-queue disconnect wedge.
So I would not merge this with Fixes #90386 / P1 closure on the present evidence. Either reproduce the already-queued + dead-watcher state on a current-main build containing #81036, or reframe this PR as a narrow hardening (Refs #90386) and leave the incident open until a current-main witness exists.
Topology / credit
- #70987 (
@teknium1, based on work by@kshitijk4poor/@webtecnica) introduced the manual watcher-liveness backstop. - #72366 preserves
@ygd58from #71867 and owns supervised watcher restart + live-handle tracking. Its own rationale identifies the no-new-fatal-event state this PR still does not close after budget exhaustion. - #81036 preserves
@HexLab98from #80700 and is the current fatal-handler queue-before-disconnect / outer-timeout authority. It must be part of the acceptance witness because #90386 predates it. - #90448 is complementary hardening of the duplicate/re-fatal branch; it is not a duplicate of those predecessors, but it also cannot substitute for the critical-task exhaustion owner.
Re-review gate: autonomous no-new-event exhaustion witness; explicit disposition of Fixes #90386 versus current-main reproduction; preserve the predecessor credit above; rebase the four non-overlapping main commits and rerun exact-head CI.
What does this PR do?
_ensure_reconnect_watcher_running()exists for exactly one situation: the reconnect watcher has exhausted_MAX_SUPERVISED_RESTARTS, so_spawn_supervisedhas loggedgiving up restartsand will never bring it back on its own. That is what #70344 added it for, and it is the gap_spawn_supervised's own auto-restart (the #71758 fix) explicitly does not cover, because the budget is finite.It had exactly one call site:
The
already in _failed_platformsearly return skips it. But a platform that is already queued is the only kind of platform the watcher can have been retrying long enough to burn five rapid restarts on. The backstop could not fire in the one state it was written for.Why the outage is silent rather than loud
This is the part that makes it expensive to diagnose. Every other guard in the fatal path is deliberately satisfied:
logger.info("... queued for background reconnection")strandedcheck in_handle_adapter_fatal_error_detachedplatform not in self._failed_platforms; it is in there, so the platform reads as safe and the gateway never exits for the service managerNo connected messaging platforms remain ...not self.adapters; with a second platform still connected, non-empty... gateway staying alive, watcher will retry in backgroundSo a retryable fatal error can produce a single
ERRORline and then nothing at all, while the platform sits in a queue nobody is draining. #90386 reports 4h17m of exactly that shape, with the cron scheduler running normally the whole time and recovery requiring a manualsystemctl --user restart.The change
Call the ensure on the already-queued path too. It is already idempotent and already cheap - it returns immediately unless the tracked task is
done(), and it spawns through the sameon_spawnhandle tracking, so a live watcher is never duplicated.The queue entry is deliberately not touched. Re-enqueueing would reset
attemptsandnext_retry, restarting the backoff ladder on every fatal error and hammering a provider that is already refusing the connection. There is a test that fails if someone later "simplifies" it that way.On the reported issue
This closes a real, reachable hole on that path, and it matches #90386's log signature exactly (one
Fatal telegram adapter errorline, then silence, gateway alive, cron unaffected). I want to be straight about what I have and have not proven: I do not have the reporter's_failed_platformscontents at 04:02:31, so I cannot prove from their logs alone that this is the only thing that went wrong in that outage. The report's own suggestion 3 (a state-file watchdog) would be a broader belt-and-braces change and is not attempted here. What I can show is that this branch is reachable, silent, terminal, and now covered.Related Issue
Fixes #90386
Type of Change
Changes Made
gateway/run.py-_queue_retryable_fatal_platform: split the combined early return so thealready queuedcase calls_ensure_reconnect_watcher_running()before returningFalse. Theno platform_configcase still returns immediately, since there is nothing to reconnect to.gateway/run.py-_ensure_reconnect_watcher_runningdocstring: it is now called from both paths, and the docstring says which and why.tests/gateway/test_platform_reconnect.py- newTestRequeueHealsDeadReconnectWatcherwith three tests.How to Test
pytest tests/gateway/test_platform_reconnect.py -q- 24 passed.Sabotage proof. Revert only the
gateway/run.pyhunk and re-run:test_requeue_respawns_a_watcher_that_gave_up_restartingfails; the other two pass unpatched by design - they constrain the shape of the fix rather than detect the bug:test_requeue_does_not_disturb_the_existing_queue_entryfails any fix that re-enqueues,test_requeue_with_a_live_watcher_spawns_nothingfails any fix that spawns unconditionally.ruff check gateway/run.py tests/gateway/test_platform_reconnect.py- clean.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — docstring updated; no user-facing docs describe this internal pathcli-config.yaml.exampleif I added/changed config keys — N/A, no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/ADuplicate check
Ran before writing any code, since this seam has been fixed several times:
gh search prs --repo NousResearch/hermes-agent "_ensure_reconnect_watcher_running" --state open: no resultsgh search prs ... "reconnect watcher respawn" --state open: no resultsgateway/run.pyat this seam: fix(gateway): bound adapter disconnect in fatal-error handler #54041 bounds the disconnect inside the fatal handler, fix(gateway): shield secondary profile fatal reconnect #71646 shields secondary-profile fatal reconnect, fix(gateway): clear stale platform status when adapter creation fails #88305 clears stale platform status on adapter-creation failure. None of them touch_queue_retryable_fatal_platform's early return or the watcher-liveness call.