feat(bot-mode): a reclaimed bot chat re-resumes itself — no stale-id error on the next send
When the gateway reaps the runtime behind the open bot chat (idle TTL, LRU cap, or the WS-orphan mass reap that killed every background bot's handle at once in the Aug 23 incident), the plugin now hears session.reclaimed and re-resumes the canonical chat immediately, instead of leaving the dead handle for the user's next send to trip over. Matched on the stored id against both claim identities; guarded by the open generation so a user action mid-re-resume wins; a failed re-resume is swallowed — the next-send recovery ladder (#92928) stays the backstop. Feature-detected on host.onEvent; disposed with the other listeners.
This commit is contained in:
@@ -14091,6 +14091,58 @@ export default {
|
||||
})
|
||||
: null
|
||||
|
||||
// Proactive reclaim refresh: when the gateway reaps the runtime behind
|
||||
// the OPEN bot chat (idle TTL, LRU cap, WS-orphan reap — the mass-reap
|
||||
// shape hits every background bot at once), re-resume the canonical
|
||||
// chat immediately instead of letting the user's next send eat the
|
||||
// stale-id error + recovery retry. Matched on the STORED id (the
|
||||
// claim's ids are stored ids; the payload carries both). Best-effort:
|
||||
// a failed re-resume (backend still down) leaves the lazy recovery on
|
||||
// next send as the backstop. Feature-detected — older shells have no
|
||||
// host.onEvent.
|
||||
const stopReclaimSync =
|
||||
typeof host.onEvent === 'function'
|
||||
? host.onEvent('session.reclaimed', event => {
|
||||
const payload = event?.payload || {}
|
||||
const stored = String(payload.stored_session_id || '')
|
||||
const claim = $openBotChat.get()
|
||||
|
||||
if (!stored || !claim) {
|
||||
return
|
||||
}
|
||||
|
||||
const owned = [claim.openedSessionId, claim.openedRegistryId].filter(Boolean)
|
||||
|
||||
if (!owned.includes(stored)) {
|
||||
return
|
||||
}
|
||||
|
||||
const bot = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get())
|
||||
|
||||
if (!bot) {
|
||||
return
|
||||
}
|
||||
|
||||
const generation = botOpenGeneration
|
||||
void openBotCanonicalChat(bot)
|
||||
.then(opened => {
|
||||
// A user action while the re-resume ran owns the center now.
|
||||
if (!opened || generation !== botOpenGeneration) {
|
||||
return
|
||||
}
|
||||
|
||||
$openBotChat.set({
|
||||
key: claim.key,
|
||||
openedRegistryId: opened.registryId,
|
||||
openedSessionId: opened.openedId
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
/* backend still down — next send recovers via the ladder */
|
||||
})
|
||||
})
|
||||
: null
|
||||
|
||||
$botsPaneVisible.set(Boolean($sidebarVisible.get()))
|
||||
$botChatFocused.set(sessionOwnsWorkspace())
|
||||
$botsHomeFronted.set(Boolean(homeVisibleStore.get()))
|
||||
@@ -14109,6 +14161,7 @@ export default {
|
||||
stopGroupSync()
|
||||
stopHomeVisibleSync()
|
||||
stopFocusSync?.()
|
||||
stopReclaimSync?.()
|
||||
$botsHomeFronted.set(false)
|
||||
closeBotsHomeWorkspace()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
// Proactive reclaim refresh (polish on top of #92928/#92901): when the
|
||||
// gateway reaps the runtime behind the OPEN bot chat (session.reclaimed —
|
||||
// idle TTL, LRU, or the mass WS-orphan reap that killed every background
|
||||
// bot's handle at once in the Aug 23 incident), the plugin re-resumes the
|
||||
// canonical chat immediately instead of letting the user's next send eat a
|
||||
// stale-id error + recovery round-trip.
|
||||
//
|
||||
// Source-shape contracts:
|
||||
// - the listener subscribes via host.onEvent('session.reclaimed'), feature-
|
||||
// detected, and is disposed with the other listeners;
|
||||
// - the match is on the STORED id against BOTH claim identities;
|
||||
// - a stale generation or missing claim/bot is a no-op;
|
||||
// - a failed re-resume is swallowed (next-send recovery ladder remains).
|
||||
|
||||
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
function reclaimBlock() {
|
||||
const start = pluginSource.indexOf("host.onEvent('session.reclaimed'")
|
||||
assert.ok(start > 0, 'reclaim listener exists')
|
||||
return pluginSource.slice(start - 400, start + 1800)
|
||||
}
|
||||
|
||||
test('listener is feature-detected and disposed', () => {
|
||||
const block = reclaimBlock()
|
||||
assert.match(block, /typeof host\.onEvent === 'function'/)
|
||||
assert.match(pluginSource, /stopReclaimSync\?\.\(\)/)
|
||||
})
|
||||
|
||||
test('match is stored-id against both claim identities', () => {
|
||||
const block = reclaimBlock()
|
||||
assert.match(block, /payload\.stored_session_id/)
|
||||
assert.match(block, /\[claim\.openedSessionId, claim\.openedRegistryId\]\.filter\(Boolean\)/)
|
||||
assert.match(block, /owned\.includes\(stored\)/)
|
||||
})
|
||||
|
||||
test('re-resume guards the open generation and refreshes both claim ids', () => {
|
||||
const block = reclaimBlock()
|
||||
assert.match(block, /generation !== botOpenGeneration/)
|
||||
assert.match(block, /openedRegistryId: opened\.registryId/)
|
||||
assert.match(block, /openedSessionId: opened\.openedId/)
|
||||
})
|
||||
|
||||
test('failed re-resume is swallowed — next-send recovery stays the backstop', () => {
|
||||
const block = reclaimBlock()
|
||||
assert.match(block, /\.catch\(\(\) => \{/)
|
||||
assert.match(block, /next send recovers via the ladder/)
|
||||
})
|
||||
Reference in New Issue
Block a user