Everything Browser Use is now managed by Hermes: the canonical binary
is the one install_cli() provisions into HERMES_HOME/bin, and every
resolution and provisioning site prefers it.
- _find_cli(): probe order flipped to managed bin -> PATH ->
user-level tool dir (then uvx across the same order). A user's own
uv tool install can no longer shadow the Hermes-managed copy with a
drifted version; side installs only matter when we have nothing.
- install_cli(): a browser-use on PATH no longer short-circuits the
install — only the managed copy does, so selecting any backend
provisions the copy Hermes controls and updates.
- _ensure_browser_use_cli() (hermes tools): drops its own PATH check
and always delegates to install_cli(), the single owner of the
managed-copy policy.
- install.sh / install.ps1: same short-circuit fix — only
HERMES_HOME/bin/browser-use counts as installed.
Follow-up to #86240 and #86320: with every non-Camofox backend
selection installing the CLI, managed-first closes the remaining
version-drift/shadowing class instead of guarding single sites.
Tests updated to pin the new contract: managed beats PATH and
user-local; PATH install does not satisfy install_cli; helper always
delegates. E2E-verified precedence chain with real files and a real
degraded-PATH install attempt.
Source-level regression for the Windows Desktop update self-lock: assert every
Invoke-HermesStep call in scripts/desktop-update/windows.ps1 drives $pythonExe
via `python.exe -m hermes_cli.main`, never the hermes.exe shim. Driving the
update through the shim keeps hermes.exe mapped as a running image, so uv's
final `pip install -e .` shim rewrite fails with os error 32 and the update can
never complete. Runs on Linux CI (no PowerShell execution needed).
Co-authored-by: Sascha Haase <sascha.haase@textiletsg.com>
Co-authored-by: adamcap926 <adamcap926@users.noreply.github.com>
`uv pip install -e .` has to replace the console-script shims, so
_quarantine_running_hermes_exe must first rename the running hermes.exe out
of the way. That rename fails whenever any child process spawned from that
hermes.exe is still alive: on Windows a child inherits a handle on the parent
image. It is the inherited handle, not the trampoline, that pins the file --
killing the child makes the identical rename succeed, and the shim flavour
(uv trampoline vs distlib launcher) makes no difference.
The updater spawns such children itself (npx cache warm, memory-provider
refresh -- hindsight-api runs as a daemon with --idle-timeout 300 and outlives
the step that started it), so this presents as a race rather than a hard
failure: the same hand-off succeeds on one run and dies on the next. Step 2's
shim-unlock preflight cannot catch it, because the shim genuinely is unlocked
at that moment; the pinning child appears later, during the update.
When the rename loses that race, _schedule_replace_on_reboot is the last
resort -- and MOVEFILE_DELAY_UNTIL_REBOOT writes to HKLM, so it needs
elevation. A Desktop-driven update is not elevated, so it returns
ERROR_ACCESS_DENIED, `uv pip install -e .` exits 2, and the ZIP fallback
repeats the identical sequence. The desktop build stage is then never reached
while the pre-build clean has already removed apps/desktop/release, leaving an
install whose Start Menu shortcut points at a Hermes.exe that no longer exists.
Running the same code as `python.exe -m hermes_cli.main update` puts the
inherited handles on python.exe, which uv never has to replace.
posix.sh is deliberately untouched: unlinking a running executable is legal
there, so the equivalent call is harmless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same local-ahead blind spot as the CLI SSH fast path, on the desktop's
passive SSH-official check: tips differ but ahead_by == 0 means the remote
tip is reachable from HEAD (carried local commit). Treat that as up to date
instead of 'update available' — the nudge toward hermes update is exactly
what wipes carried work.
Widens andyst-dev's #84860 to the desktop sibling site.
Fixes#84851
The SSH fast path in _check_via_local_git compared only the exact tip SHA
of local HEAD against upstream main. When a local carried commit makes the
SHAs differ, it returned 1 ('behind') without checking ancestry, so an
ahead-of-origin checkout was misreported as '1 commit behind' — nudging the
user to run 'hermes update' and wipe their carried work. Fall back to
git rev-list --count HEAD..origin/main (mirroring the full-clone path) when
the SHAs differ.
The composer read the same image off disk twice: once in attachImagePath
for the chip thumbnail (previewUrl — the FULL file as a base64 data URL),
and again inside uploadComposerAttachment at submit for the upload bytes.
readImageForRemoteAttach now accepts the attachment's previewUrl and
reuses its bytes when it is a base64 data URL, skipping the second disk
read + IPC round-trip. Anything else (e.g. a gateway media URL) falls
through to the disk read unchanged.
Flagged in #86302's renderer bench as the one real renderer-side
inefficiency on the attach path.
Renaming a session from its row menu opened the rename dialog, but the
menu's close restored focus to its trigger — the session row's own
<button> — instead of leaving it in the dialog input. Focus sat on the
row, so Space toggled the row (selecting/deselecting the session) and the
arrow keys moved the list rather than the text caret; you couldn't type a
space or move within the name.
Thread onCloseAutoFocus through the shared ActionsMenu / ActionsContextMenu
primitives and suppress that one focus-restore when the rename item is the
action that closed the menu, so the dialog input keeps focus. Every other
action leaves the restore untouched. Mirrors the project menu's existing
appearance-popover guard.
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).
Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.
Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.
Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
post-turn tick completion, and a supervised loop_wakeup_watcher that
injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
notification-poller wakeup driver + post-turn completion in the turn
dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
ticks defer until it finishes, pauses, or parks; real user input always
wins over both
Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
The merged data-attributes only exposed data-sessions-project on the
sessions wrapper once a project was entered, so in the project overview
(and every other mode) the attribute was absent. Put it on each project
overview row too, carrying that row's project id, so a custom skin can
target an individual project from the list — the parallel to the entered
wrapper's attribute.
The hermes update APPLY path still ran an unconditional
rev-list --count HEAD..origin/<branch> — on a depth-1 installer checkout
that walks the truncated graph and reports the entire remote ancestry
(#53479's 'Found 9980 new commit(s)' on Windows 11). The zero/nonzero gate
stays (a 0 count is trustworthy on any graph); when the count is positive
on a shallow repo, recover the real number via the GitHub compare API
(added in PR #86257) and print count-free wording when that fails.
ahead_by==0 (local-ahead) falls through to the up-to-date path.
Completes the class fix from PR #86257 on its last remaining site.
Desktop/TUI workers can spawn with a minimal PATH that omits
~/.local/bin, the default location where uv tool install links the
browser-use binary. _find_cli() then failed to resolve an installed
CLI and Browser Use mode silently fell back to the built-in tools.
Probe the user-level tool dir (~/.local/bin on POSIX, APPDATA/uv/bin
on Windows) between PATH and the managed HERMES_HOME/bin, for both
the browser-use binary and the uvx fallback.
Salvaged from PR #83788 by @kimyxx onto current main; tests adapted
and extended with precedence and uvx coverage.
The bench timed two of the six RPCs the fix touches. Extend it to
image.attach, pdf.attach, clipboard.paste and image.detach so every changed
handler carries a number rather than an inference.
Also report which surfaces reach these RPCs at all, since "why was the GUI
special" is the first question the fix invites. CLI attaches inline in its
own turn path with the agent already built, so it cannot reach the stall;
the TUI calls the same RPCs and was equally exposed. The difference was hit
rate, not code path.
gateway_attach_bench.py drives the real dispatcher with a session whose agent
build is still running and times each attach RPC against prompt.submit as the
control — the harness that located the stall and measures it.
image-attach-bench.mjs times the renderer-side transforms (file read, base64,
RPC frame, embedded-image extraction, render-weight walk) across image sizes.
It is what ruled the renderer out: ~26ms total at 3MB.
Behavior contracts, not timings: each handler must return with the session's
agent_ready event still unset, the staged image must still reach the turn,
and an unknown session must still be rejected. Verified to fail against the
unfixed resolver (4 failed, 90s of real stalls) rather than only passing
against the fix.
image.attach, image.attach_bytes, file.attach, pdf.attach, clipboard.paste
and image.detach resolved their session through _sess(), which blocks on
_wait_agent(). None of them needs the agent — they read cwd/profile_home and
mutate attached_images, all populated when the session record is created.
None of these methods is in _LONG_HANDLERS either, so the wait ran inline on
the socket reader thread. Attach runs before prompt.submit, so pasting an
image into a session whose deferred build was still warming (MCP discovery,
model metadata, skills scan) stalled the send and every RPC queued behind it
on the same socket, with no spinner to explain it. prompt.submit already
resolves via _sess_nowait and waits later, off the reader thread — which is
why the symptom reads as "text is instant, images hang".
_sess_building() resolves the session and still kicks off the build (so the
following prompt.submit finds a warm agent), it just doesn't block on it.
_sess() is now expressed in terms of it, so the two differ in exactly one
way: the wait.
The honesty half (no fabricated counts) leaves shallow installs permanently
count-less. The compare API knows the full graph regardless of local clone
depth: GET /repos/<o>/<r>/compare/<current>...<target> returns ahead_by —
exactly the behind count the shallow boundary lost.
- hermes_cli/banner.py: _github_compare_behind() (bounded, unauthenticated,
best-effort); wired into _check_via_rev and the shallow branch of
_check_via_local_git. ahead_by==0 with differing tips = local-ahead => 0.
- hermes_cli/update_cmd.py: hermes update --check shallow path prints the
exact count when recoverable, presence-only wording otherwise.
- apps/desktop/electron/update-count.ts: compareApiUrl() +
parseCompareBehindCount() pure helpers; main.ts fetches the count when
resolveBehindCount() returns null, and the SSH-official passive path stops
fabricating behind:1 (uses compare API + updateAvailable flag).
- apps/desktop/src/lib/version-status.ts: updateAvailable now applies to the
client target too, so a shallow desktop install shows '(update)' instead of
nothing (or the old frozen '(+1)').
Fixes#84591; CLI siblings of #78253 / #53479 behavior.
E2E: live compare API returned 61/62 for real 61/62-commit gaps and 0 for the
reversed (local-ahead) pair; real shallow-clone fixture (depth-1 clone +
depth-1 fetch, merge-base broken) recovers the exact count with the API and
falls back to the honest sentinel offline.
On an installer checkout (clone --depth 1) with no merge-base against the
freshly fetched origin tip, resolveBehindCount returned the sentinel 1 and
every surface rendered it as a literal count: 'A new update is ready (1
change included).' — even when the true distance was far larger (observed:
90 commits). The sentinel was meant to mean 'update available, exact count
unknown', but nothing downstream distinguished it from a real one.
- update-count.ts: return null (unknown) instead of the numeric sentinel
- main.ts: flag updateAvailable explicitly and still serve the (capped)
commit log so 'See what's new' stays useful in the unknown case
- updates.ts: toast fires for behind:null + updateAvailable, with
count-free copy instead of being swallowed by the <= 0 guard
- about-settings.tsx: status line and action buttons key off
updateAvailable; unknown size renders the new count-free string
- i18n: updateReadyUnknown / updateReadyMessageUnknown in all 5 locales
Refs #51922 (the shallow-clone special case this UI now renders honestly).
Tests: vitest electron 10/10, ui 44/44 (3 FAIL-BEFORE reds turned green),
tsc typecheck clean, eslint clean on all touched files.
The SSH-official-remote path in _check_via_local_git was hard-coded to
return 1 when _check_via_rev reported UPDATE_AVAILABLE_NO_COUNT, so
'hermes --version' and the CLI banner surfaced a stable but false
'Update available: 1 commit behind — run hermes update' message. The
count never grew: whether upstream was 1 commit or 100 commits ahead,
the banner always said '1 commit behind'.
Root cause: an ls-remote probe against the upstream URL can only tell
us tip SHAs, not a real commit count. Returning the sentinel
UPDATE_AVAILABLE_NO_COUNT (-1) already means 'update exists, count
unknown' — the exact right shape for this path.
The dashboard/desktop UI does not depend on the fabricated 1:
- The REST /api/hermes/update/check endpoint
(hermes_cli/web_server.py::check_hermes_update) treats any nonzero
behind as update_available=true, and its docstring explicitly
documents -1 as a legitimate value.
- The desktop store (apps/desktop/src/store/updates.ts::mapBackendCheck)
clamps behind<=0 to 0 and reads updateAvailable as a separate boolean
field.
So restoring the sentinel is a strict improvement: CLI banner and
hermes --version now say 'Update available' honestly instead of
inventing a count, and every REST/desktop consumer keeps working.
Changes:
- hermes_cli/banner.py: drop the 'return 1' override in the SSH branch;
propagate the sentinel unchanged.
- hermes_cli/main.py::_print_version_info: render the sentinel as
'Update available — run <cmd>' (without a count).
- tests/hermes_cli/test_update_check.py: update the SSH-official test
to assert on the sentinel; add 3 new tests covering the CLI
renderer's -1 / >0 / 0 branches.
Completes the duplicated-chrome class fix:
- Focus-in (CSI I) now routes through the same rate-limited full-redraw
recovery as Ctrl+L//redraw, clearing ghost prompt/composer copies after
Alt+Tab / tab switches (focus-regain variant reported on #60920, #25337)
- Document display.cli_rebuild_scrollback_on_redraw in configuration.md
- Register the new default in hermes_cli/config_defaults.py (moved from
the pre-refactor config.py location the salvaged commit targeted)
Same-width SIGWINCH (typical tmux attach) skipped screen clear and
left previous_screen inconsistent, crashing redraw with
'cell' object has no attribute 'char'. Always clear on resize
recovery and retry _output_screen_diff with previous_screen=None
on AttributeError/TypeError.
The resize recovery treated the first SIGWINCH of a session as a width
change (no prior width to compare against), running the Ctrl+L-style
viewport clear + _OUTPUT_HISTORY replay. The 2J clear preserves
scrollback, so everything in the deque printed a second copy below the
still-visible original. After --continue/--resume the deque holds the
whole "Previous Conversation" recap plus the first live exchange, so a
benign resize signal (GNOME Terminal tab bar appearing, monitor-scale
change, focus events) duplicated the entire conversation.
Seed the width baseline when the resize hook is installed, and replay
only on an observed width change. The baseline is read from app.output
— get_app() at install time is still the DummyApplication whose
DummyOutput reports a fake 80 columns, which would turn the first real
signal back into a phantom width change. A real initial maximize or
restore still differs from the seeded width and is still recovered
(#49120 behavior preserved; verified in a pty harness both ways).
Fixes#65293
The original fix for #60920 cleared _OUTPUT_HISTORY in _recover_terminal_after_interrupt
to prevent the interrupt marker from being replayed on redraw. This approach:
- Discarded legitimate scrollback content unnecessarily
- Broke /redraw and Ctrl+L replay for any content after an interrupt
Instead, the interrupt marker is now printed via _cprint inside a
_suspend_output_history() context so it never enters _OUTPUT_HISTORY.
_recover_terminal_after_interrupt no longer needs to clear history — the
marker was never recorded, so _replay_output_history cannot duplicate it.
Also adds:
- _show_interrupt_marker flag to cleanly separate marker rendering from
response construction
- Focused regression tests covering the marker recording suppression,
history preservation after recovery, replay cleanliness, and flag logic
Fixes: #60941
set_session_metadata() and advance_compression_session()'s repoint both
stamped entry.updated_at = now. updated_at is the user-activity clock that
drives idle/daily reset policy and the restart-resume freshness gate
(suspend_recently_active, #85709), so a background metadata write (e.g.
Slack thread watermark) or a background compression repoint on a long-idle
session could make it look freshly active and get it falsely resume_pending
after a gateway restart.
These are the last internal stamp sites after 784f733cf (recover) and
5462f689b (touch_activity gating): drop the stamps, keep the durable save.
Follow-up to #85895 (closed) — credit @GodsBoy for the report-side push and
@chelsealong for the analysis on #85709.
The Browser Use CLI 3.0 is the primary driver engine for all browser
backends except Camofox, but only the explicit 'Browser Use' picker row
ran the install hook. Local Browser, Browserbase, Firecrawl, and the
Nous-managed cloud rows left the CLI uninstalled, so those selections
depended on the uvx zero-install fallback (first-use PyPI download
inside the tool-call timeout) or silently downgraded to the built-in
browser tools where uvx was unavailable.
- Extract the install logic into _ensure_browser_use_cli() and run it
from the agent_browser/browserbase post_setup branch too (Firecrawl
and the Nous cloud row both declare post_setup: browserbase).
- Camofox is untouched: Firefox-based, no CDP surface, cannot be driven
by the CDP-only browser-use harness.
- Failure stays non-fatal: uvx fallback, then built-in tools.
Tests pin the contract: every browser post_setup key except camofox
attempts the install; camofox never does; install failure never raises.
When the inactivity reaper interrupts a timed-out turn, the interrupt
frees the blocked frame — destroying the only evidence of where the
turn was wedged. The Aug 2026 zombie-turn incident (WhatsApp session,
Relay-corrupted scope stack) wedged every turn for exactly the 1800s
timeout somewhere between 'Turn ended' and run_sync returning, and the
wedge point was unprovable post-mortem.
The reaper now logs the stack of every thread with turn-machinery
frames BEFORE interrupting, so the next occurrence names the exact
blocked line. Best-effort, bounded (8 threads, 25 frames), pure
in-process, never raises into the reaper.
A turn idle past agent.gateway_timeout (the same threshold the turn
reaper uses) no longer defers an in-band restart. Restart is usually
the remedy for a wedged turn; waiting restart_after_turn_timeout on
one inverts the graceful path's purpose — a wedged WhatsApp turn
pinned 'hermes update' in draining until SIGTERM was sent manually
(Aug 2026). stop()'s bounded drain interrupts wedged turns instead.
gateway_timeout=0 (unbounded turns) disables wedge detection; cron
and API-server work has no per-turn activity clock and is never
counted as wedged; unreadable activity summaries fail open.
The 21600s (6h) default shipped in #77184 makes an interactive
'hermes gateway restart' block for up to six hours when a turn wedges
(hung tool call, wedged event loop, stuck provider stream) — the exact
scenario the cap exists for. The intent (don't force-kill an agent
mid-turn) is sound, but the default must be a safety valve for hung
agents, not a target latency.
Lower to 1800s (30 min): still protects the overwhelming majority of
long autonomous turns (tool calls have their own timeouts well below
that), keeps worst-case interactive restart latency human-tolerable, and
users running very long unattended turns can raise it in config.yaml.
RED: new contract test fails on old 21600 default. GREEN: 4/4.
The /docs/skills hub page gains an embed mode for host apps that
iframe it as a skill PICKER (first consumer: Bot Mode's agent editor).
With ?embed=picker:
- docs chrome (navbar/footer/hero) is hidden
- every card gains '+ Add to this Agent', which posts
{type: 'hermes-skill-pick', name, identifier, installCmd, source}
to the parent window
The page never installs anything — the host validates event.origin
and performs the install through its own gateway (skills.manage).
Normal page rendering is untouched (no query param = no changes).
Follow-up to #86227: _DEFAULT_OFF_TOOLSETS entries (a2a, spotify,
discord, video, x_search, ...) and the region-specific yuanbao are
global opt-ins configured via `hermes tools`/Settings; showing them
unchecked in every per-profile capabilities editor is noise, and
showing a2a at all confuses users who never enabled agent-to-agent
serving (tester report). Enabled ones still show — hiding an ACTIVE
toolset would misrepresent the profile.
A captured native-compaction checkpoint lives in the persisted
codex_reasoning_items sidecar, but the wire restructure that follows it
(prune_pre_checkpoint_items) ran unconditionally: the native gate only
decided whether context_management went into the request, and no signal
from it ever reached _chat_messages_to_responses_input.
So a single checkpoint kept deleting every pre-checkpoint item from all
later requests — after a mid-session swap out of the gpt-5.6 family,
after compression.enabled: false, after the rejection kill switch, and
after a session resume that reloads the sidecar from state.db. The model
receiving the opaque blob was no longer the one able to decode it, and
nothing was logged.
Thread a single native_compaction_eligible boolean, derived from the same
value that gates the context_management field, into the converter. When
ineligible: do not replay type: "compaction" items and do not prune. Safe
because native compaction never truncates Hermes' local history, so the
fallback still carries the full conversation.
All Responses call sites are covered: build_kwargs and convert_messages
derive the flag via _native_compaction_active, the auxiliary/compression
client is explicitly ineligible, and the converter defaults to False
(pre-feature wire) so future call sites are safe by construction.
Fixes#85914
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes for what profiles.describe & friends expose (tester
report with screenshot):
1. describe's toolsets used the RAW registry (get_all_toolsets) —
leaking internal platform composites (hermes-discord, hermes-cron,
feishu_drive, discord_admin, desktop_ui, ...) that are gated,
platform-restricted, or deliberately hidden from users — and
reported everything enabled whenever the profile had no pin.
Now: the same filtered universe the `hermes tools` checklist
offers (_get_effective_configurable_toolsets, platform-filtered),
with enablement resolved via _get_platform_tools like the runtime.
2. skills.manage accepts optional `profile`: list/install scoped to
that profile's skills dir via the home override, so editors can
manage a bot's skills (incl. hub installs) from the main window.
search/browse/inspect (the hub catalog) unchanged.
3. New mcp.catalog method: the bundled MCP catalog with per-profile
installed/enabled state + required env keys, so capability UIs can
offer the full menu and route un-setup entries through setup
instead of silently listing dead servers.
E2E: describe now returns only user-facing toolsets with honest
enabled flags; mcp.catalog returns the 5-entry catalog; skills list
scopes to the named profile.
profiles.create inherits the launch profile's provider+model when the
caller doesn't pin one — but the gate was 'config.yaml doesn't exist
yet'. Voice-section mirroring (#85755) runs FIRST and legitimately
creates config.yaml (tts/stt), so inheritance silently skipped for
every non-clone profile since: the bot's editor showed 'Inherit
(launch profile)' while the profile actually had NO model section,
and the first message failed with 'No inference provider configured'
even though the main agent was authenticated and working (Bot Mode
tester report, screenshots).
Gate on what we actually care about: the profile's own raw config
lacking a complete model section (provider+default). Clones bring
their own section and stay untouched; explicit pins unchanged.
E2E: create receipt now model_inherited=true and the fresh profile's
config.yaml carries the launch profile's provider/model.
Review findings on the caching commit:
- ~/.cache/electron was dead weight: with npm ci skipped on an exact
cache hit, the download cache is never read (electron's unpacked
binary lives in node_modules/electron/dist, inside the cached tree);
it only inflated every saved archive by ~110MB.
- 'npm i -g npm@12' ran unconditionally in all 14 matrix jobs
(~5-15s each); now a no-op when the bundled npm is already 12.x,
which also keeps the installed major aligned with the npm12
cache-key tag.
yaml + actionlint pass.
Every job in the js-tests matrix (~10 jobs/run, 13 after the UI-suite
sharding) runs a full 'npm ci' that deletes and re-extracts the entire
workspace node_modules and reruns all postinstalls — including the
Electron binary fetch (~100MB) — because setup-node's 'cache: npm' only
caches the ~/.npm tarball cache.
Cache the installed tree itself with actions/cache (the SHA-pinned
v4.2.4 already used by e2e-desktop.yml), keyed on the exact lockfile
hash, and skip 'npm ci' on a hit:
- key includes runner.os + node26 + npm12 so a toolchain bump never
reuses a stale tree
- NO restore-keys: a partial hit would leave a stale tree ('npm ci'
skipped means nothing would repair it), so anything but an exact
lockfile match reinstalls from scratch
- distinct keys for the discovery job (--ignore-scripts tree) and the
check jobs (with-scripts tree + ~/.cache/electron), which differ in
postinstall artifacts
Measured from run 31783969717: the npm-ci step is 30-45s per check job.
On warm cache this drops to a few seconds of restore, saving roughly
5-8 runner-minutes per PR run and ~1GB of registry traffic, and taking
~35s off every job on the merge-gate critical path.
* fix(dashboard): suppress Ctrl+C shutdown traceback
* fix(dashboard): extend clean Ctrl+C exit to the Windows serve branch
The Windows loop-factory branch (and its pre-0.36 asyncio.run fallback)
runs under the same uvicorn capture_signals() re-raise as the POSIX
path, so console Ctrl+C leaked the identical KeyboardInterrupt
traceback there. Guard both serve calls with the same clean-exit
contract, keeping the import-resolution try/except comment accurate
(genuine serve-time errors still propagate).
Also ports the reworded POSIX-test docstring (the serve path is no
longer 'byte-for-byte unchanged'), wraps the POSIX KI test in
pytest.fail so a regression reports red instead of aborting the pytest
session, and adds the windows_only sibling test.
Extends #52970 to the whole bug class.
* chore: map contributor email for @wangs1203
* test(dashboard): actually exercise the pre-0.36 Windows fallback KI contract
Copilot review caught that patching uvicorn._compat.asyncio_run with
raising=False makes the import succeed, so _runner is non-None and the
extra asyncio.run patch never covered the fallback. Split it out: a
dedicated windows_only test halts the _compat import (None in
sys.modules) so the fallback branch is genuinely selected, then asserts
the same clean-KI contract on bare asyncio.run.
---------
Co-authored-by: Emiya·Leon <wangs.coder@gmail.com>