diff --git a/apps/desktop/src/plugins/hermes-bots/relay.test.ts b/apps/desktop/src/plugins/hermes-bots/relay.test.ts index ae14216422..9c98c2d509 100644 --- a/apps/desktop/src/plugins/hermes-bots/relay.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/relay.test.ts @@ -740,4 +740,33 @@ describe('the roster loop forgets a machine that left', () => { stopBotRelay() }) + + it('clears the roster of a sole connection that replaced the previous sole one', async () => { + const calls = respondWith(call => { + if (call.method === 'profiles.list') { + return { profiles: [{ name: call.connectionId === 'a' ? 'default' : 'ops' }] } + } + + return {} + }) + + const { startBotRelay, stopBotRelay } = await loadRelay() + + startBotRelay() + await vi.advanceTimersByTimeAsync(0) + hostMock.profileRoutes = vi.fn(async () => [route('a')]) + await vi.advanceTimersByTimeAsync(60_000) + calls.length = 0 + + // a is swapped for c between ticks — still one connection, but c's + // gateway has never been told the roster is empty. + hostMock.profileRoutes = vi.fn(async () => [route('c')]) + await vi.advanceTimersByTimeAsync(60_000) + + expect(calls.filter(call => call.method === 'bot_relay.roster.sync').map(call => call.connectionId)).toEqual([ + 'c' + ]) + + stopBotRelay() + }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/relay.ts b/apps/desktop/src/plugins/hermes-bots/relay.ts index 046d450b47..75b34c4590 100644 --- a/apps/desktop/src/plugins/hermes-bots/relay.ts +++ b/apps/desktop/src/plugins/hermes-bots/relay.ts @@ -80,8 +80,8 @@ interface RelayLifecycle { pushDebounceTimer: null | ReturnType pushUnsub: (() => void) | null rosterBusy: boolean - /** The below-two-connections roster clear went out; stays set until the peer set relays again. */ - rosterCleared: boolean + /** Id of the sole connection whose roster clear went out; null once the peer set relays again. */ + rosterClearedFor: null | string rosterTimer: null | ReturnType } @@ -93,7 +93,7 @@ const relay: RelayLifecycle = { pushDebounceTimer: null, pushUnsub: null, rosterBusy: false, - rosterCleared: false, + rosterClearedFor: null, rosterTimer: null } @@ -264,10 +264,11 @@ async function syncRelayRosters() { // Nothing to relay — but the gateways that remain still hold the last // pushed roster, so a departed machine's agents would stay in every // bot's prompt (and as message_agent targets) until a second connection - // reappears. Push the now-empty roster once so they forget it. An empty - // route list (registry not loaded yet) must not spend the one clear. - if (connections.length === 1 && !relay.rosterCleared) { - relay.rosterCleared = true + // reappears. Push the now-empty roster once per sole connection so it + // forgets it — a replacement sole connection has never been told. An + // empty route list (registry not loaded yet) must not spend the clear. + if (connections.length === 1 && connections[0].id !== relay.rosterClearedFor) { + relay.rosterClearedFor = connections[0].id await Promise.all( connections.map(async connection => { try { @@ -282,7 +283,7 @@ async function syncRelayRosters() { return } - relay.rosterCleared = false + relay.rosterClearedFor = null const agentsByConnection = new Map() await Promise.all( @@ -486,7 +487,7 @@ function scheduleRelayPushDrain() { export function startBotRelay() { relay.disposed = false - relay.rosterCleared = false + relay.rosterClearedFor = null // Source-shape test harnesses evaluate plugin.js without DOM timers — // the relay only runs where a real event loop exists.