feat(bot-mode): push-notified relay drain with poll backstop (#93091)

Cross-connection DMs were pure polling: the Desktop drains every gateway's
bot_relay outbox on a 4s interval, so each hop eats up to 4s outbound plus
4s for the reply leg (#92760 'bots reply slowly').

Emission point: the gateway's existing change watcher (_CHANGE_WATCHES in
tui_gateway/server.py). Envelopes are written by the AGENT process
(message_agent -> tools.bot_relay.enqueue_envelope), not the gateway, so no
gateway RPC is on the enqueue path and an in-process emit is impossible.
That is exactly the situation the change watcher already solves for the
pairing store (pairing.changed: 'written by a different process; the files
are the only shared signal') - so a new bot_relay.outbox.pending entry in
the existing watch table is the smallest correct diff: one cheap 1s-interval
stat probe folded into the existing 0.5s watcher tick, no new thread, no new
RPC, and _broadcast_global_event fans it to every connected WS client for
free. The signature is monotone (newest envelope mtime ever seen) so a
drain emptying outbox/ never re-fires the event.

Desktop (hermes-bots plugin): subscribe via the existing host.onEvent tap
(feature-detected - older shells lack it) and run drainRelayOutboxes through
a 250ms trailing debounce so a burst of signals collapses to one drain.
The 4s interval poll is intentionally UNCHANGED as the backstop: the event
tap only hears the active gateway socket, so per-connection push detection
would be complex and wrong to trade the poll against - push simply makes
the common case near-instant while older backends keep working exactly as
before.

Tests: 3 new watcher contracts (fires on enqueue, monotone across drain,
silent with no outbox) and a new relay-push-drain.test.mjs (debounce burst
-> one drain, re-arm after window, disposed no-op, poll backstop intact).
This commit is contained in:
kshitijk4poor
2026-08-24 00:28:41 +05:30
parent 2eaa863112
commit 9c829f965d
4 changed files with 211 additions and 0 deletions
@@ -1376,11 +1376,18 @@ function handleSessionsGatewayTransition() {
// relay degrades to whatever subset of connections supports it.
const RELAY_ROSTER_INTERVAL_MS = 60_000
const RELAY_DRAIN_INTERVAL_MS = 4_000
// Push path (#93091): the gateway broadcasts `bot_relay.outbox.pending` when
// an envelope lands on disk; a burst of signals inside this window collapses
// to ONE drain. The interval poll above stays as the backstop for older
// backends (and connections whose events don't reach the tap).
const RELAY_PUSH_DEBOUNCE_MS = 250
let relayDisposed = false
let relayRosterTimer = null
let relayDrainTimer = null
let relayRosterBusy = false
let relayDrainBusy = false
let relayPushUnsub = null
let relayPushDebounceTimer = null
/** One representative route per reachable connection id. */
async function relayConnections() {
@@ -1578,6 +1585,23 @@ async function drainRelayOutboxes() {
}
}
/** Push-notified drain (#93091): collapse a burst of pending signals into
* one drain call ~RELAY_PUSH_DEBOUNCE_MS after the first signal. */
function scheduleRelayPushDrain() {
if (relayDisposed || typeof setTimeout !== 'function') {
return
}
if (relayPushDebounceTimer !== null) {
return
}
relayPushDebounceTimer = setTimeout(() => {
relayPushDebounceTimer = null
void drainRelayOutboxes()
}, RELAY_PUSH_DEBOUNCE_MS)
}
function startBotRelay() {
relayDisposed = false
@@ -1595,6 +1619,14 @@ function startBotRelay() {
if (relayDrainTimer === null) {
relayDrainTimer = setInterval(() => void drainRelayOutboxes(), RELAY_DRAIN_INTERVAL_MS)
}
// Push path: the gateway change watcher broadcasts when an envelope hits
// the outbox; drain immediately (debounced) instead of waiting the poll
// out. Feature-detected — older shells have no host.onEvent — and the 4s
// poll above stays untouched as the backstop either way.
if (relayPushUnsub === null && typeof host.onEvent === 'function') {
relayPushUnsub = host.onEvent('bot_relay.outbox.pending', () => scheduleRelayPushDrain())
}
}
function stopBotRelay() {
@@ -1609,6 +1641,20 @@ function stopBotRelay() {
clearInterval(relayDrainTimer)
relayDrainTimer = null
}
if (relayPushDebounceTimer !== null) {
clearTimeout(relayPushDebounceTimer)
relayPushDebounceTimer = null
}
if (relayPushUnsub !== null) {
try {
relayPushUnsub()
} catch {
// Disposer from an older shell shape — never break teardown.
}
relayPushUnsub = null
}
}
/** Per-bot appearance + display meta, persisted via ctx.storage:
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
// Push-notified relay drain (#93091, motivated by #92760 slow replies): the
// gateway broadcasts `bot_relay.outbox.pending` when an envelope lands in the
// outbox; the plugin drains immediately instead of waiting out the 4s poll.
// Contracts:
// - a burst of signals inside RELAY_PUSH_DEBOUNCE_MS collapses to ONE drain;
// - after the debounce fires, a later signal schedules a fresh drain;
// - the subscription is feature-detected (host.onEvent) and disposed, and the
// 4s interval poll remains untouched as the backstop.
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function loadScheduler() {
const start = pluginSource.indexOf('function scheduleRelayPushDrain(')
const end = pluginSource.indexOf('function startBotRelay(', start)
assert.ok(start > 0, 'plugin defines scheduleRelayPushDrain')
assert.ok(end > start, 'scheduler has a stable source boundary')
const context = {
setTimeout,
clearTimeout,
relayDisposed: false,
relayPushDebounceTimer: null,
RELAY_PUSH_DEBOUNCE_MS: 50, // shrunk from 250ms to keep the test fast
drainCalls: 0
}
vm.createContext(context)
vm.runInContext(
`async function drainRelayOutboxes() { drainCalls += 1 }\n${pluginSource.slice(start, end)}\nglobalThis.scheduleRelayPushDrain = scheduleRelayPushDrain`,
context
)
return context
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
test('a burst of pending signals collapses to one drain', async () => {
const ctx = loadScheduler()
for (let i = 0; i < 6; i += 1) {
ctx.scheduleRelayPushDrain()
}
assert.equal(ctx.drainCalls, 0, 'drain waits for the debounce window')
await sleep(120)
assert.equal(ctx.drainCalls, 1, 'six signals inside the window → one drain')
})
test('a signal after the window fires a fresh drain', async () => {
const ctx = loadScheduler()
ctx.scheduleRelayPushDrain()
await sleep(120)
assert.equal(ctx.drainCalls, 1)
ctx.scheduleRelayPushDrain()
ctx.scheduleRelayPushDrain()
await sleep(120)
assert.equal(ctx.drainCalls, 2)
})
test('a disposed relay never schedules a drain', async () => {
const ctx = loadScheduler()
ctx.relayDisposed = true
ctx.scheduleRelayPushDrain()
await sleep(120)
assert.equal(ctx.drainCalls, 0)
})
test('subscription is feature-detected, disposed, and the poll backstop stays', () => {
const start = pluginSource.indexOf("host.onEvent('bot_relay.outbox.pending'")
assert.ok(start > 0, 'plugin subscribes to bot_relay.outbox.pending')
const block = pluginSource.slice(start - 400, start + 400)
assert.match(block, /typeof host\.onEvent === 'function'/)
assert.match(pluginSource, /relayPushUnsub\(\)/)
// The 4s interval poll is untouched — push is an accelerator, not a
// replacement (older backends never emit the event).
assert.match(pluginSource, /RELAY_DRAIN_INTERVAL_MS = 4_000/)
assert.match(pluginSource, /setInterval\(\(\) => void drainRelayOutboxes\(\), RELAY_DRAIN_INTERVAL_MS\)/)
})
+41
View File
@@ -24,6 +24,7 @@ def watcher_home(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_change_sigs", {})
monkeypatch.setattr(server, "_change_checked_at", {})
monkeypatch.setattr(server, "_change_broadcast_at", {})
monkeypatch.setattr(server, "_bot_relay_outbox_seen", 0)
events = []
monkeypatch.setattr(
@@ -177,6 +178,46 @@ def test_renderable_pet_broadcasts_meta_payload(watcher_home, monkeypatch):
assert payload["spritesheetRevision"]
def test_enqueued_envelope_broadcasts_outbox_pending(watcher_home):
"""A cross-connection envelope written by the agent process must reach the
Desktop's push-triggered drain on its own signal (#93091) — the drain poll
is the backstop, not the transport."""
home, events = watcher_home
outbox = home / "bot_relay" / "outbox"
outbox.mkdir(parents=True)
server._broadcast_watched_changes(now=0.0)
(outbox / ("a" * 32 + ".json")).write_text('{"id": "' + "a" * 32 + '"}')
server._broadcast_watched_changes(now=10.0)
assert ("bot_relay.outbox.pending", {}) in events
def test_drained_outbox_does_not_rebroadcast_pending(watcher_home):
"""Signature is monotone: a drain empties outbox/ (rename → claimed/), and
that emptying must NOT look like a change — only new envelopes fire."""
home, events = watcher_home
outbox = home / "bot_relay" / "outbox"
outbox.mkdir(parents=True)
envelope = outbox / ("b" * 32 + ".json")
envelope.write_text("{}")
server._broadcast_watched_changes(now=0.0)
envelope.unlink() # the Desktop drained it
server._broadcast_watched_changes(now=10.0)
server._broadcast_watched_changes(now=20.0)
assert not [e for e in events if e[0] == "bot_relay.outbox.pending"]
def test_no_outbox_dir_never_fires_pending(watcher_home):
home, events = watcher_home
server._broadcast_watched_changes(now=0.0)
server._broadcast_watched_changes(now=10.0)
assert not [e for e in events if e[0] == "bot_relay.outbox.pending"]
def test_broken_probe_never_kills_the_pass(watcher_home, monkeypatch):
home, events = watcher_home
server._broadcast_watched_changes(now=0.0)
+39
View File
@@ -3936,6 +3936,42 @@ def _pairing_sig():
return sig
# Newest outbox-envelope mtime the watcher has EVER seen (monotone). A drain
# empties the outbox (rename → claimed/), and letting the signature fall back
# to None on empty would fire a spurious pending event right after every
# drain — so the signature only moves forward, on genuinely new envelopes.
_bot_relay_outbox_seen = 0
def _bot_relay_outbox_sig():
"""Newest mtime across pending bot-relay outbox envelopes (monotone).
Envelopes are written by the AGENT process (``message_agent`` →
``tools.bot_relay.enqueue_envelope``) — a different process that never
touches this gateway's transports — so the files are the only shared
signal, exactly like the pairing store. The Desktop reacts to
``bot_relay.outbox.pending`` with an immediate (debounced) drain instead
of waiting out its poll interval (#93091, motivated by #92760).
"""
global _bot_relay_outbox_seen
home = _watcher_home()
root = home.parent.parent if home.parent.name == "profiles" else home
newest = 0
try:
for entry in (root / "bot_relay" / "outbox").iterdir():
if not entry.name.endswith(".json"):
continue
try:
newest = max(newest, entry.stat().st_mtime_ns)
except OSError:
continue
except OSError:
pass
if newest > _bot_relay_outbox_seen:
_bot_relay_outbox_seen = newest
return _bot_relay_outbox_seen or None
# Watched change signals: event → (check interval, signature fn, payload fn).
# Signatures are stat/dict-lookup cheap, same bar as the skin watcher; the
# check interval keeps the pricier probes (pet resolves the active sheet off
@@ -3946,6 +3982,9 @@ _CHANGE_WATCHES: dict[str, tuple[float, Any, Any]] = {
"sessions.changed": (0.5, _sessions_sig, lambda: {}),
"platforms.changed": (2.0, _platforms_sig, lambda: {}),
"pairing.changed": (2.0, _pairing_sig, lambda: {}),
# Cross-connection DM latency: 1s check so a queued envelope reaches the
# Desktop's push-triggered drain fast; the Desktop's poll stays backstop.
"bot_relay.outbox.pending": (1.0, _bot_relay_outbox_sig, lambda: {}),
}
# state.db moves on every message append during a streaming turn, and the