From d5281f59819d2ea2ce6754faec2ce317c92366c8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:54:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(bot-mode):=20a=20reclaimed=20bot=20chat=20?= =?UTF-8?q?re-resumes=20itself=20=E2=80=94=20no=20stale-id=20error=20on=20?= =?UTF-8?q?the=20next=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../desktop/src/plugins/hermes-bots/plugin.js | 53 +++++++++++++++++++ .../tests/reclaim-proactive-refresh.test.mjs | 51 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/reclaim-proactive-refresh.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 491ce0bbcc..290173a440 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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() }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/reclaim-proactive-refresh.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/reclaim-proactive-refresh.test.mjs new file mode 100644 index 0000000000..2629724f0b --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/reclaim-proactive-refresh.test.mjs @@ -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/) +})