Every /model surface parses through parse_model_switch_args. The legacy
5-tuple wrapper had no production caller; its remaining references were two
tests of the tuple shape, an unused import, and two tui_gateway patch()
calls on a function tui_gateway no longer calls.
The en-dash --session normalization those tests covered moves into the
parse_model_switch_args keeper. test_model_switch_once_flags and
test_model_switch_context_offload are removed: the first re-tests the
private parser the keeper already drives, the second the one-line to_thread
wrapper that tests/gateway/test_model_command_context_offload.py proves off
the event loop through the real /model handler.
Each removed test either asserts nothing reachable or repeats another
test's exact body:
- auxiliary_client empty-base_url: body only writes config.yaml; a merge
(890bbbda1f) restored the header of a test b3d4f67b20 had purged. The
non-Anthropic-host test beside it drives the same default fallback.
- background_review no-session-db: _record_review_usage_to_parent swallows
every exception and there is no DB to observe, so removing the guard
still passes; the path has no observable effect to pin.
- code_execution Windows "would have failed" pair: every branch returns,
skips or passes; they exercise CPython defaults, not Hermes code.
- cron test_should_use_direct_api_call_*: byte-identical copy of the test in
tests/agent/test_cron_inline_api_call.py (0213b8b77b had to edit both).
- cron liveness no-multiplexer: identical body to test_no_lock_no_pids_is_false;
its multiplexer patch is never reached.
- browser console/get_images "private URLs allowed": identical to the
local-backend test in each file; both mock the predicate the name promises.
- hermes_state TestTitleUniqueness: the duplicate-title case is a strict
subset of TestSessionTitleLineage::test_unrelated_session_still_conflicts;
the NULL-title case never writes a title.
The stale-profile recovery paragraph claimed uninstall only runs in a
mode that keeps user data. Plain interactive `hermes uninstall` is let
through and its menu still offers Full uninstall of the default root,
behind its confirmation prompts. Say so, and note in the predicate why
--full is refused (it wipes without asking) while the menu is not.
When active_profile names a deleted profile, the error still said
"Create it with: hermes profile create <name>", a command that exits with
the same error. Name the command that works instead:
`hermes profile use default`. An explicit -p keeps the create hint.
The recovery allowlist checked for the literal string "--data", but the
uninstall subparser accepts argparse abbreviations: `hermes uninstall --dat`
passed the guard and removed the default root's data. `--full` was allowed
outright, and in the default root it rmtree's every other profile under
profiles/ as well, a wider wipe than the --data mode the guard refuses.
Parse the uninstall argv with the real subparser (output silenced; the
handler reports errors itself) and allow it only when neither data nor full
resolves true. `uninstall --help` exits 0 during that probe and is let
through so the handler prints help once instead of help plus an error.
The predicate lives in a topical sibling (main_profile_recovery.py) rather
than growing the main.py facade.
Reuses the repr-field secret-name policy (is_secret_field_name) so token, password, secret and credential-suffixed fields are masked by field in hermes dump, debug share and /debug. The regression test asserts on what actually reaches the paste service first.
Text redaction of str(fallback_providers) missed api_key values that the
runtime still uses (resolve_entry_api_key str()s them): an unquoted numeric
key, a mapping, or a key containing "***" (treated as already masked).
Mask the field before serialising; keep the independent upload-boundary
pass and cover it with #126168's boundary test.
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
The strict URL-credential pass that hermes dump and debug uploads use
redacted X-Amz-Signature but not X-Goog-Signature (GCS V4 signed URLs)
or sig (Azure SAS), so a fallback base_url signed that way still reached
the dump and the upload. The documented promise is signed-URL signatures
in general.
Covers userinfo, ?key=, X-Amz-Signature= and #access_token= through the real config.yaml, hermes dump stdout, the share bundle and the /debug report. Docs (EN + zh-Hans) say the dump redacts credentials in fallback URLs.
A fallback_providers base_url can carry its credential in the URL (userinfo, ?key=, #access_token=, X-Amz-Signature=). The force pass leaves those to the log policy, which spares OAuth/magic-link URLs, so hermes dump printed them and debug share / gateway /debug uploaded them. The dump's fallback rendering and the upload copy of the dump now opt into redact_url_credentials.
The strict canonicalizer also turned x-amz-signature into x_amz_signature before the policy lookup, so strict mode never masked it; hyphenated policy names now match as stored.
Ported from #126168.
hermes dump prints config_overrides.fallback_providers as the raw list,
api_key values included, and the upload-time redaction of hermes debug
share and the gateway /debug report covered only the log snapshots, not
the dump text quoted at the top of every paste.
Force-redact the captured dump like the logs (--no-redact still opts
out), and have hermes dump itself redact fallback_providers so a pasted
dump never carries a key.
The lock-ancestry comment still claimed it was what stops a tail recursing;
the tail scripts now return before it, so it describes the processes an
update spawns instead. Also note why the identity reads sys.argv[0] rather
than the argv parameter.
update_completion.py arms the pending marker, then imports hermes_cli.main,
which runs prepare_launch. While the updater's lock claim is live the
ancestor check returns early, but an unwritable claim or one past the
20-minute ceiling sends it into _finish_source_update: a full
source_completion tail runs inside the update and clears the marker before
the update's own build has finished.
Exempt both tail scripts through one identity check. The launch check
already resolves its root, so the comparison is exact on the process's
own argv[0], which the processes a tail spawns do not inherit.
- _deepen_shallow_repo marks packs in its finally without a git config probe
that could itself raise and turn a finished unshallow into False; markers
are inert in a clone without a promisor remote.
- Installers warn instead of aborting the stage when a marker cannot be
written, matching the Python helper.
- Docs: the manual commands follow HERMES_HOME, and the missing-object
backfill feeds IDs through `fetch --stdin` so a large list cannot exceed
the Windows command-line limit.
- Test and docstring comments describe the contract, not the incident.
Manual marker one-liners for installs whose own updater cannot fetch the fix,
and the missing-object backfill for checkouts that were already turned into
full clones by hand.
An install whose updater dies on the git 2.53+ pack-objects BUG (#124272)
never fetches the release that heals it: the fetch runs in the installed
code. The installer rerun (install.sh / install.ps1, served fresh) is the
path that runs new code, so when origin is a promisor remote it now marks
the checkout's unmarked packs before its existing-checkout fetch. Marking is
idempotent and never rewrites objects, so it runs up front rather than as a
retry that would first print a failed-fetch report.
git 2.53+ `index-pack --promisor` hands every object outside the promisor
packs to pack-objects, which BUG()s (should_include_obj) on the missing
objects those objects lead to. A partial clone with unmarked packs therefore
keeps crashing its fetches.
The retry added for this ran `git -c remote.origin.promisor= fetch`. That
override does nothing: git registers promisor remotes additively, so the
repo's own `true` still wins (GIT_TRACE on 2.50 and 2.55 shows
`index-pack --promisor` and `filter tree:0` under the override). The retry
only passed when the crashed fetch had already stored the pack; a retry that
has to transfer again crashed again.
- On the crash, give every pack without a `.promisor` marker one, then retry
the same fetch once. The clone stays partial, no refetch, no config change.
If git still crashes, the updater points at the docs section.
- The two fetches that turn a full clone partial now mark the clone's old
packs afterwards: the depth-1 `tree:0` unshallow in
`fetch_full_commit_graph`, and `worktree_ops._deepen_shallow_repo`
(`--filter=blob:none`). git writes the partial-clone config before it
fetches, so this also runs when the fetch fails. Without it, a converted
depth-1 install crashed every later fetch on git 2.55 (3 of 3 rounds in a
local repro).
is_partial_clone_pack_objects_crash() required "index-pack failed" next to the
pack-objects assertion, but git 2.55 builds dropped that string: Homebrew git
2.55.0 (macOS) only reports "fatal: fetch-pack: invalid index-pack output", so
the recognizer always returned False there, fetch_with_partial_clone_recovery()
never retried with the promisor machinery disabled, and `hermes update` exited 1
on the crash (#125138 reports the same bare-assertion failure on 2.55.0.windows.5).
Fingerprint on the assertion text — the only version-stable part of the failure
— and accept the 2.55 wrapper as a terminator. The POSIX/Windows shapes and the
"fingerprint without a terminator" rejection behave as before.
(cherry picked from commit 74103b15ceb3eacb2346fca669c3a3dc81457201)
The salvaged matcher accepted any cmd_update/_cmd_update_impl frame in
hermes_cli.update_cmd/main, so the CURRENT updater's entrypoints (which
carry pre_update_version on opts, not as a local) would also hand off and
exit the process mid-`hermes update`. Extract
_old_updater.in_historical_update() sharing _historical_context's exact
frame matcher (module, name, and pre_update_version in co_varnames) so the
shim and the context detector cannot drift apart.
- tools/lazy_deps: use the shared predicate; accept the constraints/
dry_run kwargs historical updaters pass so they reach the handoff
instead of dying on TypeError; drop the vague message advice.
- tests: collapse 29 parametrize cases to the real invariants and add the
missing one — the frame walk is now exercised through a nested helper
(a getframe(1)-only implementation fails), and a current-updater-shaped
frame (no pre_update_version local) is asserted NOT to hand off.
The standalone ownership test duplicated the parametrized boot migration
(same wrapper, weaker check), was the only test in tests/hermes_cli calling
the private _publish_conveniences, and asserted a mtime-derived "changed"
flag immediately after writing the file, which is flaky on filesystems with
coarse timestamps.
Keep its one unique invariant, another checkout's venv/bin/hermes is never
adopted, by pointing the migration test's existing foreign hermes-agent
wrapper at another checkout's venv. It still also mentions this install in a
comment, so both rejection cases stay covered through public expose_cli().
* feat(desktop): open new installs at a display-sized window and 110% zoom
A fresh install opened at a fixed 1220x800 and 90% zoom, so body text sat
below the Apple and Windows minimums and users pressed Cmd+ repeatedly.
The first window now takes 75% x 80% of the primary display's work area,
floored at 1220x800, capped at 1600x1000, and never larger than the work
area. A saved window-state.json still wins. The default zoom is the 110%
UI Scale preset; Cmd+0 / Actual Size resets to it. Existing installs keep
their saved zoom (first launch has always written the default to
zoom-state.json).
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* test(desktop-e2e): let a full-window overlay cover the viewport within 1 px
waitForInteractive treats a fixed element as blocking only when its rect
reaches innerWidth/innerHeight. innerWidth is rounded: at 110% zoom a
1280x860 window is 1163.6 CSS px wide and innerWidth reads 1164, so the boot
overlay (inset-0, 1163.6 px) no longer counted as covering the window and the
update E2E declared the app interactive while it was still booting.
writeComposerImage stamped filenames via Date.toISOString(), which is UTC,
so the embedded timestamp disagreed with the file's mtime (shown in local
time) by the user's zone offset - e.g. 8 hours off for UTC+8.
Extract the stamp into composerImageTimestamp() (local wall-clock
components, same YYYY-MM-DD_HH-MM-SS-mmm shape) with regression tests that
distinguish local time from UTC on any host timezone. Machine-facing
artifacts (bootstrap run logs, emergency state.db backups, corrupt-registry
sidecars) keep their UTC stamps intentionally.
Closes#96403
React 19 only ships `act` in its development build. The jsdom suites in
`web/` drive components through `act()`, so when vitest inherits an
ambient `NODE_ENV=production` — the shell that launched the app, a
packaged CI step, an editor terminal — `React.act` resolves to undefined
and every `createRoot` test in the project fails with
`TypeError: act is not a function`.
Pin the test worker to `NODE_ENV=development` via the project's
`test.env` so the suite no longer depends on the ambient environment.
App runtime and production builds are unaffected; this only applies to
the vitest worker.
Same class of failure as #77409 (and the same fix shape as #77421), one
workspace over: that issue and PR cover `apps/desktop/vitest.config.ts`,
while `web/vitest.config.ts` has no equivalent guard.
Before (clean main, NODE_ENV=production):
Test Files 3 failed | 33 passed (36)
Tests 43 failed | 232 passed (275)
After:
Test Files 36 passed (36)
Tests 275 passed (275)
The previous fix exempted every kind != "skill" item from the browse
cap, on the premise that non-skill items only come from the fixed,
~150-entry COMMAND_REGISTRY. That's not true: plugin-registered slash
commands (PluginContext.register_command) are also classified as
kind "command" but are an unbounded, user-installed source — the same
shape the cap exists to bound for skills. A large plugin install would
have flooded a bare `/` completely uncapped.
Only exempt names actually in GATEWAY_KNOWN_COMMANDS (derived from
COMMAND_REGISTRY) from the browse cap; other kind:"command" items,
including plugin commands, keep the existing 30-item cap.
_rank_slash_completions applied the same 30-item cap to registry
commands and user-installed skills. The cap exists to bound the
skill block (which can grow to hundreds of installs); the command
registry is fixed and curated (~150 entries). On a bare `/`, that
shared cap put /goal, /subgoal, /loop, /queue, /personality and
/debug past position 30 and hid them from the desktop and TUI
browse list entirely, even though they still resolved by prefix.
Only skip the cap for commands when browsing (text == "/"); a
typed search still bounds both blocks.
Fixes#101388
migrateTilesForProfile moves every profile-keyed family to the new name
after a local rename. Tiles, Bot tile owner routes, cached transcript
tails, status drawers and preview dismissals all skip state owned by
another connection, since a same-named profile there was not renamed.
migrateSessionOwnerHintsForProfile had no such check. It rewrote the
hints of remote sessions whose profile had the old name too, and the
hint outranks the session row when routing RPCs. Those sessions then
dialled a profile that does not exist on their connection.
Rewrite only hints on the local connection.
Review follow-up on Enough1122's review of #123135:
1. A poll window refused end to end now throws instead of resolving
success. Only an ANSWERED poll proves the replacement backend came
back, so the tolerance is reserved for answered polls; a fully
drained window keeps the callers' failure banners up and toasts.
The last poll error is retained and surfaced through that path
(previously every poll error was discarded with no observability).
2. The reconnect handed over after a restart is now a
'restart-followthrough' source: the boot hook probes liveness first
and leaves a provably-alive socket alone, defers an inconclusive
probe mid-turn behind the same bounded re-probe as the wake path
(#95327), and only closes a provably dead socket. Manual recovery
keeps its unconditional re-dial contract (it may retry a credential).
3. getActionStatus is no longer passed POLL_TIMEOUT_S as the `lines`
count; the misleading constant is gone.
Tests now distinguish refused-for-part-of-the-window (success) from
refused-for-the-whole-window (failure) and pin the followthrough
teardown policy on the real boot-hook handler.
Clicking "Restart gateway" restarted the backend cleanly but never touched
the client's own WebSocket: recovery waited on the passive close->backoff
machinery, and on a Windows close-frame-less drop (code 1005) the zombie
socket survived until the 45s heartbeat deadline -- by which time the user
had clicked restart again, re-killing the fresh backend and flapping
forever (#123111). The restart's own action-status poll had the mirror
bug: it polled the very backend the restart takes down, so one refused
request mid-window aborted the wait with a false "restart failed" toast
(and a fresh process's empty registry read as an unknown action).
- awaitAction() now tolerates the restart window: refused polls and a
fresh process's running:false, exit_code:null are healthy mid-restart
states; only a recorded non-zero exit (or a drained budget) fails.
- runGatewayRestart()/watchGatewayRestartOutcome() hand reconnection to
the registered gateway reconnect owner in their finally, collapsing the
zombie-socket window from the heartbeat deadline to immediate.
Red->green: system-actions.test.ts fails 4/7 on unfixed code, passes 7/7
with the fix; the full src/store suite (1674 tests) stays green.
When opening a session owned by a local secondary profile (without an
explicit connectionId), recordSessionEventScope only recorded
sessionOwnerByRuntimeId and omitted sessionScopeByRuntimeId. Furthermore,
foregroundSessionScopes had no fallback to knownOwnerForSession for
active sessions before inbound runtime events had populated the runtime
scope map.
As a result, foregroundPinned evaluated to false once initial request
leases settled (such as session.control.read), causing route.release()
to dispose the secondary gateway socket with code 1005. The frontend
then repeatedly re-attempted read requests, causing an infinite
websocket reconnect loop and high renderer CPU usage (~18%).
Resolve this by:
1. Recording the normalized profile key into sessionScopeByRuntimeId
when handling events from secondary profile owners.
2. Cleaning up sessionScopeByRuntimeId during forgetProfileOnlyRuntimeOwners.
3. Checking knownOwnerForSession in foregroundSessionScopes for active
sessions whose runtime scope is not yet registered.
4. Aligning reconcileBusyStatesOnReconnect and liveSessionScopes with
local secondary scopes, and updating doc blocks.
Fixes#121865
The gave-up-banner test was pasted twice when the budget-survival commit
(a5e0c9e5c0) was assembled: one copy at describe scope, and a second copy
inside the body of 'times out a stalled URL request and retries'. Vitest
rejects calling it() inside a test ('Calling the test function inside
another test function is not allowed'), which is exactly what failed the
JS & TS checks run on a5e0c9e5c0. Remove the nested copy; the describe-
scope copy and the stalled-URL test are unchanged and pass 20/20.
The attempt counter lived in the [gw] effect closure, so every redial —
which bumps version and re-runs the effect — reset it to zero and the
five-attempt budget never exhausted. The counter and the gave-up flag
now live in component refs (reset on a successful open and on scope
switches), and exhausting the budget surfaces a one-time banner
('gateway sidecar disconnected — gave up after 5 attempts, use
Reconnect'), mirroring the events feed's give-up contract; the banner
clears on the next successful open.
A dashboard service restart closes both web sockets with 1012 (Service
Restart) — a CLEAN close. The PTY pane's reconnect guard only retried
dirty closes and 1001/1006, so 1012 fell through to the '[session
ended]' path and the pane stranded until a manual relaunch (server logs
in the issue show a 23-minute dead window). The JSON-RPC sidecar had no
redial owner at all: GatewayClient deliberately delegates reconnect
policy upward, but ChatSidebar only dialed once.
- ChatPage: the transient-drop guard now also retries 1012 and 1013 —
both mean 'the server is coming right back', so the same ?attach=
token reattaches to the still-living PTY.
- ChatSidebar: the sidecar gets a redial owner with the same bounded
backoff the PTY pane uses (250ms * 2^n capped at 3s, 5 attempts,
reset on open, cancelled on unmount/scope switch; the manual
Reconnect affordance stays the fallback after the cap).
Reported recovery goes from a manual app relaunch (~23 min stranded in
the log sample) to an automatic redial ~6.5s after the service returns.
The lease that spares a mid-dial switch target from the live-work pruner was a
fixed 30 s, while the dial it must outrun now carries SOURCE_SWITCH_DIAL_TIMEOUT_MS
(135 s, the whole remote bring-up chain). From t=30 s the target was therefore
prunable for the rest of a healthy dial: a recompute disposes the entry, the
bring-up lands anyway, and openSecondary's `!entry.wantOpen` guard closes the
fresh socket and returns — the dial resolving on a closed gateway, with a second
bring-up in flight.
Derive the lease from the dial budget instead of an independent literal so the
two cannot drift again (the argument with-timeout.ts already makes for
BACKEND_BOOT_WAIT_TIMEOUT_MS), and pin it with the convergence case review asked
for: a prune late in a still-in-flight dial must not dispose the target.
A remote bring-up is a chain of main-process stages, not one IPC: ssh
connect (15 s), the platform/locate/version execs (20 s each), the spawned
backend's ready sentinel (45 s) and the forward (15 s). A healthy cold
bring-up therefore runs 37-64 s against a small VPS over Windows ssh.
Two renderer awaits bounded that chain with the reconnect-class 20 s budget
(selectConnection's phase-1 dial and openSecondary's registry leg), so the
switch toasted "Could not connect to <source>" while the dial was still
progressing -- and then connected anyway, because withTimeout does not
cancel its input and the dial's socket is retained warm. Users read it as a
broken source and retried.
Bound both dial sites with SOURCE_SWITCH_DIAL_TIMEOUT_MS, the sum of the ssh
layer's own stage budgets, and keep the 20 s reconnect-class budget for the
cheap round-trips (WS re-mint, route probes) and for the local activation
commit it was written for. The #93454 anti-wedge property is preserved: the
per-IPC budgets still fire from inside the dial, and both a wedged dial test
and a slow-but-healthy-dial test pin the behaviour.
Fixes#120296
Vendor React throws 'Tried to unmount a fiber that is already unmounted'
when a Radix portal (context menu / dialog / popper) unmounts mid-commit
while its host subtree swaps (#98654, also seen in #41693). The throw
unwinds into whatever ContribBoundary hosts the workspace route table, so
the whole pane blanks behind a manual Retry even though the fiber graph
settles as soon as the swap completes.
Extend the existing capped auto-recovery (already used at root for
assistant-ui lookup races) to this error class at every boundary, since
the throw comes from React itself rather than boundary-local app code.
Persistent failures still surface the fallback: 3 recoveries per 5s
window, identical budget semantics.
On a compute-host (turn_isolation) session the live agent lives in the child
process, but config.set model either stashed pending_model_switch for a turn
thread that pops-and-drops it against agent=None (busy), or built a second
agent in the serving process and switched that copy while the child kept the
old model (idle). Carry the stash in the turn frame (copied, not popped), let
the child's _ensure_server_session adopt it in both branches, and clear the
server-side stash only after a successful isolated turn.
Rebases tsemana's #79510 onto the post-split modules: the frame key and the
clear-on-success now live in compute_host_bridge.py, adoption in
compute_host.py, and the always-defer condition in methods_config_set.py.
Fixes#79509
Co-authored-by: Tony Semana <tsemana@users.noreply.github.com>
_run_post_turn_followups claimed the session for a goal continuation
checking only running, so a Stop that landed after the model answered
but before the follow-ups ran still chained the next goal turn. Refuse
it while the Stop latch is set; the goal resumes after the next prompt.
Bot live delivery stays ungated on purpose: an envelope is a person's
message rather than an automatic turn, and only a local prompt clears
the latch. A comment at the gate says so.