fix(honcho): skip memory calls while the oauth grant is dead

reauth_required() existed but nothing called it, so after a grant died
every dialectic fire and sync flush still sent a Honcho API call that
401ed. dialectic_query and _flush_session now check the dead-grant flag
first and skip the call: dialectic raises HonchoAuthError (exempt from
cadence backoff), sync returns False with the failure recorded so the
one-time notice still fires.

The check compares the on-disk refresh-token digest, so a re-login flips
it back with no network call and the next cadence resumes immediately.
Transient auth errors keep the existing force-refresh-and-retry path.

Four new tests: a dead grant issues no dialectic or sync call, and a
re-login resumes both without waiting.
This commit is contained in:
Erosika
2026-08-06 13:52:54 -04:00
committed by kshitij
parent 6ea01262fc
commit ecfc427b28
2 changed files with 114 additions and 4 deletions

View File

@@ -56,6 +56,12 @@ def _auth_error_message(exc: BaseException) -> str:
)
_REAUTH_REQUIRED_MESSAGE = (
"Honcho OAuth grant is revoked and cannot be refreshed; "
"re-authenticate with 'hermes honcho setup'."
)
@dataclass
class HonchoSession:
"""
@@ -219,6 +225,23 @@ class HonchoSessionManager:
self._auth_notice_emitted = True
return self._auth_failure
def _reauth_required(self) -> bool:
"""True when the OAuth grant is dead and only a new login can fix it.
Reads the config and compares the refresh-token digest, so a re-login
flips this back to False with no network call — recovery is immediate.
"""
try:
from plugins.memory.honcho import oauth
from plugins.memory.honcho.client import resolve_config_path
host = getattr(self._config, "host", "") or ""
if not host:
return False
return oauth.reauth_required(resolve_config_path(), host)
except Exception:
return False
def _force_reauth(self) -> bool:
"""Rotate the OAuth token after a server-side 401 and rebind the client.
@@ -512,6 +535,15 @@ class HonchoSessionManager:
if not session.messages:
return True
new_messages = [m for m in session.messages if not m.get("_synced")]
if not new_messages:
return True
# A dead grant cannot authenticate; skip the call until re-login.
if self._reauth_required():
self._record_auth_failure(HonchoAuthError(_REAUTH_REQUIRED_MESSAGE))
return False
user_peer = self._get_or_create_peer(session.user_peer_id)
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
honcho_session = self._sessions_cache.get(session.honcho_session_id)
@@ -521,10 +553,6 @@ class HonchoSessionManager:
session.honcho_session_id, user_peer, assistant_peer
)
new_messages = [m for m in session.messages if not m.get("_synced")]
if not new_messages:
return True
honcho_messages = []
for msg in new_messages:
peer = user_peer if msg["role"] == "user" else assistant_peer
@@ -750,6 +778,12 @@ class HonchoSessionManager:
if not session:
return ""
# A dead grant cannot authenticate; skip the call until re-login.
if self._reauth_required():
exc = HonchoAuthError(_REAUTH_REQUIRED_MESSAGE)
self._record_auth_failure(exc)
raise exc
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id is None:
return ""

View File

@@ -398,6 +398,82 @@ class TestSyncAuthRetry:
assert mgr._auth_failure is None
# ---------------------------------------------------------------------------
# dead grant: skip calls entirely until re-login
# ---------------------------------------------------------------------------
def _kill_grant(tmp_path, monkeypatch) -> Path:
"""Revoke the grant on a tmp config and point the manager's path at it."""
from plugins.memory.honcho import client as client_mod
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
monkeypatch.setattr(
oauth, "_http_post_form_status",
lambda *a, **k: (400, {"error": "invalid_grant"}),
)
oauth.ensure_fresh_token(path, "hermes", now=1000)
assert oauth.reauth_required(path, "hermes") is True
monkeypatch.setattr(client_mod, "resolve_config_path", lambda: path)
return path
def _relogin(path: Path) -> None:
oauth.install_grant(
path, "hermes",
{"access_token": "hch-at-fresh", "refresh_token": "hch-rt-fresh", "expires_in": 3600},
client_id="hermes-desktop",
token_endpoint="http://localhost:8000/oauth/token",
)
class TestDeadGrantSkipsCalls:
def test_dead_grant_issues_no_dialectic_call(self, tmp_path, monkeypatch):
_kill_grant(tmp_path, monkeypatch)
peer = _FlakyPeer(failures=0)
mgr = _make_manager(peer)
with pytest.raises(HonchoAuthError):
mgr.dialectic_query("k", "q")
assert peer.calls == 0
assert mgr.pop_auth_notice() is not None
def test_relogin_resumes_dialectic_without_waiting(self, tmp_path, monkeypatch):
path = _kill_grant(tmp_path, monkeypatch)
peer = _FlakyPeer(failures=0)
mgr = _make_manager(peer)
with pytest.raises(HonchoAuthError):
mgr.dialectic_query("k", "q")
assert peer.calls == 0
_relogin(path)
assert mgr.dialectic_query("k", "q") == "synthesized answer"
assert peer.calls == 1
assert mgr._auth_failure is None
def test_dead_grant_issues_no_sync_call(self, tmp_path, monkeypatch):
_kill_grant(tmp_path, monkeypatch)
flaky = _FlakyHonchoSession(failures=0)
mgr, session = _make_sync_manager(flaky)
assert mgr._flush_session(session) is False
assert flaky.calls == 0
assert mgr._auth_failure is not None
def test_relogin_resumes_sync_without_waiting(self, tmp_path, monkeypatch):
path = _kill_grant(tmp_path, monkeypatch)
flaky = _FlakyHonchoSession(failures=0)
mgr, session = _make_sync_manager(flaky)
assert mgr._flush_session(session) is False
assert flaky.calls == 0
_relogin(path)
assert mgr._flush_session(session) is True
assert flaky.calls == 1
assert all(m["_synced"] for m in session.messages)
assert mgr._auth_failure is None
# ---------------------------------------------------------------------------
# one-time user-facing notice
# ---------------------------------------------------------------------------