fix(tui_gateway): stop the bot mailbox poll from flooding the log on installs that never received a delivery

The per-session notification poller ran `_poll_bot_live_delivery_once` every
0.5 s. Once a "Bot Chat" session exists, each pass opens state.db and takes
the exclusive active-session registry lock; on Windows (`msvcrt` LK_LOCK
gives up after 10 s of contention) that raised
`RuntimeError: active session file lock unavailable` and the loop logged
`Bot live-owner delivery poll failed` on every attempt — 8,838 warnings in
three days, 91% of one install's WARNING output (#111719).

Two changes:
- `tools/bot_live_delivery.has_mailbox`: the mailbox directory is created
  only when a delivery is first admitted, so a profile without it has nothing
  to claim — the poll now returns before the state.db open / registry lock.
  This keeps cron→Bot Chat and Bot Mode DM delivery intact on installs with
  no messaging platform configured (both deliver through this mailbox), which
  is why the poll is gated on the mailbox rather than on connected platforms
  (PR #111733's guard would have broken those).
- `_poll_bot_live_delivery_guarded`: a failing poll backs off 5 s before the
  next attempt and is logged at WARNING once per 60 s window (with the count
  of suppressed repeats), DEBUG otherwise.

Live probe (real poller loop, temp HERMES_HOME with a Bot Chat row and the
registry lock made unavailable, 3 s):
before: owner_lookups=6 WARNING=6 (with or without a mailbox)
after:  no mailbox -> owner_lookups=0 WARNING=0; mailbox -> owner_lookups=1 WARNING=1

Fixes #111719
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-15 12:14:37 -07:00
committed by Teknium
parent 2246c245f5
commit 3c3ab69abb
3 changed files with 91 additions and 5 deletions
@@ -80,6 +80,7 @@ def test_local_work_blocks_mailbox_claim_without_consuming_envelope(monkeypatch,
"_run_prompt_submit": submit,
"_notif_release_turn": lambda session: session.update(running=False),
})
mailbox._root(tmp_path).mkdir(parents=True) # a delivery was admitted for this profile
session = {"history_lock": threading.RLock(), "agent": object(), "session_key": "chat",
"active_session_lease": SimpleNamespace(lease_id="lease", released=False)}
for blocker in ("running", "queued_prompt", "queued_prompts", "_auto_continue_scheduled"):
@@ -93,3 +94,51 @@ def test_local_work_blocks_mailbox_claim_without_consuming_envelope(monkeypatch,
assert submitted == [("imported", author)] and not pending
assert receipts[0][0][1] == "receipt"
assert receipts[0][1]["reply"] == "reply"
def test_mailbox_poll_skips_owner_lookup_without_a_mailbox(monkeypatch, tmp_path):
"""No mailbox directory → no state.db open / registry lock per pass; the lookup runs once one exists."""
import tools.bot_live_delivery as mailbox
lookups = []
monkeypatch.setattr(mailbox, "find_canonical_live_owner", lambda home: lookups.append(home) or None)
poll = rebind(session_notifications._poll_bot_live_delivery_once, {"_session_home": lambda session: tmp_path})
session = {"history_lock": threading.RLock(), "agent": object(), "session_key": "chat",
"active_session_lease": SimpleNamespace(lease_id="lease", released=False)}
assert poll("live", session) is False
assert lookups == []
mailbox._root(tmp_path).mkdir(parents=True)
assert poll("live", session) is False
assert lookups == [tmp_path]
def test_failing_mailbox_poll_backs_off_and_warns_once_per_window():
"""A failing poll is retried only after the backoff and logged at WARNING once per window."""
import logging
records = []
class _Handler(logging.Handler):
def emit(self, record):
records.append(record)
log = logging.getLogger("test.bot_poll_throttle")
log.addHandler(_Handler())
log.setLevel(logging.DEBUG)
attempts = []
def failing(sid, session):
attempts.append(sid)
raise RuntimeError("active session file lock unavailable")
guarded = rebind(session_notifications._poll_bot_live_delivery_guarded, {
"_poll_bot_live_delivery_once": failing, "logger": log,
"_BOT_POLL_FAILURE_BACKOFF_S": session_notifications._BOT_POLL_FAILURE_BACKOFF_S,
"_BOT_POLL_WARN_INTERVAL_S": session_notifications._BOT_POLL_WARN_INTERVAL_S})
session = {}
for now in (0.0, 0.5, 1.0, 6.0, 12.0, 61.0):
guarded("live", session, now)
assert len(attempts) == 4 # 0.0, 6.0, 12.0, 61.0 — the 0.5/1.0 passes sat out the backoff
warnings = [r for r in records if r.levelno == logging.WARNING]
assert [r.getMessage() for r in warnings] == [
"Bot live-owner delivery poll failed (0 repeat(s) suppressed since the last report)",
"Bot live-owner delivery poll failed (2 repeat(s) suppressed since the last report)"]
assert all(r.exc_info for r in warnings)