fix: clear the relay roster per sole connection, not once per below-two regime
rosterCleared was a single boolean latched the first time the peer set fell below two connections. When the sole connection a was replaced by c between ticks (still length 1) the flag stayed set, so c's gateway never received the empty-roster push and kept the stale roster. Track the id of the connection that got the clear instead and push whenever the sole connection's id differs; reset once two or more connections relay again. Review finding: routes [a] → tick → routes [c] → tick pushed no roster clear to c.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,8 +80,8 @@ interface RelayLifecycle {
|
||||
pushDebounceTimer: null | ReturnType<typeof setTimeout>
|
||||
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<typeof setInterval>
|
||||
}
|
||||
|
||||
@@ -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<string, RelayAgentRow[]>()
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user