Skip to content

fix(bot-mode): Routines pane scopes to the clicked bot, not the stale gateway profile - #89637

Closed
chelsealong wants to merge 2 commits into
NousResearch:mainfrom
chelsealong:fix-89625-routines-selected-bot
Closed

fix(bot-mode): Routines pane scopes to the clicked bot, not the stale gateway profile#89637
chelsealong wants to merge 2 commits into
NousResearch:mainfrom
chelsealong:fix-89625-routines-selected-bot

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Fixes #89625

Bug

In Bot Mode, clicking a different bot in the roster doesn't change which
bot's cron jobs the Routines pane shows — it keeps showing the previously
active bot's jobs (e.g. the default profile's 14 jobs instead of Blog
Writer's 1) until a chat message is sent, and sometimes not even then.

Root cause

RoutinesPane (apps/desktop/src/plugins/hermes-bots/plugin.js) picked
gatewayProfile (host.state.profile, the profile the chat connection is
currently routed through) ahead of $selectedBot (the bot the user just
clicked):

const bot = (gatewayProfile || selected || 'default').trim() || 'default'

Clicking a bot in the roster sets $selectedBot synchronously, but the
gateway connection takes a moment to actually swap over — host.state.profile
still reports the previous bot during that window. Since the old code
preferred gatewayProfile, the pane queried cron.manage with the wrong
profile scope until the connection caught up (and could stay wrong if the
routing never fully switched, per the report).

Fix

Flip the precedence and extract the decision into a small pure helper:

function resolveRoutinesBot(selected, gatewayProfile) {
  return (selected || gatewayProfile || 'default').trim() || 'default'
}

RoutinesPane now calls resolveRoutinesBot(selected, gatewayProfile).

Making $selectedBot win unconditionally is only safe if $selectedBot is
actually always current — and a first version of this fix (reviewed here)
wasn't: the host.state.profile.listen(...) subscriber that keeps
$selectedBot synced to the live gateway profile is attached in register()
and torn down in onDispose(). nanostores' .listen() never replays the
current value the way .subscribe() does, so if a user disables Bot Mode,
switches the active gateway profile while it's disabled, then re-enables it,
$selectedBot stayed pointed at the pre-disable bot until the next profile
change happened to fire the listener — which, combined with the precedence
flip, would have made RoutinesPane show the wrong bot's jobs indefinitely
(the same failure class as the original bug, just moved to a longer-lived
trigger).

Fixed by extracting the sync into bindProfileSync(), which reseeds
$selectedBot from the profile store's current value before attaching the
listener:

function bindProfileSync(profileStore) {
  const current = profileStore.get?.()
  if (current && typeof current === 'string') {
    $selectedBot.set(current)
  }
  return profileStore.listen(profile => {
    if (profile && typeof profile === 'string') {
      $selectedBot.set(profile)
    }
  })
}

register() now calls bindProfileSync(host.state.profile), so every
register — including a disable → re-enable cycle — starts back in sync with
the live profile before anything can rely on $selectedBot.

Test

apps/desktop/src/plugins/hermes-bots/tests/routines-selected-bot.test.mjs
(following this suite's existing vm-harness pattern, see
routines-error.test.mjs) covers both the pure resolveRoutinesBot helper
and, separately, bindProfileSync's reseed behavior — using a fake profile
store that mirrors real nanostores get/listen semantics (listen does
not replay the current value) rather than a mock that assumes the fix works.
The new regression test drives: bind (reseed to "blog-writer") → unbind
(disable) → profile changes to "researcher" while unbound → bind again
(re-enable) → asserts $selectedBot and resolveRoutinesBot both resolve to
the live "researcher" profile, not the stale "blog-writer".

Verified the new regression tests fail without the bindProfileSync reseed
(reverting just the reseed lines, keeping the plain .listen()):

not ok - regression: re-enabling after a profile switch while disabled resyncs, not stays stale
  expected: 'blog-writer'
  actual: 'default'

And that all tests in the file fail against the pre-fix commit, where
bindProfileSync doesn't exist at all:

$ git checkout HEAD~1 -- apps/desktop/src/plugins/hermes-bots/plugin.js
$ node --test apps/desktop/src/plugins/hermes-bots/tests/routines-selected-bot.test.mjs
error: 'bindProfileSync is not defined'
# pass 0
# fail 5

All pass with the fix in place:

$ node --test apps/desktop/src/plugins/hermes-bots/tests/routines-selected-bot.test.mjs
# tests 5
# pass 5
# fail 0

Full plugin test suite (from apps/desktop):

$ npm run check:test:plugins
# tests 288
# pass 288
# fail 0

AI assistance disclosure

This change was authored with AI assistance (Claude).

… gateway profile

Clicking a bot in the roster sets $selectedBot immediately, but the chat
connection takes a moment to actually swap gateways, so host.state.profile
still reports the previous bot during that gap. RoutinesPane picked the
stale gateway profile first, so the pane kept showing the old bot's cron
jobs until the connection caught up (NousResearch#89625).

$selectedBot already tracks the live gateway profile via a listener plus
roster clicks, so it is current the instant a bot is clicked — flip the
precedence so it wins.
…eaves it stale

nanostores' .listen() never replays the current value the way .subscribe()
does, so the profile-sync listener added in the previous commit only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, and since the
previous commit made $selectedBot win over the live gateway profile
unconditionally, RoutinesPane would scope to that stale bot indefinitely.

Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.

Added a regression test that reproduces the stale-value semantics with a
fake store mirroring real nanostores listen/get behavior, and confirmed it
fails without the reseed (falls back to 'default'/stale value) and passes
with it.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) area/profiles Multi-profile isolation, HERMES_HOME scoping labels Aug 19, 2026
teknium1 pushed a commit that referenced this pull request Aug 19, 2026
…eaves it stale

nanostores' .listen() never replays the current value the way .subscribe()
does, so the $focusedBotProfile listener in register() only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, so the roster
highlight fallback and Routines scoping could start from a stale bot.

Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.

Salvaged from PR #89637 (the pane-precedence portion was superseded on main
by the $focusedBotProfile design; this residual reseed gap remained).
Regression test mimics real nanostores get/listen semantics and fails
without the reseed.

Fixes-residual-of: #89625
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #89717 (#89717) with your authorship preserved. Note: the precedence-flip half was superseded on main by 23c1c98 shortly before you opened this, but your second finding — the missing get()-before-listen reseed of the selected bot — was real and unfixed; that half is what landed, with your regression tests adapted to the new $focusedBotProfile design. Thanks for the sharp diagnosis!

@teknium1 teknium1 closed this Aug 19, 2026
lisajlau pushed a commit to lisajlau/hermes-agent that referenced this pull request Aug 20, 2026
…eaves it stale

nanostores' .listen() never replays the current value the way .subscribe()
does, so the $focusedBotProfile listener in register() only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, so the roster
highlight fallback and Routines scoping could start from a stale bot.

Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.

Salvaged from PR NousResearch#89637 (the pane-precedence portion was superseded on main
by the $focusedBotProfile design; this residual reseed gap remained).
Regression test mimics real nanostores get/listen semantics and fails
without the reseed.

Fixes-residual-of: NousResearch#89625
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bot Mode: Routines pane always shows default profile's cron jobs, not the selected bot's

3 participants