#90732 removed right-click → Sessions (one forever-chat per bot is the
product contract); #90756 cleaned the last in-app copy. This removes the
remaining docs bullet describing the dead affordance.
parseBackendOwnership returned [] for unreadable JSON and reapOrphans
unconditionally rewrote survivors — one corrupt read replaced the roster
with [], permanently orphaning every backend it described. The sweep now
detects corruption, parks the file as .corrupt (evidence preserved), and
skips the rewrite; empty/missing files keep the legacy sweep behavior.
PR #90732 removed the per-bot Sessions browser (right-click -> Sessions),
but the Disband-group confirm dialog still told users they could open the
kept 'Group: X' sessions 'from each bot's session browser' - an affordance
that no longer exists. Drop the stale clause; also update the one test
comment that still described the removed workspace.
Review follow-up from #90732 (found by the 3-angle review pass).
One conflict, gateway/relay/adapter.py send_for_platform: main added the
turn-final draft-seal interception (_sfp_metadata with the _interim_send
marker stripped, seal-or-fall-through); this branch added format-hint
stamping on the same frame. COMPOSED: the plain-send frame now stamps
_with_format_hints_for_platform over _sfp_metadata (the stripped copy),
so both the seal fall-through contract and the cron-lane block hints
hold. Note: the seal frame itself (op:draft final) does not stamp hints
— cron sends are never open drafts, so the flagship path is unaffected;
noted as a connector-PR follow-up for streamed interactive finals.
_scale_to_zero_is_idle() consumed _running_agent_count(), but cron jobs
run through a standalone AIAgent on the scheduler's own thread pool and
API-server runs live on the adapter — both outside _running_agents (the
same blind spot the #60432 shutdown-drain fix addressed with
_active_work_count()). The idle predicate therefore read True DURING a
running cron job; a suspend at that moment freezes the job mid-flight.
Observed live on staging 2026-08-20: is_idle held True throughout the
10:45:04-22 cron run — only watcher-tick timing (next tick 9s after
completion) avoided a mid-job freeze.
Use _active_work_count() (agents + cron + API runs). New tests cover a
running cron job and an active API run each blocking idle, plus the
all-quiet True case; both blocking tests fail without the fix.
seed_mock.assert_not_called() alone could pass for the wrong reason — a
harness failure before delivery also leaves the seed uncalled. Assert
the real adapter recorded exactly one live send to the origin chat, so
the test pins the D6 thread-fallback decision, not an accidental
no-delivery.
flyd mounts the local Machines API (flaps) socket at /.fly/api owned
root:root 0755, but the gateway runs as the unprivileged hermes user.
The scale-to-zero self-suspend (gateway/scale_to_zero.py suspend_self)
therefore failed every attempt with EACCES and an opted-in machine could
never sleep — fail-awake held, but the feature was inert. Verified live
on staging 2026-08-20: repeated 'flaps suspend request failed: [Errno
13] Permission denied' until a manual chgrp/chmod on the socket, after
which the same watcher tick suspended the machine cleanly (flaps 200 ->
suspension/suspended).
stage2 runs as root before the supervision tree starts: chgrp hermes +
g+w on the socket when present. Minimal widening — the socket stays
root-owned; no-op off Fly.
The #90732 adoption scan used session.list's 200-row recency window. A busy
bot profile (group-chat traffic, routines, or accumulated fork spam) pushes
an older forever-chat past row 200, the scan misses it, and the mint path
re-enters the unique-title-conflict fork loop — same pathology, higher
trigger threshold.
Profile → Named Session is an exact registry (UNIQUE title index), so
consult it exactly:
- session.list gains a `title` param: indexed WHERE title = ? lookup,
window-free, hidden rows resolve, archived/deny-listed do not,
compression lineages resolve to the live tip (resolved_id), mirroring
profiles.list's preferred_session resolver.
- findExistingCanonicalChat sends title: 'Bot Chat'. Older gateways ignore
the unknown param and return the windowed listing — the local scan stays
as the compatibility rung.
- Adoption opens the lineage tip (resolved_id) while pinning the durable id,
same split as the preferred_session path.
Unspecced MagicMock/AsyncMock adapters fabricate
supports_inchannel_continuable_for_platform as a truthy callable, so the
scheduler's duck-typed D6 gate silently took the relay accessor branch in
every in-channel test — the native scalar fallback the fixtures describe
was never exercised, and setting supports_inchannel_continuable=False on
a mock could not force thread mode. Pin the accessor to None on both
mock fixtures (matching a real native adapter, which never defines the
method), and add a fallback-boundary test with a real minimal adapter
class: scalar False -> in_channel fails safe to thread, flat seed never
fires.
`hermes update` on Windows detached on every run, including the
`Already up to date!` no-op that never touches the venv. emozilla hit
the visible half: the shim exits, PowerShell takes the console back,
and a child prints the result under a fresh prompt — it reads as a
frozen update. The invisible half is worse: the hand-off sat ahead of
the fetch, so it also carried off the stash and branch-switch
questions, which #90205 then had to answer by closing stdin. Nobody
who mods Hermes got asked about their local changes again.
The shim lock is real and the child is still required — a launcher
holds venv\Scripts\hermes.exe open without FILE_SHARE_DELETE for the
whole command, so the quarantine rename is refused and uv fails with
os error 32. A parent that waits deadlocks against the handle it is
itself holding, and Windows has no exec to escape with.
But that lock only binds one step. Move the hand-off to the dependency
sync boundary, beside the native-module deferral that solves the same
"this process holds a file the sync must replace" problem — and for
the reason that placement already exists (#86735: a preflight ahead of
the fetch re-bricked the flow it was meant to protect). Everything
before the sync now runs foreground in the user's console: the
preflight, the stash question, the branch switch, git pull. An
up-to-date run never hands off at all.
Deferring to the next launch cannot substitute here the way it does
for a mapped .pyd: every future `hermes` launch is also the shim, so
the marker would defer forever. The child re-runs the update to keep
the node/web/lazy-refresh tail, and takes the sync it was spawned for
rather than the up-to-date early return.
The seed-key fix made the SESSION scoped, but the delivery leg still
dropped the scope: cron route_metadata carried only job_id (+thread),
DeliveryRouter stamps scope_id only for the configured HOME channel, and
the RelayAdapter's per-chat scope cache is cold after a gateway restart
(learned from inbound only). A scoped Slack origin that is not the home
chat therefore egressed with NO tenant discriminator, and the connector's
fail-closed guard could reject the brief before delivery — the
delivery-leg sibling of the seed-key scope gap.
Copy origin.scope_id into the live text and media routing metadata for
ORIGIN-MATCHING targets only (setdefault — never overrides router/home
stamping). Fan-out/broadcast targets are excluded by the origin gate: a
fan-out target's tenant is not the origin's, and a wrong scope is worse
than none.
Tests: restart-shaped positive (scoped non-home origin -> scope_id on
routed metadata, RED before this fix) and legacy negative (scope-less
origin stamps nothing).
Symptom: switching between bots forked a brand-new "Bot Chat" for the
returned-to bot on EVERY switch, burying the user's real forever-chat
(one report: a 930-message chat displaced by 7 forks in one morning).
Root cause is a self-perpetuating loop between three parties:
- state.db enforces UNIQUE(title): the first fork permanently squats
the "Bot Chat" title; every later mint's title request is silently
dropped by set_session_title (returns 0, no error to the caller).
- The post-turn LLM auto-titler then names the untitled fork from its
kickoff content ("Assistant introduction request #2", ...).
- openBotCanonicalChat's identity check is title-string matching, so
the renamed fork reads as "not plumbing" -> corrupted metadata ->
clear pin -> mint again. Grandfathered pre-convention chats (real
history, derived titles) hit the same branch and are forked away
from immediately.
Fix, two invariants:
1. Adopt-before-mint: createCanonicalChat first scans the profile via
session.list include_hidden:true for an existing "Bot Chat" row and
re-pins it instead of creating. The UNIQUE index makes this an exact
registry lookup (at most one match), not a heuristic. Older gateways
without include_hidden find nothing and fall through to mint.
2. A pin that resolves to a NON-plumbing session carrying real history
is the user's conversation - keep it and open it (title drift is
metadata damage, not ownership loss). Only a pin resolving to an
EMPTY stray draft is treated as corrupted and replaced (which now
goes through adoption first).
Also removes the right-click -> Sessions per-bot stored-session browser
(ProfileSessionsWorkspace and its atoms/query/rows). Bot Mode's product
contract is ONE forever-chat per bot; a browser listing every hidden
plumbing session contradicts that and confused users into opening dead
forks. The Sessions workspace test goes with it; the include_hidden
source-shape test now pins the adoption scan instead, and a new suite
(canonical-chat-adopt-before-mint.test.mjs) covers both invariants plus
the older-gateway fallback.
An unset checkbox's `.value` is "on" per the HTML spec, and the inventory read
the value before the state — so a ticked box and an empty one both came back as
`value: "on"`, and the agent had no way to tell them apart. Anything built on
reading a form back ("is the newsletter box already checked?") was answering
from a coin flip.
The state check now runs first, gated on the input's type rather than on
`checked` being defined — it is defined, as false, on every input including
text fields, which is what made the original ordering look reasonable. ARIA
checkboxes and switches built out of divs read `aria-checked`, which the old
branch would have missed anyway since a div has no `.checked`.
Found by putting the extracted helper under a direct test. It was unreachable
before: the only way to observe it was to run a whole action against a whole
document.
act-in-page.ts had grown to 1,054 lines: one function holding twenty-eight
closures, of which roughly four hundred lines were pure string and geometry
work that never touched the holder, the action, or any page state. None of it
could be tested. `slug`, `affinity`, `coin` and the rest were reachable only by
running a whole action against a whole document and inferring what they must
have done.
Four modules now, at the seams the dependency graph actually has:
types.ts the six shared interfaces, re-exported from act-in-page.ts so
no import site anywhere changes
naming.ts what an element is called — labels, slugs, stems, anchors
visibility.ts whether it is really there and whether it is on screen
identity.ts the re-bind ladder and handle minting
Each is a factory rather than a bag of exports, and that shape is load-bearing
rather than taste. These sources are stringified into the guest page, where
module scope does not exist; separate exports would be separate names for the
bundler to mangle independently of the call sites inside the stringified core.
A factory that stringifies whole has no cross-module reference to break. The
core takes the kits as a parameter for the same reason — it names nothing it
did not receive.
That failure mode deserves spelling out, because it is why this is not a plain
import. The renderer minifies. An imported binding referenced inside the core
would be renamed to something the injected bundle never declares, and it would
break in packaged builds ONLY — green in dev, green under vitest, broken for
users. `actEngineSource()` is now the single supported way to obtain the
source, so a partial injection is not something a caller can express.
The self-containment test moves with it, and gets stronger: it evaluates the
assembled bundle rather than one function, which is what the guest actually
receives. It caught the contract change on the first run.
Bodies moved unchanged; the only edits are closure captures becoming
parameters, and `coin` taking the counter it used to read off the holder. The
engine is 634 lines, all of it orchestration. The 53 existing engine tests pass
untouched, which is the evidence the move preserved behaviour, and 27 new tests
exercise the extracted helpers directly for the first time.
Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.
Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.
Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.
The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.
Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.
Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
Driving someone's browser invisibly is unnerving, and this browser is the one
they are signed into. The pane now draws the field the agent can reach, a box
round what it is touching, a cursor that goes there, and a wipe over text it
just read — one cursor primitive and one mark primitive, in a closed shadow
root so the agent's own inventory cannot see them. Marks carry the same handle
the agent addresses them by, so the word on screen and the word in the
transcript are the same string.
The point is supervision rather than decoration: a person glancing at the pane
can tell what is about to happen to their live session, and stop it.
It also has to cover the waiting. The agent flashes through a click in under a
second and then sits idle for the twenty to a hundred seconds the model spends
deciding what to do next, which is most of the wall clock of any task — so the
surface used to look broken during the part where it was working hardest. A
think stage runs off the $busy edge, sparsely flashing elements from the field
the last action left behind, and rest stops it. It guards itself: started
before there is an overlay or a field, it idles until there is one, so it can
be raised on the turn boundary without knowing whether anything has been
inventoried yet.
read_preview had the same hole from the other side. Reading is the cheapest
thing the agent does — hundredths of a second between two model round trips —
so paging through a document left the pane dark for twenty seconds immediately
after the one moment that showed anything. It draws a top-to-bottom wipe over
the text it took, and that is the one stage allowed to be a wipe: reading is
the only thing the agent does to a page in an order a person could follow.
Both go through preview-nudge, which says a single stage to an overlay the page
already has rather than re-shipping the engine to narrate. On a page the agent
never acted on it is a no-op, which is the honest answer — chrome there would
be a lie about what it did.
Everything respects prefers-reduced-motion.
A dispatched MouseEvent is untrusted, so hover menus never opened and any
control that gates on isTrusted ignored it. The pane now sends input through
the webview itself: the pointer travels to its target and the page cannot tell
it from a hand.
The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.
Four pieces, and they only make sense together:
· an in-page engine that inventories what is interactable and performs the
verb, injected as source because it has to run inside the guest page;
· the preview.act.request bridge from the gateway into the pane;
· drive_preview, for acting: elements, click, type, scroll, press, and the
pane's own back/forward/reload;
· annotate_preview, for marking without acting.
Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.
Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.
Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
#84327 excluded _spawn_supervised's permanent watchers (session-expiry,
kanban, reconnect, the scale-to-zero watcher itself, ...) from
_scale_to_zero_has_live_background_work() via a _hermes_supervised_watcher
tag, because counting them made an armed gateway consider itself busy
forever and never go dormant.
Two more permanent, infinite-loop tasks are added to _background_tasks
OUTSIDE _spawn_supervised and were untagged:
- _loop_heartbeat_task (loop_heartbeat_forever, #66892): a `while True`
loop started unconditionally in start() on every gateway boot. Extracted
the inline spawn block into _start_loop_heartbeat_task() so it's
independently testable, matching the existing _start_heartbeat_poller()
pattern.
- _heartbeat_poll_task (_poll_loop in _start_heartbeat_poller): also a
`while True` loop, started the first time a session registers a
heartbeat watch, and then permanent for the rest of the process.
Because _loop_heartbeat_task starts on every boot, it alone made
_scale_to_zero_has_live_background_work() return True forever on every
armed instance, regardless of the #84327 fix -- confirmed empirically
against the real method with the exact untagged-task shape this task has.
Two new regression tests spawn each task through its real production
entry point and assert the busy check returns False; both fail against
the unfixed code (missing method / real assertion failure).
Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
test_flat_key_wins_over_subblock asserted the OPPOSITE of its name (the
sub-block wins, matching _relay_slack_extra). Rename to what it proves.
Also note in _resolve_cron_surface_mode why its fallback differs from
_relay_slack_extra's all-or-nothing sub-dict: the flat key is the legacy
staging shape, and a flat knob applies to every fronted platform, gated
only by the per-platform D6 capability check.
_cron_mirror_delivery_enabled still promised 'cron deliveries live only
in the cron job's own session' as the unconditional default, but the
in_channel continuable surface now seeds the target session regardless
of attach_to_session/cron.mirror_delivery (the seed IS the continuation
feature, and in_channel is itself opt-in). State the carve-out where the
guarantee is documented.
RelayAdapter.supports_inchannel_continuable is a scalar adopted from the
PRIMARY identity's handshake descriptor, but one RelayAdapter fronts N
platforms and the connector advertises the bit per platform. Reading the
scalar for every logical platform both leaked a Slack-primary True onto
other fronted platforms (activating the flat surface their descriptor
never advertised) and suppressed a non-primary platform's advertised
True (forcing thread mode on capable Slack behind a Discord primary).
Add supports_inchannel_continuable_for_platform(platform): resolves the
platform's own negotiated descriptor via descriptor_for_platform (the
same Phase 1.5 seam max_message_length uses), scalar fallback only when
the per-platform descriptor is unavailable. The scheduler's D6 gate
prefers the query when the adapter provides it; native adapters keep
the class-attribute path byte-identically.
Tests: two-platform descriptor matrix (primary-True no-leak,
non-primary-True honored, unknown-platform scalar fallback).
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.
- $groupMainTabsRev atom shadows tab-map membership; all mutations go
through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
desktops without the main-window door (and a throwing door) still get
the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
(fails on the old ordering — sabotage-verified), and rev bumps on tab
open/close.
Two gaps in the block-formatting hint stamping:
1. Wrong descriptor: _format_hints gated on self.descriptor — the PRIMARY
identity's scalar — while one RelayAdapter fronts N platforms. A
Slack-primary adapter stamped Slack hints onto known Discord chats; a
Discord-primary adapter suppressed hints for Slack chats whose own
negotiated descriptor advertised the bit. Resolve per destination:
send/edit use _descriptor_for_chat (the same seam max_message_length
already uses) plus the chat's logical platform for the config
sub-block; the knob lookup is now per-logical-platform
(platforms.relay.extra.<platform>.*) instead of hardwired to slack.
2. Missing lane: send_for_platform — the scheduled/persisted-home lane
(gateway/delivery.py), i.e. the CRON delivery path, the flagship
consumer of the in_channel brief — never stamped hints at all. Stamp
there too, resolving descriptor_for_platform(logical) off the
transport; the scalar descriptor is used only when it belongs to that
exact platform (fail closed).
Tests: Slack-primary/Discord-chat no-leak, Discord-primary/Slack-chat
still-stamps, send_for_platform stamps for capable platform and stays
clean for incapable — all against a two-platform negotiated-descriptor
transport. Existing single-platform suite unchanged and green.
The seed was decoupled from the mirror opt-in (in_channel is the
continuation surface regardless of attach_to_session), but the
thread-id-clearing gate above it still read mirror_this_target. With the
advertised default config (attach_to_session=false, cron.mirror_delivery
unset) and an origin carrying a real thread_id, the brief kept delivering
INTO the origin thread while the flat (thread_id=None) session got
seeded — brief and continuation surface in different places, so a plain
reply never saw it.
Flatten on the same gate as the seed: origin_target (with the existing
live_adapter_ready guard). Fan-out/broadcast targets are unaffected.
Test drives _deliver_result with a thread-carrying origin and default
knobs, asserting on the routed DeliveryTarget.thread_id — RED on the old
gate, GREEN now.
'hermes --version' (and -V) now prints the full version report — banner
version line with upstream SHA, install directory, authoritative install
method, Python and OpenAI SDK versions, and update status — making the
separate 'hermes version' subcommand redundant. The subcommand is removed.
- _startup_fast.print_fast_version_info() is now THE canonical version
printer: static lines print instantly from stdlib probes, then the
banner label, install-method resolver, and update check lazy-import
after the first line is on screen (each degrades gracefully).
- main.py _print_version_info() delegates to it (used by /version in the
CLI chat surface and the --version flag path); the old duplicate
implementation is deleted.
- hermes_cli/subcommands/version.py removed; parser wiring, subcommand
sets, console-engine extraction entry, and tests updated. Hermes
Console keeps a 'version' command wired to the shared printer.
- Termux fast paths now include update status too (previously
check_updates=False).
- Docs/i18n, CONTRIBUTING, SECURITY, and nix checks updated to
'hermes --version'.
build_session_key embeds the workspace segment (scope_id) in every Slack
dm/group/thread key, but both cron seed helpers built their SessionSource
without it: the seeded row keyed agent:main:slack:dm:<chat>:<thread> while
a real scoped reply keys agent:main:slack:dm:<team>:<chat>:<thread> — a
row no reply ever resolves to. DMs were rescued only incidentally by the
legacy-key claim-once migration; scoped channels/threads got continuation
amnesia, and identical channel ids in two workspaces could collide.
Capture HERMES_SESSION_SCOPE_ID into the cron origin (_origin_from_env —
the session-context var async_delegation already snapshots), add scope_id
to _seed_cron_thread_session/_seed_cron_channel_session, and pass the
origin's scope at all three seed call sites.
Tests: scoped dm-thread / channel-thread / flat-channel seed-vs-reply key
equality through the real build_session_key, plus a two-workspace
non-collision guard.
A GitHub-side HTTP 429 during 'hermes update' printed only
'Failed to fetch updates from origin.' — and the curl
'unable to access ... returned error: 429' shape even matched the
network-error branch, blaming the user's connection for a GitHub
outage.
- new _classify_fetch_failure(): 429/rate-limit -> 'GitHub is rate
limiting requests or having an outage — try again in 5 minutes';
5xx -> outage message with githubstatus.com; ordered BEFORE the
generic 'unable to access' network check
- both fetch-failure sites (update apply + --check) now share the
classifier via _print_fetch_failure(), and both always print the
first raw stderr line so the wire error stays diagnosable
- tests: classifier matrix + E2E against a live local HTTP server
returning 429 through real git
Fixes#89287
Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced
and re-verified on a headed Electron seat via CDP:
- Bots roster no longer shrinks during a gateway outage: source enumeration
is bounded (10s/source instead of wedging the roster IPC >30s behind a
dead dial) and a bounced remote source keeps painting its last-known
profile list (was SSH-only), so 4 bots never show as 2 mid-outage.
- Pool backend spawns that die before the child exists (forced-local spawn
of a profile that only exists on the remote) now log the failure to
desktop.log, and the profile-exists guard runs BEFORE the Starting line —
no more orphaned no-READY/no-exit spawn bursts in bundles.
- An SSH host-key change (VPS reinstall) is classified terminal like a
reauth rejection: it latches, the boot-failure overlay shows the
ssh-keygen -R guidance, and the renderer stops the infinite boot-retry
loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/
apply-config clear the latch; live-verified Retry-after-fix boots clean.
When the chosen/keyed backend fails a web_search or web_extract call
(bad key, upstream outage, 5xx, raised exception), that single call
retries on the keyless free-tier ring instead of erroring. The next
call attempts the chosen backend again — no sticky failover, no state.
Resolves the keyed half of #78984/#32159 (keyless half landed in the
ring PR).
- tools/web_tools.py: _rescue_eligible (keyed ring vendors + non-ring
backends eligible; keyless-mode calls excluded — they already walked
the ring), _rescue_search/_rescue_extract (search annotates
rescued_from + backend_error naming the original failure and the
retry-next-call semantics; extract rescues only whole-batch failures,
partial failures pass through untouched; rescue failure preserves the
ORIGINAL backend error with the rescue note appended)
- both dispatchers wrap the provider call: failure-results AND raised
exceptions rescue; ineligible paths re-raise unchanged
- web.keyless_rescue config key (default true; implicitly off when
keyless_fallback is off); docs updated
Live E2E: keyed Tavily with an invalid key 401'd and the call was
served by the real ring with the rescue annotation; a second call
re-attempted Tavily first (statelessness proven); whole-batch extract
rescue returned real page content. 13 new tests; 67 green across the
keyless suites.
Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.
- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
active backend first (detailed progress), every other eligible
registered gateway via the existing Electron fan-out (cloud rows skip),
the client LAST (its apply relaunches the app). startActiveUpdate/
requestActiveUpdate route through it whenever more than one update
target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
and a one-click 'Update desktop app' warning fires if the GUI is still
behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
memoization, nudge on/off).