Repository navigation
Conversation
The re-bind mechanism itself looks sound: lineage-matching via Points to consider:
Minor: |
|
Good catches — addressed in
3/3 tests green on base |
953806a to
8addac0
Compare
|
Follow-up: rebased onto current upstream main ( |
A mid-conversation model/provider switch rebuilds the agent runtime. The rebuilt runtime emits session.info (and all later events) under a NEW explicit session_id while the pane still holds the dead one as its active id — isActiveEvent is false for the same conversation from that moment on, so view-scoped updates stop and the chat freezes until a full resume (NousResearch#93942 scenario B; backend even logs 'client should resume the stored session', but the client never does). Fix: when a session.info event lineage-matches the selected conversation (sessionMatchesStoredId over stored_session_id) but carries a different runtime id, adopt the new runtime id as the active session id — keeping the durable selection untouched — so every subsequent isActiveEvent gate keeps matching without a resume. Guarded: the old runtime must show no live turn (not busy/awaiting/streaming) or the adoption is refused, so an overlapping manual switch can never split one conversation across two panes. The existing compression-rotation path does not cover this case: it fires when the SAME runtime's stored id rotates, while a rebuild produces a NEW runtime with a NEW stored id. Together with NousResearch#94255 (tile reconcile on sessions.changed), closes NousResearch#93942. Regression tests verified failing pre-fix on 41447a6.
Enough1122 review points on NousResearch#94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
8addac0 to
3faf39f
Compare
|
Root-caused the red and fixed by rebase: the failing check is the known |
Enough1122 review points on #94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
Enough1122 review points on #94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
* fix(desktop): bound the boot descriptor wait so a dead primary cannot strand the registry restore
Follow-up to the #95007 partial cherry: waitForInitialConnection() was
an unbounded listen on $connection — a primary that never publishes its
descriptor (spawn failure, dead SSH target) would strand
initializeConnectionsRegistry() forever and the last-used source would
never be restored. Bound it with the codebase's withTimeout helper
(same pattern as the sibling SWITCH_* call sites in this file): after
45s (the primary spawn budget) the restore proceeds exactly as it did
before the wait existed, and the listener is torn down either way.
Regression test: boot restore proceeds after the deadline with the
descriptor never arriving.
Original-PR: #95007
* fix(desktop): stamp remote list rows with their owning connection; retry one transient projects.tree loss
Partial cherry-pick of PR #94901 (joe-rodgers). Surviving scope:
- api/sessions: stampActiveConnectionOwner — rows returned by the
active non-local gateway are stamped with its registry connection_id
(explicit owners from multi-source responses preserved), so a later
resume cannot fall back to a same-named local profile.
- store/projects: one-shot projects.tree retry when a remote source
switch leaves the first read RPC on a newly-opened socket without a
response (request timed out / gateway connection closed), only while
the same gateway/profile is still foreground. Component fix for the
live-confirmed #92352 sidebar-never-paints gap.
Dropped scope (superseded on main / by the #94656 anchor landed just
below): knownSessionOwner+SessionOwnerScope rewiring in session.ts,
session-states.ts, wiring.tsx (main and #94656 carry richer variants),
and the $connection-derived optimistic-row stamp in
use-session-actions/utils.ts (#94656 stamps the optimistic row from
the captured exact owner route instead of ambient state).
Original-PR: #94901
Dropped-scope: routing half of 2cb5bdbf1 (session.ts, session-states.ts, wiring.tsx, use-session-actions/utils.ts hunks)
* refactor(desktop): one canonical write shape for connection_id row stamping
Reconcile #94901's API-layer row stamping with #94656's durable-owner
persistence: extract lib/session-owner-stamp.ts as THE canonical
stamp-untagged-rows write path (never clobbers an explicit owner,
never stamps `local`) and re-express api/sessions'
stampActiveConnectionOwner through it. #94656's writers (optimistic
row from the captured owner route, mergeSessionPage carry, cache
patch) are exact-owner writers and stay as-is; the helper's contract
documents why it must not overwrite them.
Credit: row-stamping concept from PR #94901 (joe-rodgers) and
PR #95007 (weismanfamily); persistence shape from PR #94656
(Zeus-Deus).
Co-authored-by: joe-rodgers <25499388+joe-rodgers@users.noreply.github.com>
* chore: map contributor email for attribution gate
* style(desktop): sort registrySourceOwnsPrimaryBackend imports (lint)
* fmt(js): `npm run fix` on merge (#95457)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(cron): mirror continuable-cron briefs for origin-fallback and opted-in explicit targets
A managed cron (created by a provisioning script, not from a live gateway
chat) never captures an origin. With cron.mirror_delivery: true and
deliver: origin, its brief was delivered to the home channel — the
user's own DM — but the transcript mirror and the in_channel session
seed were silently skipped: _target_matches_origin returns False for an
empty origin, and the whole continuable machinery keys off that check.
A user replying to the brief landed in a session with no record of it.
Field report 2026-08-17 (enterprise, Slack DM surface).
The June origin-scoping refactor (c06ceb3232) was written to exclude
broadcasts, and the exclusion is kept. What changes is the
classification: a home-channel FALLBACK for deliver=origin is the user's
primary conversation standing in for the origin, not a broadcast.
Changes:
- Delivery targets carry a resolution-provenance tag (_resolved_from:
origin / origin_fallback / explicit; broadcast expansions untagged).
- _target_mirror_eligible replaces the bare origin check at the mirror
gate: origin unchanged; origin_fallback eligible under the same flags
as origin (per-job attach_to_session wins, else global
cron.mirror_delivery); explicit platform:chat targets eligible ONLY
under per-job attach_to_session — the global flag never activates
them, so it cannot start writing transcript entries into arbitrary
explicitly-addressed chats. 'all'/bare-platform stay never-eligible.
- Dedup OR-merges provenance so 'origin,all' resolving to the same chat
keeps eligibility regardless of token order.
- _inchannel_seed_allowed guards the flat-session seed: group-channel
session keys are user-isolated, so a seed without a user_id (origin-
less job into a shared channel) would create an orphan session no
reply resolves to — those targets fall back to the plain mirror. DM
targets (keys don't embed user_id) always seed.
- cronjob tool schema text updated to describe the new attach scope.
Behavioral note: origin-less deliver=origin jobs under global
mirror_delivery now activate the full continuable path — on default
'thread' surface this opens a dedicated thread in the home channel
where the brief previously posted flat. That is the documented
continuable behavior; the silent flat post was the bug.
15 new tests (tests/cron/test_mirror_origin_fallback.py): eligibility
matrix (origin/fallback/explicit/all/bare/other-chat), dedup order
both ways, end-to-end mirror via _deliver_result for all four shapes,
origin regression control, seed user_id guard.
* test: extend exact-shape assertion in relay-delivery guard for provenance tag
tests/cron/test_cron_relay_delivery_guards.py landed on main after #89329
branched; its exact-dict assertion needs the new _resolved_from field the
salvaged commit adds to origin-resolved targets.
* fix(cron): unify in_channel flatten and seed behind one continuable gate
Review finding: the thread-flatten stayed gated on origin_target while the
seed gained fallback/explicit eligibility — a threaded origin_fallback or
opted-in explicit target would deliver into the thread while the seed
created the flat session (the exact split-surface drift the flatten
comment warns about). One shared inchannel_continuable gate now drives
both, with _inchannel_seed_allowed folded in; is_dm_target hoisted above
the flatten and deduplicated.
* docs(cron): describe widened continuable scope (origin fallback + explicit opt-in)
The feature page still said 'only the origin chat is ever touched', which
this change makes stale. Documents the three attach-eligible shapes and
that the global flag never activates explicit targets (review nit).
* refactor(cron): fold simplify-review findings into mirror eligibility
- _target_mirror_eligible accepts a precomputed origin_match so the sole
production caller stops re-resolving origin + re-running the origin
match it computed one line earlier (tests keep the self-contained path).
- Document why the fallback branch restates _cron_mirror_delivery_enabled
precedence (standalone correctness: per-job False must beat raw global
True) instead of collapsing it to the call-site-coupled 'return True'.
- Retarget the stale in_channel warn branch from 'not origin_target' to
'not inchannel_continuable' and reword it for the widened seed scope.
* chore: map kshitijkapoorr@gmail.com to kshitijk4poor in contributors/emails
* fix(cron): forward attach_to_session through cronjob handler
The public schema and job store already support per-job
attach_to_session, but the registry adapter dropped the argument.
Create silently omitted the field; update reported "No updates provided."
Fixes #84802
* test: assert omitted attach_to_session stays absent from formatted list output
Closes the gap flagged in review: the raw store was checked but not the
_format_job surface.
* fix(desktop): upgrade Electron past popup sandbox advisory
* chore: mailmap kshitijkapoorr@gmail.com to kshitijk4poor's canonical noreply
Six commits merged via #95366/#95367 carry an incorrect author email
(kshitijkapoorr@gmail.com — not an address the contributor owns; it was
set by tooling error during salvage). The address maps to no GitHub
account (verified via the users search API). Canonicalize it to the real
identity so shortlog/contributor tooling attributes correctly; the
contributors/emails mapping file from the same PRs already covers
release attribution.
* fix(dashboard): spawn detached actions from the install's venv interpreter
Under an SSH remote backend the web server is launched by running the uv
BASE interpreter with the venv's site-packages injected into sys.path at
startup, so sys.executable is a dependency-less python. Detached
dashboard actions spawned from it (Update now, restart, anything routed
through _spawn_hermes_action) inherited neither the injected path nor a
PYTHONPATH and died on the first third-party import — 'Update now'
always failed instantly with ModuleNotFoundError: No module named 'yaml'
while 'hermes update' from the venv worked (#90026).
_dashboard_spawn_executable now prefers the install's own venv
interpreter (venv/bin/python, venv/Scripts/python.exe) when it differs
from sys.executable, resolving the same dependency set the venv launcher
provides. Same-interpreter launches return sys.executable unchanged,
preserving the Windows console-ownership behavior verbatim, and layouts
without an install venv keep the old fallback.
* fix(dashboard): compare and spawn the venv interpreter by UNRESOLVED path
Follow-up on the cherry-picked #90030: candidate.resolve() breaks the fix
on the standard Linux venv layout, where venv/bin/python is a symlink to
the base interpreter. Resolving makes the venv python compare equal to
the dependency-less base (so the swap never happens), and returning the
resolved target would spawn the bare base interpreter, bypassing
pyvenv.cfg — the fix would silently not fix #90026 on the exact platform
it was reported from. Compare and return normalized UNRESOLVED paths:
the venv path IS the interpreter's identity. Adds the symlink-layout
regression test; live-E2E'd with a real dependency-less base runtime.
* fix(desktop): a canonical-title race adopts the winner instead of forking the forever chat (#92473, part 2)
Between the registry miss and the eager session.title write, another
writer can take the canonical title (peer dm minting server-side, a
second machine, cross-connection sync). UNIQUE(title) rejects our write
with 'already in use' — which the compat path previously read as 'old
gateway' and prompted into OUR stray lazy session, forking the forever
chat. A uniqueness rejection now re-consults the registry and adopts the
winner; the zero-message stray is abandoned to the gateway pruner.
Genuine old-gateway failures (unknown method) keep the compat kickoff.
* test(computer-use): serialize Windows screenshot paths
* fix(desktop): drop generated duplicate plugin entries
* fix: preserve macOS TCC identity for managed Python
* fix(macos): keep the TCC anchor alive across CVE-repair rotations + sign anchor copies
Integration fixups so #82529's generation signing and #95131's interpreter
anchor cover each other's gaps (without these, each fix leaves the other's
rotation path broken):
- macos_tcc_anchor store detection now recognizes .hermes-runtime/python/
generation-* stores: repair_vulnerable_runtime() rebuilds the venv against
a generation interpreter, replacing the anchored bin/python with a fresh
symlink — previously the anchor then read 'not uv-managed' and NEVER
re-anchored, so every SQLite CVE repair silently orphaned terminal TCC
grants (the exact #82427 scenario, path-keyed).
- _install_anchor signs the anchor copy with the same identifier-pinned DR
(via managed_uv._macos_sign_managed_python) before it goes live: copy2
carries the source build's cdhash-based signature, so an unsigned refresh
would still change the stored csreq on every patch bump/repair despite
the stable path. Best-effort, never blocks the anchor.
- Tests: generation-store recognition + repair-generation anchoring +
sign-on-install call (sabotage-verified: dropping the generation root
marker fails both new tests).
* chore: map contributor email for notkisk
* fix(checkpoints): stop safe restore deleting files the size cap excluded
`/rollback <N>` runs `restore(..., safe=True)` — safe mode is the default,
`--all` opts out. Safe mode splits the changed files into two groups: those
present in the checkpoint are checked out, and those absent from it are treated
as files Hermes created during the turn and deleted, since deleting them is
what restores the pre-turn state.
Absence from the checkpoint is not proof of authorship. `max_file_size_mb`
(default 10) keeps large files out of every checkpoint via
`_drop_oversize_from_index`, so a file the agent appended to — a dataset, a
corpus, an export, a log — is absent for a completely different reason. Safe
mode deleted it. No checkpoint held a copy, so nothing could bring it back, and
`restored_files` listed the path, so the user was told it had been restored.
Reproduced on main with shipped defaults:
corpus.jsonl (2 MB), agent appends to it, then /rollback 1
safe_restore_plan restore=['corpus.jsonl', 'notes.py']
restore ok=True restored_files=['corpus.jsonl', 'notes.py']
notes.py exists=True content="v1 = 'original source'"
corpus.jsonl exists=False <- deleted, was in no checkpoint
Scope: this needs an agent write to the capped file. A large file Hermes never
touched is not in the ledger, lands in `skipped`, and was already safe.
The delete branch now asks whether the path is one the cap would have excluded,
using the same test `_drop_oversize_from_index` applies when building the
checkpoint, so "kept out of the checkpoint" and "refused deletion at restore"
share one definition. Such a path is reported under a new `skipped_oversize`
key and dropped from `restored_files`.
The classification keys on "absent from the checkpoint", not on "large now".
A file small enough to be checkpointed and later bloated past the cap does have
a stored version, and reverting to it is exactly what was asked for — it still
restores, and a test pins that.
The ledger records a content hash, not whether a write created or modified the
file, so an oversize path cannot be proven agent-created. Leaving one behind
costs a stale file the user can delete; removing it costs the file.
Tests: 4 cases in tests/tools/test_checkpoint_manager.py::TestSafeRestore. Two
fail on main — the deletion and the misreport. Two are guards: the
grew-past-the-cap revert, and the small agent-created file that must still be
removed.
Regression: the 10 test files covering checkpoint_manager / rollback —
130 passed on main, 134 with this change (+4 new), zero failures either side.
* refactor(checkpoints): call the size-cap predicate instead of restating it
Review feedback on #95207: `_exceeds_size_cap` and `_drop_oversize_from_index`
each computed the byte cap and compared against it. Both used `> cap`, so they
agreed, but only by coincidence of two independent expressions — nothing held
them together.
The coupling is the whole point of the fix. The checkpoint decides what to
store and safe restore decides what may be deleted; a threshold that drifted
between them would produce a file both absent from the checkpoint and not
recognised as capped at restore, which is exactly the deletion this branch
exists to prevent. `_drop_oversize_from_index` now calls the predicate.
Added a boundary case to TestSafeRestore that pins the round trip from both
ends: a file at exactly the cap is stored, so it must revert; one byte more is
excluded, so it must be kept. Mutation-checked — moving either side to `>=`
fails it, including the re-inlined-with-`>=` shape the reviewer described.
No behaviour change: the byte cap, the strict comparison and the
unstattable-path result are all as before.
Regression: the 10 test files covering checkpoint_manager / rollback, against
current main (1fe0f2f3a, 134 commits newer than the base measured on the first
commit) — 130 passed on main, 135 here (+5 new), zero failures either side.
* fix(checkpoints): surface skipped_oversize to users and stop misreporting failed deletes as restored
Follow-up to the salvaged #95207 fix, completing the misreport bug class:
- restore() now also drops delete_targets whose unlink failed (OSError
swallowed) from restored_files — the sibling of the kept-oversize
misreport the salvaged fix closed.
- /rollback output in the CLI (cli_commands_mixin) and gateway
(slash_commands + gateway.rollback.kept_oversize locale key in all 17
catalogs) now tells the user which files were kept because the size
cap excluded them from every checkpoint; previously the file was
correctly preserved but the user got no notice it was not reverted.
- Regression test for the failed-unlink misreport.
* fix(cli): don't serve stale update-check results after fetch failure (#82166)
When _check_via_local_git's git fetch fails (timeout, offline, DNS),
the code silently fell through to compare HEAD against the stale
origin/main tracking ref, which can report 0 (up to date) even when
upstream has moved forward. Combined with the 6-hour cache in
check_for_updates, a single fetch failure could suppress update
notifications for days — the exact symptom in #82166 where the daily
cron reported 'up to date' for 4 days after v0.20.0 was released.
Two fixes:
1. _check_via_local_git now detects fetch failure (returncode != 0 or
exception) and returns None instead of falling through to stale
refs. The caller treats None as 'check could not run' rather than
'up to date'.
2. check_for_updates no longer caches None results. Previously, a
None from a failed check was cached for 6 hours, suppressing
retries until the cache expired. Now only conclusive results
(0 or >=1) are cached, so the next check attempt runs immediately
on the next call.
Added regression tests:
- test_check_via_local_git_fetch_failure_returns_none
- test_check_for_updates_does_not_cache_none
* fix(cli): preserve stale positive behind-count on fetch failure (#92578)
huklaa's review: a failed fetch makes origin/main stale, so a stale ref
cannot prove *currentness* (rev-list 0 is inconclusive), but a stale
positive count is still sound evidence an update exists. On fetch
failure, compute the stale behind-count and return it when > 0;
otherwise return None (inconclusive) and still skip the cache write.
Regression tests:
- fetch failure + stale rev-list 0 -> None (not 'up to date')
- fetch failure + stale rev-list 5 -> 5 (update evidence preserved)
- fetch failure + rev-list error -> None
* fix(web_server): recheck WEB_DIST existence dynamically in mount_spa
When hermes dashboard --skip-build runs across agent updates, mount_spa checked WEB_DIST.exists() once at server startup and mounted an immutable 404 handler if the build was missing. As a result, subsequent builds while the server was running continued to serve 404 "Frontend not built".
- Removes early static return in mount_spa().
- Moves WEB_DIST.exists() check dynamically into _serve_index() and serve_spa().
- Mounts /assets StaticFiles with check_dir=False.
- Adds unit test test_mount_spa_dynamic_web_dist_recheck in tests/hermes_cli/test_web_server.py.
Closes #82614
* fix(web_server): a dashboard started without a build recovers the moment one appears (#82614)
mount_spa's WEB_DIST.exists() check ran ONCE at mount time: a long-lived
'hermes dashboard --skip-build' that survived a git pull (or launched
before the first build) installed a permanent no_frontend catch-all and
answered 404 'Frontend not built' on every route forever — even after
npm run build completed. Remote Desktop clients saw ERR_EMPTY_RESPONSE.
The missing-dist branch is now reserved for the headless-serve contract
only. The SPA routes mount unconditionally and already cope with a
missing dist per-request (_serve_index returns the same 404 JSON when
index.html is unreadable; the /assets mount gains check_dir=False so
StaticFiles 404s instead of raising at mount). The dashboard recovers
the moment a build lands on disk — no restart needed.
Direction from #82666 by @codexbt (his PR's rebase dropped the product
hunk, leaving only the test; the test is cherry-picked as-is and this
commit restores the behavior it pins, adapted to the current mount_spa
shape: headless guard preserved, per-request recovery instead of a
per-request exists() check).
* fix(desktop): bound getConnection() on the boot and soft-switch paths (#93454)
resolveGatewayWsUrl() in attemptReconnect() because a wedged IPC
round-trip into the main process (e.g. a stuck revalidation after a
liveness-probe trip) can hang these awaits forever. A later fix
(e8d5660bae) extended the bound to resolveGatewayWsUrl() in boot() and
softSwitch() too, but left the getConnection() call immediately above
it in both functions unbounded.
If that call wedges: during initial boot the 'Starting Hermes...'
screen never resolves (bootCompleted never flips, nothing hits catch),
and during a soft gateway/profile switch latches
true forever since the try block's finally never runs.
Wrap both with the same withTimeout()/RECONNECT_ATTEMPT_TIMEOUT_MS
pattern already used for the sibling calls.
* fix(desktop): unify boot-class getConnection() budgets on one shared 45s constant
Follow-up to the #95039 salvage: the cherry-picked bound used the 20s
RECONNECT_ATTEMPT_TIMEOUT_MS on boot()/softSwitch() getConnection(), but a
reviewer note (and the Phase A registry-restore work) established that
boot-class awaits must ride out a full backend cold spawn — main's spawn
budget is 45s (DEFAULT_BACKEND_READY_TIMEOUT_MS). A 20s renderer bound would
latch boot errors on healthy-but-slow cold boots.
Introduce BACKEND_BOOT_WAIT_TIMEOUT_MS (45s) in lib/with-timeout.ts as the
single shared boot-class budget, point boot()/softSwitch() getConnection()
and connections.ts BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS at it, and keep the 20s
reconnect budget only for reconnect-class awaits against an already-spawned
backend. No magic-number drift: 45_000 now appears once in renderer code.
* fix(desktop): stop the status-stack poll storming a dead session with 4001s
The composer status stack polls `process.list` every 5s while a background
process row is on screen. `process.list` is session-scoped, so against a
runtime id the gateway no longer holds it returns 4001 "session not found".
`refreshBackgroundProcesses` swallowed *every* failure with a bare `catch {}`
commented "transient socket loss". A gone session is not transient: the poll
re-sent the same dead runtime id every 5 seconds for the lifetime of the
window. On one machine this produced 31,518 gateway rejections in a day
(vs 663 the day before), 18,614 of them against a single runtime id, and it
is what users see reported as "sessions stopped with a session not found
error" after an update.
The trigger is a reconnect, not the poll itself: anything that mints a fresh
runtime (gateway restart, the #94219 reconnect/replay work, an idle-reaped
pooled backend) strands the id the status stack is still holding, and nothing
in this path ever re-checked it.
Distinguish the two failure classes:
- 4001 / "session not found" is TERMINAL for that runtime id — latch the id
and stop polling it.
- A timeout or transport error is transient — keep retrying, since the
session may well still be alive. Misclassifying that direction would
silently freeze the status stack on a healthy session.
The latch is cleared when the status stack (re)binds a session id, so a
session that comes back under a fresh runtime resumes polling normally
rather than staying dark for the life of the app.
Also name the method in the gateway's 4001 warning. That line was added in
c305839442 "for diagnosability", but without the RPC name it cannot say WHICH
client call is looping — the reason this storm could not be attributed from
the logs alone. A ContextVar set in `handle_request` carries it; it is
diagnostic only and never used for authorization.
Tests:
- composer-status: 4001 stops the poll, a timeout does not, one gone session
never suppresses a healthy sibling, and a rebind resumes polling.
- tui_gateway: the rejection warning names the method.
* fix(desktop): harden the dead-session poll guard per #94950 review
Two review-thread deltas on the salvaged #94950 latch:
- Match the gateway's structured 4001 code, not a message substring, when
the rejection carries one (JsonRpcGatewayError). A coded error that
merely mentions 'session not found' in wrapped text (e.g. a 5007 tool
failure) must not latch the guard and freeze the status stack on a
healthy session. The substring fallback survives only for codeless
legacy errors.
- Reset the latch on runtime re-mint, not only on status-stack rebind:
wire resetBackgroundPollingGuard() at both reconnect seams that already
drop stale runtime bindings (use-gateway-boot's post-reconnect
resetTileRuntimeBindings and gateway.ts's reopening path), so ids the
dead runtime 4001'd resume polling once a respawned backend re-mints
them.
Tests: code-specific match both directions; full-reset resumes every
latched session.
* fix(desktop): release reconnect-orphaned warm transcripts once their authoritative state settles
A gateway connection that dies mid-turn leaves cached session snapshots
whose busy/awaitingResponse flags can never settle: the respawned
backend re-mints runtime ids, so no terminal publish ever reaches the
orphaned snapshot again. #isWarmSettled treated those frozen flags as
live work, so every orphan pinned its full warm transcript until app
restart — roughly 5MB per reconnect cycle, which turned the restart
loop in #95189 into renderer OOM.
SessionStateCache now accepts an optional isAuthoritativelyActive
probe. When wired, in-flight flags only block eviction while the
authoritative $sessionStates record still claims work for the same
runtime id; without the probe the legacy always-block behavior is
preserved byte-for-byte. Eviction remains gated on needsInput, pending
drafts, and active references, so a genuinely running turn (which
re-asserts busy on every publish) is never a casualty.
The useSessionStateCache hook wires the probe to the store it already
imports. Reconnect reconciliation (reconcileBusyStatesOnReconnect)
settles the authoritative record, and the next prune drains the
orphaned cache entry through the normal LRU path, ownership included.
* fix(desktop): republish the connections registry to renderers after every successful save (#95393)
Live-confirmed on the Phase B build: hermesDesktop.connections.save()
succeeds and the registry on disk gains the row, but the switcher menu
(fed by the renderer $connectionsRegistry snapshot) keeps painting the
stale list until reload. remove() already broadcasts
hermes:connections:changed; save() only did so on the dial-material-edit
branch, so a brand-new connection or a label rename never reached the
switcher's onChanged re-pull (or any other window).
Fix at the publish seam only: saveRegistryConnection now broadcasts a new
'saved' reason for every successful save that isn't a dial-material edit.
'saved' is a pure registry-refresh signal — the use-gateway-boot listener
explicitly ignores it (nothing moved, so no dispose/redial/forget), while
the switcher's existing onChanged listener re-pulls the snapshot.
Tests:
- electron/hardening.test.ts pins both broadcast branches in
saveRegistryConnection (source-assertion pattern; main.ts has no exports).
- connection-switcher.test.tsx mirrors the live repro scenario
(/tmp/mg-ab/w2_95393.py): menu before save lacks the row, Electron's
'saved' push arrives, menu after — without reload — shows it.
* chore: map justinjohnson25600 and BrunoBza contributor emails
* fix(desktop): isolate the poll-guard reset import + sort-imports lint
The combined dynamic import meant a failed composer-status import (mocked
test graphs) silently skipped resetTileRuntimeBindings too — the exact
lifecycle regression CI caught. Separate best-effort trys per module.
* fix(desktop): poll-guard reset is fire-and-forget off the redial path
composer-status imports $gateway from this module (cycle forces the
dynamic import), and awaiting the module load inside openSecondary sat on
the timed redial path — under CI load that pushed cold-start redials past
waitFor budgets in the lifecycle suite. The reset needs no ordering
guarantee relative to the dial; detach it.
* fix(gateway): redeliver transient failures after reconnect
* test(gateway): prove delivered rows are never reclaimed by the reconnect sweep
* fix(desktop): revalidate pooled remote/SSH backends on power resume (#93910)
After macOS sleep/resume, pooled remote SSH descriptors kept serving dead
tunnels: a remote entry has no child 'exit' to clear it, the renderer
keepalive spares it from the idle reaper, and the wake-path nudges from
b90289b04/febed060a only re-drive the PRIMARY renderer socket. The
background failure-streak policy needs several probe rounds before it
drops a descriptor, so the Bots pane showed 'Gateway offline' long after
the network was back.
New revalidateSuspectPooledRemoteBackends(): on resume every pooled
remote is suspect — probe each once (bounded by
REMOTE_LIVENESS_TIMEOUT_MS), retire the dead ones immediately (pool
entry + SSH bootstrap + tunnel/master teardown) and rebuild them through
the caller's dial path, while healthy descriptors are left untouched. A
failed retire skips the rebuild (never dial on top of an installed
descriptor); a failed rebuild is logged and left to the renderer's
normal reconnect — the sweep never throws.
attachPowerResumeRemoteRevalidation() wires the sweep to the Electron
powerMonitor 'resume'/'unlock-screen' seam with a 15s holdoff so the
near-simultaneous macOS wake signals coalesce into one sweep and can
never form a hot loop; overlapping kicks additionally join the one
in-flight sweep via the existing RemoteRevalidationCoordinator.
* fix(desktop): single-owner backend dial claim in Electron main (#90812)
reconnectGateway()'s in-flight lock lives at renderer module scope, so it
only dedupes reconnects inside ONE window. Two windows racing the same
wake both invoke the main-process backend ensure IPC, and for a pooled
SSH connection the loser of the pool-entry race could bootstrap a
duplicate remote backend (two tunnels, two remote serve processes).
Electron main is the single owner of backend lifecycles, so the claim
now lives there: BackendDialClaims keys in-flight dials by the pool
scope key from backendScopeKey(connectionId, profile) — the composite
identity seam wave-1 #93189 established for effective-identity reuse.
'hermes:connection' and 'hermes:connection:for' route through
backendDialClaims.run(), so concurrent renderer dials for one scope
coalesce onto one spawn and the second caller receives the first's
result. A claim exists only while its dial promise is unsettled: both
outcomes release it, a failed dial is never cached (fail closed, not
latched), and a synchronously-throwing dial rejects the claim instead
of escaping the seam.
The #93910 resume rebuild re-dials retired pool keys through the same
claim (redialPoolBackendAfterResume + new parseBackendScopeKey), so a
resume-driven rebuild and a concurrent renderer reconnect also coalesce
instead of racing.
* fix(computer_use): pitch background-FIRST (not background-only) in schema, prompt block, and skill
* refactor(computer_use): diet schema + delete prompt block (~1.4K tok/call); remove max_elements, ladder moves to response verdicts
* test: loosen frozen bare-verdict dict in cua_0_9 sibling test to decision contract
The verify_fresh_state verdict now carries an optional human hint; assert
the decision + additive-field absence instead of the exact dict shape
(same contract loosening as test_computer_use_delivery_ladder.py).
* fix(terminal): tolerate macOS TCC PermissionError in _safe_getcwd
On macOS with TCC (Transparency, Consent, and Control), os.getcwd()
raises PermissionError: [Errno 1] Operation not permitted — not
FileNotFoundError — when the process CWD is under a protected location
(~/Documents, ~/Desktop, ~/Downloads) and the calling process lacks
Full Disk Access.
_safe_getcwd() only caught FileNotFoundError (deleted CWD), so the
terminal-tool cleanup thread, which calls _get_env_config() →
_safe_getcwd() every 60 s, logged a full stack trace on every tick.
This accumulated hundreds of MB of noise in mcp-stderr.log (observed
184 MB on a single-day session) without breaking functionality — the
cleanup thread's outer try/except swallowed the exception, but
exc_info=True kept emitting the traceback.
Fix: add PermissionError to the existing except clause so the fallback
chain (TERMINAL_CWD → $HOME) runs, matching the existing pattern for
deleted-CWD recovery (#17558). Complements #66306, which handles
PermissionError from subprocess.Popen(cwd=...) for an inaccessible
configured cwd on Linux; this handles the distinct case where the
live process CWD itself is TCC-blocked.
Tests cover: PermissionError fallback to $HOME, TERMINAL_CWD priority,
FileNotFoundError regression, happy path unchanged, and unrelated
OSError (NotADirectoryError) still propagating instead of being
swallowed.
* chore: map contributor email for wiseconnex
* fix(desktop): skip macOS TCC-protected media dirs in git repo scan
The sidebar's home-dir repo crawl descends into ~/Pictures, ~/Music,
~/Movies and ~/Public — and into Photos/Music library packages — which
triggers Photos, Media Library and Files & Folders permission prompts
attributed to Hermes.app. Because the app is ad-hoc signed and re-signed
on every self-update (#49110), macOS drops all TCC grants after each
update, so these prompts re-fire every time.
Skip the media folders as direct children of a search root (nested dirs
like ~/dev/Music are ordinary and still scanned; an explicitly passed
root is still walked), and skip Apple library packages
(*.photoslibrary/*.musiclibrary/*.tvlibrary/*.aplibrary) at any depth.
Partial mitigation for #49110 / #52010: removes the Photos and Media
Library prompts entirely; the identity reset itself needs Developer ID
signed release artifacts (tracked in #49110).
* fmt(js): `npm run fix` on merge (#95511)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* test(tui): isolate custom-provider picker from host OAuth
* feat(deadline): SuspectableBackend protocol — mark timed-out backends suspect
Phase 3a of the #85125 unified-deadline plan. run_bounded_async and
run_bounded_sync accept backend= and call mark_suspect(label +
timeout) exactly once on timeout, never on completion. The layer
fails open: backends without the protocol (incremental Phase 3b
adoption) and raising mark_suspect implementations can never weaken
the deadline bound or corrupt the BoundedResult.
* test(deadline): Phase 3a poisoned-state coverage
- async timeout marks once with a label-carrying rounded-timeout reason
- completion never marks
- sync flavor marks on timeout
- non-adopting backends keep the real timeout result
- a raising mark_suspect cannot eat the timeout or the label
* fix(deadline): Phase 3a review round — mark ordering, loop offload, annotation
- mark_suspect runs BEFORE owner cleanup in both flavors (the reason
describes the state at timeout; a recycling cleanup never poisons the
healed replacement)
- the async flavor offloads the mark off the event loop
(asyncio.to_thread), matching how owner cleanup is scheduled
- the protocol documents the synchronous-cheap contract for adopters
- the windows-footgun annotation stays on its matched killpg line
* fix(deadline): document the inline-mark contract; pin the ordering invariants (Phase 3a salvage round)
Record correction: the previous commit's message says the async flavor
offloads mark_suspect via asyncio.to_thread — it does NOT (and must not).
The mark is deliberately inline on the event loop: running it
synchronously guarantees mark-happens-before-BoundedResult-return and
mark-before-on_abandon-cleanup (cleanup is ensure_future'd and cannot
start until the next loop tick). An offloaded mark would race both.
The trade-off is that a slow adopter mark_suspect would block the loop
(measured: a 2s mark stalls every coroutine for 2.003s), so the adopter
contract is now explicit in the Protocol docstring and at the async call
site: mark_suspect must be cheap, non-blocking, lock-free; expensive
recycle work belongs in ensure_healthy.
New pins so the negotiated semantics can't silently regress:
- test_sync_mark_happens_before_on_timeout (the review-round ordering)
- test_async_mark_happens_before_on_abandon_cleanup (the scheduling
invariant an offloaded mark would break)
- test_sync_completion_never_marks_backend (sync counterpart of the
async completion test)
* fix(checkpoints): surface failed_deletes and make skipped_oversize unconditional
Follow-up to #95491. The restore result dict had two inconsistent
reporting surfaces: skipped_oversize was only present when non-empty
(unlike skipped_user_edits), and failed_deletes was filtered from
restored_files but never surfaced to the user at all (debug-level log
only). Both are the same silent-omission class #95491 fixed for
oversize files; this completes the cleanup.
* fix(checkpoints): display failed deletes to users and stabilize result keys
Folds review findings: surface failed_deletes in CLI and gateway
/rollback output (new gateway.rollback.failed_deletes locale key, 17
locales), emit skipped_oversize on the nothing-to-restore early return
too, document all three report keys in the restore() docstring, and pin
the failed_deletes contract from both sides in tests.
* fix(desktop): reject SSH backends with replaced runtimes
* fix(web_server): detect replaced venvs with a marker file — inode snapshots miss ext4 inode reuse
Follow-up on the cherry-picked #82644: the (st_dev, st_ino) snapshot of
site-packages does not survive contact with ext4 — a recreated directory
routinely REUSES the freed inode, so the exact reported repro
(rm -rf venv && uv venv) passed the intact check undetected. Proven live
during salvage: the E2E's replaced venv came back with the identical
inode and runtimeIntact stayed true.
Primary identity is now a marker file written into site-packages when
the SSH owner nonce activates: it deterministically dies with the old
tree on ANY replacement (same or different Python version) and survives
in-place pip/uv installs (no false stales). The stat snapshot remains as
the fallback for read-only site-packages, where it still catches
cross-device moves and version-bump path changes. Client classifier
semantics unchanged: only an explicit runtimeIntact:false rejects, so
older remotes stay compatible.
Three new tests: recreated-venv-with-reused-inode (the live-proven
case), in-place-install stays intact, read-only fallback arms the stat
tier.
* fix: avoid macOS privacy prompts during broad searches
* fix(search): path-scoped grep pruning + execution-backend gating for macOS TCC exclusions
Two fixups the #75785 review required before landing:
- grep fallback no longer uses --exclude-dir for protected dirs: grep
matches exclude-dir globs against BASENAMES anywhere in the tree, so
--exclude-dir=Downloads silently skipped every nested directory named
Downloads (a repo's own Downloads/ included). Protected-dir searches now
route through find's path-scoped -prune (same traversal-prevention the
find backend uses) feeding grep via -exec. Regression test proves a
nested work/repo/Downloads/notes.txt is still found while ~/Downloads is
not (live filesystem, real find+grep).
- exclusions gated on env.is_local (new BaseEnvironment flag, True on
LocalEnvironment): sys.platform/Path.home() describe the controller, not
the execution host — a macOS controller driving a Linux SSH/container
backend must not prune the remote's unprotected Downloads. Environments
without the flag default to local semantics (warning-carrying skip,
never data loss).
Both sabotage-verified: restoring basename --exclude-dir fails 2 tests.
* chore: map contributor email for deepeet-git
* feat(macos): one-switch Full Disk Access guidance in doctor and setup
The last piece of the macOS permissions campaign (#52010 follow-up): macOS
prompts per-folder (Desktop, then Downloads, then Documents, ...) as the
agent touches each one — a drip-feed of dialogs on first use. ONE Full Disk
Access grant covers all of them permanently, and with the stable signing
identities merged this week it survives every update. Nothing in Hermes
taught users that.
- hermes doctor: check_macos_full_disk_access() — prompt-free probe (the
FDA-gated TCC db dir returns EPERM without a dialog; TCC only prompts on
protected-CATEGORY paths), reports granted state or prints the one-switch
setup with the Privacy_AllFiles deep link.
- hermes setup: same probe at the end of onboarding — the moment users are
primed to do system setup — silent when already granted, indeterminate,
or non-macOS.
- docs: desktop.md TCC section now leads with the one-switch guidance.
- 7 tests (granted / denied / indeterminate / non-macOS, both surfaces).
* fix(desktop): legacy group members stored under display names seat their real bot once, not as ghosts (#92794)
Older builds persisted group members with a FRIENDLY name as the
descriptor's `name` (e.g. '大司命' for slug 'taiyi'), some predating
connection scoping entirely (no connectionId). Key matching alone seated
those descriptors as ghosts NEXT TO their own live rows ('4 bots' in a
2-bot room, reproduced live), and any path passing ghost identity onward
targeted a profile that does not exist on disk.
groupChatMemberBots now normalizes stored descriptors before seating:
an unmatched descriptor re-tries by case-drifted slug or friendly name
(botFriendlyNames precedence) against rows on its own connection —
connectionless pre-scoping descriptors match local rows only. The next
persistence pass rewrites storage to slugs, so the repair self-heals.
Unresolvable descriptors still seat as degraded ghosts and are never
used as profile targets.
* fix(backup): restore state.db through SQLite backup API so live connections see restored data (#65942)
* fix(backup): clear stale SQLite sidecars on snapshot restore
`restore_quick_snapshot` swaps only the main `.db` file: copy to a temp name,
`unlink()` the destination, `move()` the temp into place. The destination's
`state.db-wal` / `state.db-shm` are left untouched.
The snapshot is a checkpointed `sqlite3.backup()` image (`_safe_copy_db`) that
owns no WAL, so a leftover `-wal` describes the database that was just
unlinked. An ungracefully killed gateway (SIGKILL / OOM / power loss /
container stop) strands exactly those sidecars -- and that is precisely when an
operator reaches for a snapshot restore. SQLite then replays that foreign WAL
over the restored file on the next open.
The module already documents this hazard for the backup archive:
`_EXCLUDED_SUFFIXES` says shipping sidecars next to a `backup()` copy "would
pair a fresh snapshot with stale sidecar state and produce a torn restore on
the next open." The same reasoning was never applied to the restore
destination.
Reproduced against the real `create_quick_snapshot` / `restore_quick_snapshot`:
origin/main restore_returned=True sidecars_left=['-wal','-shm']
integrity=*** in database main *** Tree 2 page 295:
btreeInitPage() returns error code 11 rows=DatabaseError
patched restore_returned=True sidecars_left=none
integrity=ok rows=2000
The restore reports success and returns True while leaving the database
malformed. `SessionDB` then fails to open on every subsequent start, and
`repair_state_db_schema` only rebuilds `sqlite_master`/FTS -- the damage is in
the `sessions`/`messages` b-trees, so it re-raises. The pre-restore `state.db`
is already unlinked, and because the stale `-wal` survives the restore,
re-running it corrupts the file again identically: the documented last-resort
recovery is wedged.
Every other DB-move site in the repo already handles sidecars --
`hermes_state.py:742` and `:1698`, `hermes_cli/kanban_db.py:1759`,
`hermes_cli/session_recovery.py:60`. `restore_quick_snapshot` was the outlier.
The two `hermes update` auto-restore sites (`hermes_cli/main.py`) have the same
shape and run unattended: they `copy2` the snapshot over `state.db` with the
sidecars still present, then `verify_sqlite_integrity` fails and prints
"Auto-restore FAILED -- restored copy also failed integrity". Same fix.
* fix(update): clear stale SQLite sidecars before auto-restoring state.db
The post-update integrity guard (#68474) restores state.db from a pre-update
quick snapshot with a plain shutil.copy2, at both auto-restore sites: the
ZIP-update path in _update_via_zip and the git-pull path in _cmd_update_impl.
The snapshot image is produced by backup._safe_copy_db through sqlite3.backup(),
so it is already checkpointed and owns no WAL. That is precisely why
backup._EXCLUDED_SUFFIXES refuses to ship -wal/-shm/-journal inside a snapshot:
"shipping the live WAL / shared-memory / rollback-journal alongside would pair a
fresh snapshot with stale sidecar state and produce a torn restore on the next
open." The backup side excludes sidecars for that reason; the restore side never
cleared the destination's.
copy2 replaces only the main database file. A state.db-wal belonging to the old,
corrupt database survives the copy and is replayed over the fresh image on the
next open. The restored file then passes PRAGMA integrity_check while serving
the discarded database's contents, so _restored_ok reports valid and the CLI
prints "Auto-restored from snapshot" over data the user has lost. The first
subsequent checkpoint folds the stale WAL in permanently.
A hot -wal is reachable at exactly this moment: a second Hermes holder the
updater's drain did not stop, or the crash that corrupted state.db in the first
place, which is the very trigger for this code path.
Clearing the destination's sidecars is safe here specifically -- they belong to a
database the caller has already declared corrupt and is about to discard.
Contrast preflight_db_writability, which correctly refuses to delete a live WAL.
Reproduced against real SQLite: restoring a 400-row snapshot over a database
with a hot WAL yields 0 of the 400 rows, all 201 visible rows coming from the
old WAL, with integrity_check reporting ok.
* refactor(update): fold the auto-restore sequence into a shared helper
Addresses review feedback on the regression test. The test previously parsed
the update_cmd.py AST to assert that each auto-restore call site cleared the
destination's sidecars before copying. That bound the fix to source text rather
than behaviour, and would break on unrelated refactors.
Extract _restore_state_db_from_snapshot(state_path, snap_state), which performs
the clear -> copy -> verify sequence as one unit and returns whether the
restored file passes its integrity check. Both auto-restore paths now call it,
so the ordering is guaranteed by construction instead of by inspection, and the
two byte-identical blocks collapse to a single call each.
The regression test now exercises that helper directly against a database that
still owns a hot WAL: removing the clear from inside the helper fails it with
201 rows where 400 were expected, so the guard remains bound to behaviour.
Also covers the two failure modes the callers already handle: a snapshot that
does not survive the copy returns False, and a missing snapshot raises OSError.
* fix(state): fail closed when a live process still holds state.db during destructive restore (#90950)
The #91080 harness narrowed the #90950 corruption class to 'something
unlinks/replaces state.db or its -wal/-shm while another process holds
them'. The restore paths are exactly that actor:
- restore_quick_snapshot's unlink+move fallback replaced the inode and
deleted sidecars under a live holder -> deleted-fd split brain.
- update auto-restore copy2'd a snapshot over a possibly-live DB ->
the live writer's next checkpoint writes wrong-offset pages (the
page-1 compression_locks clobber from the report).
Add _foreign_db_holder_pids(), a /proc fd scan that counts holders of
the DB and its sidecars (including already-deleted generations), and
refuse the destructive replacement while any holder exists. The
backup-API path (safe under live connections) remains the primary
route for /snapshot restore.
* docs: /snapshot restore live-safety behavior
* fix(desktop): stabilize Windows locale and UI tests
* revert(macos): remove the TCC interpreter anchor — anchored copies could not load libpython
Reverts the interpreter-anchor halves of #95131 and #95478 (the anchor
module, its doctor check, and the update-time refresh). On real Macs the
anchored real-file copy of the uv interpreter dies in dyld: its LC_RPATH
(@executable_path/../lib) resolves into venv/lib/, which holds no
libpython — bricking EVERY hermes command including update and doctor
(#95425), and the re-pointed python3 aliases lost the stdlib
(ModuleNotFoundError: encodings, #95541). Linux CI could not catch this:
the fixture interpreters were one-byte fakes with no dynamic linking.
Kept: managed_uv._macos_sign_managed_python (#82529, @notkisk) — the
identifier-DR signing of repair generations is independent of the anchor
and unaffected by the dyld issue (it signs binaries IN PLACE in their
store, where their rpath is valid).
Added: doctor's check_macos_tcc_anchor_removed() heals venvs the anchor
already converted — restores bin/python to a symlink at the recorded
source (the anchor's own marker file) and re-points aliases; prints the
manual one-liner if the heal itself fails. Users whose CLI is fully
bricked can run the workaround from #95425 directly.
Re-land criteria: a dylib-complete anchor design (bundle libpython or
rewrite LC_RPATH), verified on macOS hardware BEFORE merge. Credit to
@kim-miram (#95358), @kokhlo (#95476), @zengzheqing (#95551) for the
forward-fix diagnoses that mapped the failure, and to the #95425/#95541
reporters.
* test(ssh-ownership): remove process-global patches that crashed sibling threads
De-flakes tests/hermes_cli/test_ssh_ownership_endpoint.py, which failed CI
twice on PR #95563 with teardown-time daemon-thread excepthook crashes — a
different test in the file each attempt, always green in isolation. Root
cause: three PROCESS-GLOBAL monkeypatches leaked into every other thread
sharing the per-file worker:
- monkeypatch.setattr(web_server.os, 'stat', ...) — web_server.os IS the os
module; any daemon thread from an earlier test that stat()ed during the
patch window got the fake 2-field stat and died in its excepthook, which
fired at interpreter teardown.
- monkeypatch.setattr('builtins.open', ...) — same class, worse blast radius.
- monkeypatch.setattr(web_server.sysconfig, 'get_paths', ...) — sysconfig is
process-global too.
Fixes, none of which weaken coverage:
- replaced-runtime test: a REAL tmp_path purelib whose recorded inode
deliberately mismatches (st_ino + 1) — real os.stat, same code path.
- readonly-purelib test: chmod 0o555 on the real directory instead of an
open() interceptor — exercises the genuine OSError branch (root-skipped,
where mode bits aren't enforced).
- sysconfig patches swapped for a SimpleNamespace on the web_server module
attribute — module-scoped, invisible to other threads.
Verified: 14 consecutive full-file runs green; sabotaging
_ssh_runtime_intact to always-True still fails 2 tests (coverage intact).
* chore: remove stealth/ox-alpha from OpenRouter and Nous Portal model catalogs
Drops the retired Ox Alpha stealth preview from both curated picker lists
and regenerates website/static/api/model-catalog.json. Metadata entries
(context window, reasoning timeout) and the generic stealth/ free-tier
policy are left intact so manually-entered ids still behave.
* Revert "chore: remove stealth/ox-alpha from OpenRouter and Nous Portal model catalogs"
This reverts commit 8e1bc8d342d7a97151dfbcbf2621d5354189e9ce.
* feat(compression): lean tail retention is the default — compaction keeps 10-25K verbatim, not 100-240K
The legacy tail budget scales as threshold×target_ratio, which was designed
around 128K windows at a 50% trigger (~13K tail). On modern big-window
models with raised thresholds it silently hoards: a 1M-window session at
threshold 0.85 keeps a 170K-token verbatim tail (255K soft ceiling) out of
EVERY compaction, so a 540K manual /compress lands at ~290K and every
subsequent turn re-ships the hoard. Nobody chooses this; it is an artifact
of the formula outside its design envelope.
Lean mode (#87326, compaction-v2) was built for exactly this and its recall
was validated in the before/after eval (evals/compaction/results/): clamped
2.5%-of-window tail (10K floor / 25K cap), continuity carried by the
upgraded summary (digests, anchor index, verbatim user messages,
session_search recovery pointers). This flips the DEFAULT to lean; explicit
'tail_mode: legacy' in config keeps the old behavior exactly.
Also fixes a latent bug the flip exposed: update_model() re-assigned the
LEGACY formula directly when recomputing budgets, silently reverting a lean
compressor to the hoard on every mid-session model switch. The recompute
now routes through the mode-aware tail_token_budget property (regression
test included).
Surfaces: context_compressor.py defaults + getattr fallbacks, agent_init
parse default, DEFAULT_CONFIG, gateway _CACHE_BUSTING_CONFIG_KEYS gains
compression.tail_mode (mode changes now evict cached gateway agents like
target_ratio changes do), user + developer docs. Tests: 3 new default
contracts, legacy tests pinned explicitly, feasibility-skip scenario pinned
to legacy (under lean its payloads correctly become compressible).
E2E counterfactual (real imports, 1M window @ 0.85):
main default: legacy, tail 170,000 (ceiling 255,000)
head default: lean, tail 25,000 (ceiling 37,500)
head legacy: 170,000 (opt-out intact)
update_model to 400K: 10,000 (lean preserved across switch)
* test: adapt summary-continuity + rotation-flush fixtures to the lean default
Continuity tests pin tail_mode=legacy (they assert the raw LLM text
terminates the stored summary; lean's verbatim-user appendix follows it by
design and the contract under test is mode-independent). The #57491
rotation fixture grows 200→2000 chars/message: at ~2.5K total tokens the
old fixture fit entirely inside lean's 10K tail floor, so the no-growth
guard correctly refused the rotation the test exercises.
* chore: remove stealth/ox-alpha from OpenRouter and Nous Portal model catalogs
Drops the retired Ox Alpha stealth preview from both curated picker lists
and regenerates website/static/api/model-catalog.json. Metadata entries
(context window, reasoning timeout) and the generic stealth/ free-tier
policy are left intact so manually-entered ids still behave.
* refactor(session_search): halve the schema (1570->695 tok/call); teachings move into response hints
* fix(desktop): force session.resume on explicit bot-switch open so Bot Chat never paints a stale cached transcript (#93604)
The post-open surface-health check in host.openSession trusts any
non-empty cached transcript ($messages.length > 0), so re-opening a
bot's canonical chat after switching bots could pass the check while
painting a stale snapshot kept by the session-states cache — skipping
requestSessionResume and leaving old messages on screen until an app
restart.
Add an opt-in forceResume flag to PluginOpenSessionOptions, honored only
alongside awaitHydration, and set it on the one explicit bot-switch open
path (openStoredBotChat, which serves openBotCanonicalChat and stored
opens). Resume is cheap and idempotent per the route-resume effect's own
contract, so the extra request is a no-op when the transcript is already
fresh. All other navigation paths keep the existing heuristic.
* test(desktop): cover canonical activity stale-runtime split
* fix(desktop): refresh hidden Bot Chat transcripts
* fix(desktop): reconcile workspace-tile transcripts on sessions.changed
Bot canonical chats open as workspace tiles (workspaceMode: 'bots') and
are deliberately hidden from $sessions/$messagingSessions, so the
sessions.changed transcript refresh skipped them twice over: it covers
only the main pane's selection, and its resolveSession() bails on hidden
sessions. A background delivery (bot-to-bot DM via bot_relay.deliver, a
cron run's output, another machine) therefore never reached an open bot
chat — the roster updated but the pane stayed stale until remount
(#93942 scenario A).
Fix: the sessions.changed tick now also reconciles every visible
workspace tile through a dedicated signature-gated path. Each tile
carries its own stored↔runtime id pair so no resolution step is needed;
per-tile signatures make no-change ticks free; busy tiles are skipped
(their own stream owns the view); closed/superseded tiles discard their
in-flight read.
Slice 1 of 2 for #93942 (scenario A only). Scenario B (stream re-key
after mid-conversation model switch) follows separately.
Fixes part of #93942
* fix(desktop): satisfy lint — read busy atom directly in tile reconcile
CI caught two lint issues in the tile-reconcile path:
- no-restricted-syntax: don't mirror the $busy atom into a ref via
useEffect (stale-read hazard); reconcileTileTranscripts now receives a
live getter view so the loop reads the current value at tick time.
- react-hooks/exhaustive-deps: add updateSessionState to the
sessions.changed effect deps (stable useCallback from
useSessionStateCache, so no extra re-subscription).
No behavioral change: regression tests 2/2, adjacent hook suite 58/58,
typecheck clean.
* test(desktop): behavior tests for tile reconcile + signature pruning (#94255 review)
Enough1122 review points on #94255, all addressed:
1. Source-grep Python tests replaced with real vitest behavior tests:
- a tile whose stored transcript gained a background delivery IS
reconciled (updater invoked, correct stored id fetched)
- an unchanged transcript is skipped entirely (no updater call —
the signature gate proven, not asserted by regex)
The structural smoke test in test_bots_chat_live_append.py stays as
a cheap drift alarm; the contract now lives here.
2. Shared-sequence latest-wins semantics documented in the reconcile
docstring (review point 2).
3. Signature map pruning: a tile closed/superseded mid-read now deletes
its signature entry instead of leaking one map slot per ever-opened
tile for the app lifetime (review point 3).
4. Test harness: typed updateSessionState mock via Parameters<> instead
of the {}-as-state cast; no more silent spread corruption.
No production behavior change beyond pruning: 18/18 hook suite green,
typecheck clean.
* fix(desktop): re-bind open pane to rebuilt runtime after model switch
A mid-conversation model/provider switch rebuilds the agent runtime. The
rebuilt runtime emits session.info (and all later events) under a NEW
explicit session_id while the pane still holds the dead one as its
active id — isActiveEvent is false for the same conversation from that
moment on, so view-scoped updates stop and the chat freezes until a
full resume (#93942 scenario B; backend even logs 'client should resume
the stored session', but the client never does).
Fix: when a session.info event lineage-matches the selected conversation
(sessionMatchesStoredId over stored_session_id) but carries a different
runtime id, adopt the new runtime id as the active session id — keeping
the durable selection untouched — so every subsequent isActiveEvent gate
keeps matching without a resume. Guarded: the old runtime must show no
live turn (not busy/awaiting/streaming) or the adoption is refused, so
an overlapping manual switch can never split one conversation across
two panes.
The existing compression-rotation path does not cover this case: it
fires when the SAME runtime's stored id rotates, while a rebuild
produces a NEW runtime with a NEW stored id.
Together with #94255 (tile reconcile on sessions.changed), closes
#93942.
Regression tests verified failing pre-fix on 41447a6d70.
* test(desktop): harden rebind assertions per #94417 review
Enough1122 review points on #94417:
1. Precedence hazard fixed: the busy-guard assertion now locates the
rebind helper body precisely and asserts the guard INSIDE it, instead
of a 2000-char window with an (m and X) or Y precedence trap.
2. stored_session_id guarantee: documented + pinned — the gateway always
stamps it ('stored_session_id': session_key or "" in server.py), and
the rebind's typeof check refuses non-string/empty values, so an
unnamed rebuilt runtime is never adopted as lineage proof.
3. New third assertion pins that refusal contract.
Structural smoke tests remain structural by design; the behavior
contract for the rebind is exercised end-to-end by the model-switch
manual repro path — a vitest harness driving handleSessionInfoEvent is
the follow-up candidate noted in the reply.
* chore: map A-Snegin attribution
* fmt(js): `npm run fix` on merge (#95614)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(models): add z-ai/glm-5.3-flash to OpenRouter and Nous Portal catalogs
Slots below glm-5.3, above glm-5.2 in both curated lists; regenerates
model-catalog.json. No new metadata entries needed: context resolves via
the existing glm-5.3 fuzzy key (1,048,576 — matches OpenRouter live), and
both routes bill via official_models_api (live pricing).
* feat(update): network-bound serve backends survive hermes update on their recorded endpoints (#63206)
A manually-launched `hermes serve --host <ip>` powering a remote Desktop
was invisible to the entire update pipeline: not in the runtime
inventory, a permanent exit-2 dead-end at the Windows venv-holder guard,
and — when anything killed it — never relaunched, stranding the remote
client on a dead endpoint (#63206). Serve backends were also visible to
`hermes dashboard --stop` but hidden from `--status` (#81564's
asymmetry), so operators could kill what they couldn't see.
Built on the spawn ledger (positive identity, never argv guessing):
- process_identity.py: LedgerEntry gains structured host/port/profile
(backward-compatible — readers .get()); register_self accepts detail=;
argv capture widened 6→10 tokens so profiled launches survive.
- web_server.py: serve/dashboard registration moved AFTER the bind and
now records the ACTUAL bound host/port/profile.
- update_inventory.py: serve/dashboard collector reading the ledger —
manual backends inventory as supervisor=manual-serve with
restart_via=respawn-argv; Desktop-owned ones (live recorded spawner)
as desktop. Plan/receipts/fleet matrix see them for free.
- update_cmd.py: new venv-guard rung — manual serve/dashboard holders
are stopped for the update and relaunched via an idempotent atexit
token built from structured identity (same contract as the gateway
pause/resume); receipts record serve_pause/serve_relaunch.
Desktop-owned backends keep the refusal (the app respawns what we
kill).
- dashboard_procs.py: the process scan is augmented with live ledger
rows, so profiled launches (`hermes --profile p serve ...`) that match
no substring pattern are finally visible to kill/respawn.
- main.py: `--status` now lists serve-mode backends too, tagged [serve]
— closing the #81564 status/stop asymmetry.
Salvage note: detection deliberately does NOT reuse #70742's psutil
cmdline-pattern scan (the argv-guessing class this campaign retires);
its resume-token lifecycle (atexit + idempotent flag) and don't-replay
guard shaped the relaunch contract here — credit @Tranquil-Flow.
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
* docs: serve/dashboard backends in the update flow, --plan, and --status (#63206)
* test: re-pin --status output to the serve-inclusive contract (#81564)
The old assertions pinned the phrasing that HID serve backends — the
exact asymmetry #81564 reports. Re-pinned to the new message and
strengthened: a serve-mode row must now appear, tagged [serve].
* fix(installer): target Windows updater shim kills
* fix(windows): restore Hermes runtime compatibility
* eval: session_search schema A/B harness + PR #95570 reference results
Live tool-use A/B for session_search schema changes: arms are git refs
(tools/session_search_tool.py extracted per ref), tasks run a minimal
agent loop over OpenRouter against a freshly seeded temp session DB with
programmatic oracles — discovery, forced forward-scroll, AND-mi…
Enough1122 review points on NousResearch#94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
Enough1122 review points on NousResearch#94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
Enough1122 review points on NousResearch#94417: 1. Precedence hazard fixed: the busy-guard assertion now locates the rebind helper body precisely and asserts the guard INSIDE it, instead of a 2000-char window with an (m and X) or Y precedence trap. 2. stored_session_id guarantee: documented + pinned — the gateway always stamps it ('stored_session_id': session_key or "" in server.py), and the rebind's typeof check refuses non-string/empty values, so an unnamed rebuilt runtime is never adopted as lineage proof. 3. New third assertion pins that refusal contract. Structural smoke tests remain structural by design; the behavior contract for the rebind is exercised end-to-end by the model-switch manual repro path — a vitest harness driving handleSessionInfoEvent is the follow-up candidate noted in the reply.
#120071 - test_kanban_dashboard_plugin::test_dashboard_markdown_html_is_sanitized_before_render: runs the real bundle's sanitizer and MarkdownBlock under node; script/event handler/javascript: HTML never reaches dangerouslySetInnerHTML (security). - test_meta_ai_profile::test_images_ride_user_turns_not_tool_results: vision on, tool-message vision off, so screenshots never 400 in tool results (#101668). - session-info.test.ts "rebuilt-runtime re-bind" (7 cases, replaces the regex test tests/desktop/test_bots_chat_stream_rekey.py): the pane adopts a rebuilt runtime id only when lineage matches and the old runtime is idle (#93942, #94417).
#120071 - test_kanban_dashboard_plugin::test_dashboard_markdown_html_is_sanitized_before_render: runs the real bundle's sanitizer and MarkdownBlock under node; script/event handler/javascript: HTML never reaches dangerouslySetInnerHTML (security). - test_meta_ai_profile::test_images_ride_user_turns_not_tool_results: vision on, tool-message vision off, so screenshots never 400 in tool results (#101668). - session-info.test.ts "rebuilt-runtime re-bind" (7 cases, replaces the regex test tests/desktop/test_bots_chat_stream_rekey.py): the pane adopts a rebuilt runtime id only when lineage matches and the old runtime is idle (#93942, #94417).
#120071 - test_kanban_dashboard_plugin::test_dashboard_markdown_html_is_sanitized_before_render: runs the real bundle's sanitizer and MarkdownBlock under node; script/event handler/javascript: HTML never reaches dangerouslySetInnerHTML (security). - test_meta_ai_profile::test_images_ride_user_turns_not_tool_results: vision on, tool-message vision off, so screenshots never 400 in tool results (#101668). - session-info.test.ts "rebuilt-runtime re-bind" (7 cases, replaces the regex test tests/desktop/test_bots_chat_stream_rekey.py): the pane adopts a rebuilt runtime id only when lineage matches and the old runtime is idle (#93942, #94417).
Fixes part of #93942
Summary
A mid-conversation model/provider switch rebuilds the agent runtime. The rebuilt runtime emits
session.infoand all later events under a NEW explicitsession_idwhile the pane still holds the dead id as its active session —isActiveEventgoes false for the same conversation, so the open chat freezes until a full resume (scenario B of #93942; the backend even logs "client should resume the stored session," but the client never does).Mechanism
Event routing pins explicit-sid events correctly (
gateway-events.ts:resolveGatewayEventSessionId), but identity matching is(newId === activeSessionIdRef.current)atindex.ts:196— a rebuilt runtime's new id never matches. The existing compression-rotation path doesn't cover this: it fires when the same runtime's stored id rotates; a rebuild produces a new runtime with a new stored id.This PR
When a
session.infolineage-matches the selected conversation (sessionMatchesStoredIdoverstored_session_id) but carries a different runtime id, adopt the new runtime id as the active session id — durable selection untouched — so every downstreamisActiveEventgate keeps matching without a resume. Guarded: adoption refused while the old runtime shows any live turn (busy/awaiting/streaming), so an overlapping manual switch can never split a conversation across panes.Non-goals
Test plan
Two regression tests verified failing pre-fix on
41447a6d70; adjacent gateway-event suites 130/130; typecheck clean ×3 projects; eslint clean on touched file.Together with #94255, closes #93942.
Platforms: Electron desktop, platform-independent logic; tested on Linux (Pop!_OS 24.04).