Skip to content

fix: supervise WebSocket recovery per thread - #26

Closed
thomashaus wants to merge 7 commits into
Humalike:mainfrom
thomashaus:fix/humalike-supervised-websocket-recovery
Closed

thomashaus wants to merge 7 commits into
Humalike:mainfrom
thomashaus:fix/humalike-supervised-websocket-recovery

Conversation

@thomashaus

@thomashaus thomashaus commented Jul 31, 2026 •

Copy link
Copy Markdown

Summary

  • supervise one WebSocket reconnect loop per thread
  • refresh access tokens on reconnect
  • use bounded exponential backoff with clean shutdown
  • deduplicate outage/recovery alerts across concurrent threads
  • suppress replies while a thread is not ready instead of silently losing them
  • keep recovery state scoped by failure domain and connection
  • suppress generic recovery claims until every active failure domain is healthy
  • preserve the 1,800-second alert cooldown across recovery so rapid flaps do not spam outage/recovery pairs
  • declare the WebSocket runtime dependency and terminate missing-dependency faults without retry loops
  • make supervisor cleanup ownership-aware so stale tasks cannot erase replacement state

Validation

  • normal pytest suite: 107 passed
  • direct focused recovery runner: all 11 scenarios passed
  • mixed HTTP/WebSocket recovery regressions pass in both directions
  • repeated-flap regression: ten immediate WebSocket flaps emit one outage and one recovery, not 20 notifications
  • missing-dependency regression: zero backoff sleeps and zero reconnect-grant attempts
  • stale-cleanup regression preserves replacement task, readiness, route, session, and alert state
  • compileall: passed
  • diff check: passed
  • independent responsive-background evidence is linked from the Odin task
  • durable Mack learning: Team/Mack - Automation Specialist/journal/2026-07-31-realtime-recovery-state-must-be-connection-scoped.md

Deployment boundary

The active Odin Hermes installation does not currently have Humalike installed/enabled or linked to a Humalike account. Live deployment proof requires explicit approval for first-start messaging autoconfiguration, interactive account linking, and a controlled gateway restart. No live state was changed without that approval.

Control issue

This pull request was generated by AI during implementation.

Summary by CodeRabbit

  • New Features
    • Improved realtime connection recovery with automatic reconnects and bounded retry delays.
    • Connections now refresh authentication details before reconnecting.
    • Added controls to stop individual or all active realtime deliveries.
  • Bug Fixes
    • Prevented repeated failure alerts while preserving separate recovery status for different connection scopes.
    • HTTP recovery notifications no longer clear unrelated realtime errors.
    • Permanent connection failures no longer trigger endless retries.
  • Tests
    • Added regression coverage for recovery, reconnect cancellation, and failure handling.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: afb8bc3d-465f-4677-9a80-fd1d54aa3135

📥 Commits

Reviewing files that changed from the base of the PR and between ede0c2f and ff5c4bf.

📒 Files selected for processing (3)
  • tests/test_recovery.py
  • turn_taking/delivery.py
  • turn_taking/notify.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • turn_taking/notify.py
  • tests/test_recovery.py
  • turn_taking/delivery.py

📝 Walkthrough

Walkthrough

Realtime recovery now tracks failures by kind and scope. WebSocket delivery uses supervised reconnects with fresh tokens, bounded backoff, cancellation, and permanent dependency handling. Tests cover notification recovery, reconnect behavior, and cleanup.

Changes

Realtime recovery

Layer / File(s) Summary
Scoped failure recovery
turn_taking/notify.py, turn_taking/service.py
Failure alerts now track independent scopes. HTTP recovery clears only non-WebSocket failures. Recovery notifications occur after all failures of a kind resolve.
Supervised WebSocket delivery
turn_taking/delivery.py, turn_taking/service.py, turn_taking/state.py, plugin.yaml
Delivery tasks are tracked per thread. WebSocket failures trigger fresh-token reconnects with bounded backoff. Cancellation, cleanup, and missing dependency handling are implemented.
Recovery regression coverage
tests/test_recovery.py
Tests verify scoped recovery, reconnect supervision, cancellation, permanent dependency failures, and direct execution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StartDelivery
  participant ReceiveLoop
  participant WebSocket
  participant ThreadGrant
  StartDelivery->>ReceiveLoop: start supervised delivery
  ReceiveLoop->>WebSocket: connect with current token
  ReceiveLoop->>StartDelivery: invoke on_connected
  WebSocket-->>ReceiveLoop: report transport failure
  ReceiveLoop->>ThreadGrant: request fresh connection token
  ThreadGrant-->>ReceiveLoop: return reconnect grant
  ReceiveLoop->>WebSocket: reconnect with fresh token
  StartDelivery->>StartDelivery: cancel and clean up supervisor
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: supervising per-thread WebSocket recovery.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@turn_taking/delivery.py`:
- Around line 101-110: The ownership guard on line 103 that checks if task is
None or current is task only protects the DELIVERY_TASKS cleanup, but the
subsequent cleanup operations on lines 105-110 that remove DELIVERY_READY,
ROUTES, SESSIONS, and ws notify state execute unconditionally. Extend the
ownership check to guard all cleanup operations in the _cleanup_delivery_state
function by adding an early return when the caller is not the current task
owner, ensuring a superseded supervisor cannot wipe the live supervisor's state.

In `@turn_taking/hooks.py`:
- Line 135: Undo answer suppression whenever realtime response construction or
scheduling fails. Add an _unsuppress_answer helper beside _suppress_answer that
discards the normalized pending answer, then update the _respond construction
and scheduling flow to catch failures, close the coroutine when scheduling
fails, call _unsuppress_answer, and log the failure before returning or
continuing.

In `@turn_taking/notify.py`:
- Around line 168-178: The recovery message in recovered_http must describe only
HTTP/API recovery, not claim that turn-taking is fully active. Update the _send
text scheduled after clearing recovered_kinds to explicitly indicate HTTP/API
recovery while preserving the existing ws scope and recovery logic.
- Around line 79-94: Update _fire and the recovery/clear paths so _last_by_kind
timestamps persist after a scope recovers or is cleared, allowing the cooldown
to span disconnect/reconnect flaps. Track whether each outage actually emitted
an alert, and have recovered, clear, and recovered_http emit recovery
notifications only for outages with a corresponding alert; cooldown-suppressed
outages must not produce unpaired recovery messages.

In `@turn_taking/service.py`:
- Around line 249-253: Treat a failed websockets import in the delivery
supervision flow as a permanent dependency error: raise a dedicated exception
from the import block, handle it before the generic reconnect logic in
_supervise_delivery, discard DELIVERY_READY, and exit that thread without
retrying or emitting WS_LOST. Preserve the existing reconnect behavior for other
delivery failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1612c150-c384-4abc-b205-e2869f8a0ea7

📥 Commits

Reviewing files that changed from the base of the PR and between 6861cd9 and 4350bd3.

📒 Files selected for processing (6)
  • tests/test_recovery.py
  • turn_taking/delivery.py
  • turn_taking/hooks.py
  • turn_taking/notify.py
  • turn_taking/service.py
  • turn_taking/state.py

Comment thread turn_taking/delivery.py Outdated
Comment thread turn_taking/hooks.py Outdated
epoch = latest
if chat:
_suppress_answer(chat, draft) # drop the send whose content matches this answer
_suppress_answer(chat, draft) # drop only when realtime delivery is ready

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Suppression is registered before delivery is scheduled.

Line 135 marks the raw answer for suppression. Delivery is only attempted afterwards. Line 141 builds _respond outside the try block, and the scheduling at Lines 143-146 can fail. In either case the raw send stays suppressed and no bubble is delivered, so the turn produces silence.

The orphaned entry also stays in state.PENDING_ANSWERS[chat]. It then suppresses any later identical answer for that chat.

Undo the suppression when scheduling fails.

🛠️ Proposed fix

Add an inverse helper next to _suppress_answer at Line 22:

def _unsuppress_answer(chat_id: str, content: str) -> None:
    """Release a suppression when realtime delivery could not be scheduled."""
    state.PENDING_ANSWERS.get(chat_id, set()).discard((content or "").strip())

Then cover both the coroutine construction and the scheduling, and roll back on failure (Lines 141-149):

    try:
        _coro = _respond(sid, draft, epoch,
                         _with_memory(_build_system_prompt_for_turn_taking(None, sid), sid), meta)
    except Exception as e:
        _unsuppress_answer(chat, draft)
        _log.warning("tt transform: could not build _respond: %s", e)
        return None
    try:
        if state.LOOP is not None:
            asyncio.run_coroutine_threadsafe(_coro, state.LOOP)  # bubbles delivered via WS
        else:
            asyncio.create_task(_coro)  # fallback: already in a loop
    except Exception as e:
        _coro.close()
        _unsuppress_answer(chat, draft)
        _log.warning("tt transform: could not schedule _respond: %s", e)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@turn_taking/hooks.py` at line 135, Undo answer suppression whenever realtime
response construction or scheduling fails. Add an _unsuppress_answer helper
beside _suppress_answer that discards the normalized pending answer, then update
the _respond construction and scheduling flow to catch failures, close the
coroutine when scheduling fails, call _unsuppress_answer, and log the failure
before returning or continuing.

Comment thread turn_taking/notify.py
Comment thread turn_taking/notify.py
Comment on lines +168 to 178
def recovered_http() -> None:
"""Resolve HTTP/API failures only; never clear live realtime failures."""
try:
with _LOCK:
if not _active_kinds:
recovered_kinds = [kind for kind in _active_scopes if kind != "ws"]
if not recovered_kinds:
return
_active_kinds.clear()
_last_by_kind.clear() # let the next failure alert immediately
for kind in recovered_kinds:
_active_scopes.pop(kind, None)
_last_by_kind.pop(kind, None)
_schedule(lambda: _send("✅ Humalike recovered — turn-taking active again."))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The HTTP recovery text overstates the state.

recovered_http clears non-ws kinds only, and it deliberately leaves live realtime failures active. The message still says "turn-taking active again". If a ws scope is still failing, that text contradicts the WS_LOST alert that is still in force.

Scope the text to the HTTP path.

✏️ Proposed text fix
             for kind in recovered_kinds:
                 _active_scopes.pop(kind, None)
                 _last_by_kind.pop(kind, None)
-        _schedule(lambda: _send("✅ Humalike recovered — turn-taking active again."))
+            realtime_down = bool(_active_scopes.get("ws"))
+        text = ("✅ Humalike API recovered — realtime connection still reconnecting."
+                if realtime_down else
+                "✅ Humalike recovered — turn-taking active again.")
+        _schedule(lambda: _send(text))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def recovered_http() -> None:
"""Resolve HTTP/API failures only; never clear live realtime failures."""
try:
with _LOCK:
if not _active_kinds:
recovered_kinds = [kind for kind in _active_scopes if kind != "ws"]
if not recovered_kinds:
return
_active_kinds.clear()
_last_by_kind.clear() # let the next failure alert immediately
for kind in recovered_kinds:
_active_scopes.pop(kind, None)
_last_by_kind.pop(kind, None)
_schedule(lambda: _send("✅ Humalike recovered — turn-taking active again."))
def recovered_http() -> None:
"""Resolve HTTP/API failures only; never clear live realtime failures."""
try:
with _LOCK:
recovered_kinds = [kind for kind in _active_scopes if kind != "ws"]
if not recovered_kinds:
return
for kind in recovered_kinds:
_active_scopes.pop(kind, None)
_last_by_kind.pop(kind, None)
realtime_down = bool(_active_scopes.get("ws"))
text = ("✅ Humalike API recovered — realtime connection still reconnecting."
if realtime_down else
"✅ Humalike recovered — turn-taking active again.")
_schedule(lambda: _send(text))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@turn_taking/notify.py` around lines 168 - 178, The recovery message in
recovered_http must describe only HTTP/API recovery, not claim that turn-taking
is fully active. Update the _send text scheduled after clearing recovered_kinds
to explicitly indicate HTTP/API recovery while preserving the existing ws scope
and recovery logic.

Comment thread turn_taking/service.py Outdated
@thomashaus

Copy link
Copy Markdown
Author

This was generated by AI during final gate disposition.

Independent implementation gates are complete at cd3e0f738df5d51c17431d274878c012ff1a36b5:

  • Vera PASS WITH NOTES
  • Vex PASS WITH NOTES after three failed rounds and remediation
  • Nolan learning hygiene PASS
  • 107 full-suite tests, 11 focused tests, 11 direct scenarios, and 100/100 stress cycles passed
  • compileall and diff checks passed

The PR is mergeable, but thomashaus lacks upstream merge permission. A Humalike maintainer now owns review and merge. Live installation is deliberately separate and has not occurred.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_recovery.py`:
- Around line 13-28: Update the httpx setup before the isolated imports to
attempt importing the real package first and install the SimpleNamespace stub
only when that import raises ImportError. After the imports of state, notify,
service, and delivery, restore sys.modules["httpx"] by removing the temporary
stub so later tests can load the real package; retain the imported service
module’s existing reference.

In `@turn_taking/delivery.py`:
- Around line 138-144: Update the permanent WebSocket failure path around
WebSocketDependencyError and _cleanup_delivery_state so task cleanup does not
clear the active "ws" failure scope. Restrict notify.clear("ws", thread_id) to
intentional shutdown or successful reconnection, preserve the permanent alert
after cleanup, and extend the permanent-failure regression test to assert
notify.is_active("ws", "thread-a").
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 41d41257-625d-45e0-ad06-ab8bf15e4900

📥 Commits

Reviewing files that changed from the base of the PR and between 4350bd3 and ede0c2f.

📒 Files selected for processing (6)
  • plugin.yaml
  • tests/test_recovery.py
  • turn_taking/delivery.py
  • turn_taking/notify.py
  • turn_taking/service.py
  • turn_taking/state.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • turn_taking/notify.py
  • turn_taking/service.py

Comment thread tests/test_recovery.py Outdated
Comment thread turn_taking/delivery.py
@thomashaus thomashaus closed this by deleting the head repository Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants