Commit graph

3458 commits

Author SHA1 Message Date
nesquena-hermes
76d6cc7f74 #5142: office-doc preview + safe docx editing (#540), rebased on master; optional deps 2026-07-02 01:15:57 +00:00
nesquena-hermes
728caf5ec6 #4682: surface read-only other-profile cron jobs in Tasks panel (#3947), rebased on master 2026-07-02 00:44:24 +00:00
nesquena-hermes
b709ae6fc2 #5228: require browser provenance on all proxy methods (close GET/POST asymmetry from gate); CHANGELOG 2026-07-02 00:23:10 +00:00
nesquena-hermes
3d99763f3a #5228: opt-in extension loopback proxy (#4747), rebased on master; union urllib imports 2026-07-01 23:49:25 +00:00
nesquena-hermes
9fb2731848 #5310: push-to-talk hold gesture (#3700) 2026-07-01 23:26:15 +00:00
nesquena-hermes
b1a02bdb72 #5309: ctl.sh load ~/.hermes/.env 2026-07-01 23:24:38 +00:00
nesquena-hermes
36643867f4 #5153: MoA gateway fail-closed routing 2026-07-01 23:24:38 +00:00
nesquena-hermes
ffad5e4acd stage #5374 2026-07-01 22:57:37 +00:00
Stacey2911
6044eaa0cc fix: respect auxiliary title timeout for manual regenerate 2026-07-02 08:48:47 +10:00
nesquena-hermes
d43618beca stage #5301 2026-07-01 22:42:35 +00:00
nesquena-hermes
8b8d5d990d test(#5364): fully restore sys.modules in the profiles import harness
The regression test re-imports api.profiles in isolation by stubbing
flask/yaml/agent in sys.modules and deleting+reloading the real api /
api.profiles modules. Teardown only popped api/api.* (never restoring the
real modules) and left the flask/yaml stubs behind, so the manipulation
LEAKED: subsequent tests re-imported api.config/api.routes against the stub
yaml (safe_load->None) and a half-populated api package, silently breaking
~120 unrelated tests in the full serial suite (e.g. MCP/provider/config
tests whose get_config patch no longer saw real config). Snapshot every
sys.modules key we touch and restore it exactly (real modules back, injected
stubs removed) in a finally block. Full suite now matches master baseline
(11537 passed, only the 2 known cron-isolation artifacts).
2026-07-01 22:12:30 +00:00
nesquena-hermes
c191141615 fix #5368 2026-07-01 21:35:11 +00:00
nesquena-hermes
2574e6bef9 stage #5317 2026-07-01 21:10:21 +00:00
nesquena-hermes
4a3b820e22 stage #5370 2026-07-01 21:06:10 +00:00
nesquena-hermes
c8d9e4c1ad stage #5373 2026-07-01 20:49:03 +00:00
nesquena-hermes
3673028a26 stage #5365 2026-07-01 20:35:45 +00:00
b3nw
6f9c648d23 fix(tests): more _read_profile_model_config stubs need 3-tuple 2026-07-01 20:33:09 +00:00
Rod Boev
feaf9a423e fix(#4737): retry even when the synth fallback is empty 2026-07-01 16:30:34 -04:00
b3nw
6d06001b6f fix(tests): update _read_profile_model_config callers/assertions for 3-tuple return 2026-07-01 20:24:57 +00:00
Rod Boev
c5378cb96a fix(#4737): skip stale live-model fetch before catalog retry 2026-07-01 16:23:36 -04:00
Rod Boev
a609b99e76 fix(#4251): guard provider ownership under the session lock 2026-07-01 16:19:53 -04:00
b3nw
a477069e3a fix(model): address gate feedback #4857080754 — slug names, list models, get_config, single YAML parse
- _repair_bare_custom_provider_model matches display-named providers via
  _custom_provider_slug_from_name (custom:my-proxy matches 'My Proxy').
- _ordered_custom_provider_model_ids now handles dict keys, list strings,
  and list dicts with id/model/name, aligned with api/config.py catalog.
- config_obj=None fallback uses get_config() instead of raw cfg.
- _read_profile_model_config returns profile config dict too, avoiding a
  second YAML parse on hot display paths.
- Tests added for slug matching, list-form models, and get_config fallback.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 20:12:38 +00:00
Rod Boev
d0e99e9d84 fix(#4737): preserve boot redirect handling on catalog retry 2026-07-01 16:03:08 -04:00
Rod Boev
d73bea19cd fix(#4737): retry model catalog fetch once after cold-cache fallback 2026-07-01 15:56:04 -04:00
Rod Boev
5c7ff9c70e fix(#4251): preserve raced picker ownership through profile repair 2026-07-01 15:42:57 -04:00
nesquena-hermes
ab38ffe25f Fix profile skills-stats thundering herd at cold startup (#5364)
The two-tier mtime cache from #4783 fixed the per-request SKILL.md rescan
but left two concurrency holes that only bite at container cold start,
when the frontend fires several profile-data requests at once and the
caches are empty:

1. `_get_profile_skills_stats()` had no lock, so concurrent misses on the
   same profile each ran `os.walk(followlinks=True)` + parsed every
   SKILL.md simultaneously.
2. `_build_profile_rows_fast()` ran outside `_LIST_PROFILES_CACHE_LOCK`
   in `list_profiles_api()`, so every concurrent request rebuilt all rows
   (each walking every profile's skill tree) at once.

With ThreadingHTTPServer (one OS thread per request) and Docker overlay2,
this stacked thousands of concurrent stat() calls and stalled workers
57-70s (per the report's thread dumps).

Fix:
- Add a per-profile compute lock (registry guarded by a meta-lock) and
  use double-checked locking in `_get_profile_skills_stats()`: concurrent
  misses on one profile collapse to a single compute, while independent
  profiles still compute in parallel.
- Single-flight the row build in `list_profiles_api()` by holding
  `_LIST_PROFILES_CACHE_LOCK` across the build + cache write. Lock order
  is strictly list-lock -> per-profile skills-lock, so no deadlock.

The report's third suggestion (debounce the mtime probe) is deliberately
NOT taken: the every-call cheap probe is the #4783 out-of-band
change-detection contract (test_issue4783 asserts it MUST run on every
call). Serializing the misses removes the herd without weakening that
contract, since only the expensive compute is guarded, not the probe.

Adds tests/test_issue5364_skills_stats_thundering_herd.py proving the
herd collapses (single compute / single build under a concurrent burst),
independent profiles still parallelize, and the every-call probe contract
is preserved. All existing #4783 contract tests still pass.

Co-authored-by: claw-io <claw-io@users.noreply.github.com>
2026-07-01 19:42:11 +00:00
nesquena-hermes
17b0998c0b stage #5313 2026-07-01 19:35:42 +00:00
Rod Boev
160a397c48 fix(#4251): stop in-flight turns from reverting picker model choice 2026-07-01 15:35:08 -04:00
Rod Boev
c561cf546b fix(#5340): use local time for pasted-text filenames 2026-07-01 15:22:59 -04:00
nesquena-hermes
2de28f5e14 stage #5360 2026-07-01 19:08:24 +00:00
nesquena-hermes
dd9216c770 stage #5357 2026-07-01 18:46:44 +00:00
hermes-agent
741f8fed70 fix(webui): align reconciliation dedup key with workspace-prefix stripping (#5339)
_session_message_content_key (the state.db reconciliation key in
api/models.py) normalized whitespace only, while the streaming-side
identity _message_identity strips the workspace prefix for user turns.
WebUI sends the model a workspace-prefixed user_message
([Workspace::v1: /path]\n<text>) while the visible/optimistic bubble and
sidecar row carry the bare <text>. The mismatch made a prefixed state.db
row and a bare sidecar row key differently, so state_db_delta_after_context
failed to align them, treated the state.db copy as new, and appended a
duplicate user turn. The agent-side merge then concatenated the two
adjacent user rows into a permanent composite -- the post-restart
stale-user-prepend bug.

Fix: strip the workspace prefix for role=='user' in the reconciliation
key, reusing the same _strip_workspace_prefix helper the streaming side
uses (lazy import to avoid the api.streaming -> api.models cycle) so the
two dedup layers can't drift again. Assistant/tool keys are unchanged and
prefix-free user messages key identically (idempotent).

Fixes #5339.
2026-07-01 18:45:18 +00:00
nesquena-hermes
2693a75d89 stage #5354 2026-07-01 18:41:03 +00:00
nesquena-hermes
30d8edc5ea stage #5353 2026-07-01 18:36:29 +00:00
nesquena-hermes
6caaced8eb fix(webui): bound in-memory SESSIONS cache with lazy reload (#4765)
Root cause of the silent-crash-after-hours cluster (#4765/#2233/#4633): the
global in-memory SESSIONS LRU evicted with a blind popitem(last=False), which
could drop an actively streaming or not-yet-persisted session (data loss) and
was capped only via an env var. On long-running installs the effective result
was unbounded RAM growth until segfault.

- Add _session_is_evictable(): a session is evictable ONLY when it is not
  streaming (no active_stream_id), has no in-flight turn (no pending_user_message
  / pending_started_at), and its full state is proven on disk (sidecar
  message_count >= in-memory count; metadata-only stubs and zero-message shells
  are trivially safe).
- Add _evict_sessions_over_cap(): replaces all 8 blind popitem loops across
  models.py, routes.py, streaming.py. Walks the LRU oldest-first and removes only
  provably-safe entries; never acquires LOCK/stream locks itself (caller holds
  LOCK) so no lock-ordering deadlock. May briefly exceed the cap rather than ever
  evict an active/unsaved session.
- Make the cap configurable via config.yaml webui.sessions_cache_max
  (get_sessions_cache_max()); precedence config.yaml -> HERMES_WEBUI_SESSIONS_MAX
  (legacy) -> DEFAULT_SESSIONS_CACHE_MAX=300. No new HERMES_* env var. Invalid or
  <1 values fall back so a typo can never disable the bound.
- Evicted sessions lazily reload from their JSON sidecar via the existing
  get_session() accessor; no call sites changed. _index.json sidebar behavior
  unchanged (the sidebar reads the index, not SESSIONS).
- Add tests/test_issue4765_sessions_lru_eviction.py (8 tests): eviction past
  cap, active/streaming never evicted, unsaved/stale-tail never evicted, lazy
  reload with identical content, and no-data-loss under heavy churn.
- README: document the config.yaml key + safety semantics.

Fixes #4765.
2026-07-01 18:14:39 +00:00
nesquena-hermes
8b8ee92eca fix(webui): overlay real state.db message count for subagent children so they don't vanish from the sidebar (#5308)
Regression seam behind #5308: a delegated subagent child's sidebar row is built
from a stale sidecar that reports message_count==0, and the state.db count
overlay in _apply_sidebar_state_db_override_metadata was gated on
`state_db_source == 'webui'`. A subagent child (state_db_source=='subagent')
therefore never received its true message count, so the front-end visibility
predicate (_sidebarRowHasVisibleMessages) dropped the row and the subagent
session disappeared entirely (not nested, not orphaned) after #5244+#5306.

Fix: widen the count/last-message overlay to `state_db_source in ('webui','subagent')`,
keeping the same conservative anti-resurrection guard. The source-tag/title
reassignment stays WebUI-only so a subagent child keeps its subagent
classification. Same state.db-blind-metadata root as the #5307 transcript
recovery, fixed server-side rather than by loosening the front-end predicate
(which would fight the #5306 active-parent scoping).

Tests: subagent child gets its count overlaid + classification preserved; a
non-webui/non-subagent foreign source (cron) still gets NO overlay.

Fixes #5308.
2026-07-01 18:06:21 +00:00
hermes-agent
5bca5db900 fix(webui): crash visibility — faulthandler + thread excepthook + exit audit (#4633)
server.py exited SILENTLY after 9-16h: no traceback, no shutdown-audit line,
no core dump — the log just stopped mid-request. It runs a ThreadingHTTPServer
with daemon_threads=True, so an unhandled exception in a request/SSE/long-poll
handler thread could terminate work with nothing recorded, and faulthandler was
not enabled so a native crash left nothing at all. The WebUI also configures no
logging handlers, so INFO/ERROR records are dropped by logging's lastResort
filter (WARNING+ only) — meaning even the existing shutdown audit never reached
the log.

Add api/crash_visibility.py (stdlib-only, hooks never raise) and wire
install_crash_visibility() into server.main() before any heavy startup:
  * faulthandler.enable(all_threads=True) — native crash dumps a C-level
    traceback; SIGUSR1 registered for on-demand hang diagnosis.
  * threading.excepthook — logs uncaught daemon/handler-thread exceptions
    (thread name, ident, traceback) instead of losing them silently.
  * sys.excepthook — logs uncaught main-thread exceptions (KeyboardInterrupt
    preserved).
  * atexit exit-audit breadcrumb — a clean/unwound exit is recorded; its
    absence narrows a silent death to an un-unwound kill (OOM/SIGKILL/abort).

All diagnostics are written directly to the fault stream (stderr, which the
bootstrap redirects into the WebUI log) AND mirrored through logging, so the
line is guaranteed to land regardless of logging config.

No request-handling behavior change, no new deps. Paired memory root-cause: #4765.

Fixes #4633.
2026-07-01 18:01:39 +00:00
nesquena-hermes
86c9ec0a85 fix(webui): drop verification-stop synthetic nudge from transcript (#5334) 2026-07-01 18:00:28 +00:00
allenliang2022
d06e463e2b test(chat): stub _postProcessWithAnchorSuppression in the renderMessages node harness (#5338)
The Node-executed gate in test_anchor_fallback_ownership.py
(test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds)
eval()s the real renderMessages(). Commit 7536f6f1 made renderMessages schedule
its post-render pass via _postProcessWithAnchorSuppression(), but the harness
only stubbed postProcessRenderedMessages() — so the eval threw
'ReferenceError: _postProcessWithAnchorSuppression is not defined' and the test
failed on Linux CI (shard 2). It passed locally only because Windows hit the
unrelated WinError 206 command-line-too-long first, masking the real error.

Add a no-op stub for _postProcessWithAnchorSuppression alongside the existing
postProcessRenderedMessages stub. Verified by dumping the generated node script
to a temp .js file and running 'node file.js' (bypassing the Windows -e length
limit): the eval no longer throws and the test's assertions pass.
2026-07-01 17:46:03 +00:00
allenliang2022
fbb21964a9 test(chat): update 6 post-render tests orphaned by the _postProcessWithAnchorSuppression refactor (#5338)
Commit 7536f6f1 routed the deferred post-render dispatches through
_postProcessWithAnchorSuppression() (holds overflow-anchor suppression across
the async media/layout settle frame, then calls postProcessRenderedMessages).
Six pre-existing tests string-matched the old
'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' literal and
failed on the rename — behavior is preserved (the wrapper still invokes
postProcessRenderedMessages), so this is a test-fix not a code-fix.

Per the gate-cert recommendation, the tests now assert the BEHAVIOR chain
(post-render is scheduled via _postProcessWithAnchorSuppression, and that
wrapper calls postProcessRenderedMessages) rather than the exact rAF literal, so
a future wrapper rename can't re-orphan them.

Files: test_csv_table_rendering, test_excalidraw_inline_embed,
test_issue483_inline_diff_viewer, test_issue484_json_tree_viewer, test_issue347,
test_pdf_html_preview. Verified: the 6 updated assertions pass locally (the only
local failures are the pre-existing Windows-only WinError 206 command-line-too-long
in Node-harness tests, unrelated, green on Linux CI).
2026-07-01 17:46:03 +00:00
allenliang2022
7db6fd8650 fix(chat): keep overflow-anchor suppressed across the async post-render settle window (mobile jump-back)
The sync-frame guards (_fixMobileScrollJank / _suppressBrowserOverflowAnchor)
only cover the render frame itself. postProcessRenderedMessages() — syntax
highlight, inline diff/csv/pdf/html/excalidraw, katex/mermaid — is scheduled a
FRAME LATER via requestAnimationFrame(), after those guards have released. Each
of those can change the height of rows ABOVE the viewport; on mobile
(overflow-anchor:auto) the browser's native anchor engine then compensates
scrollTop a SECOND time in that unguarded frame, yanking an unpinned reader to
another turn (the residual mobile 往回大跳).

Wrap all three deferred post-process dispatches (fast-path cache branch, main
render tail, live-tool remount) in _postProcessWithAnchorSuppression(), which
routes through the shared _suppressBrowserOverflowAnchor() and holds suppression
one extra frame so late media/layout reflow is covered too. Desktop rests at
overflow-anchor:none so the wrapper is a verified no-op there.

Reproduced on an isolated debug instance with a cloned 1179-message session:
above-viewport +350px during the async settle window jumped scrollTop +350 on
mobile (auto) and 0 with the wrapper; desktop (none) 0 both ways. static/ui.js
only.
2026-07-01 17:46:03 +00:00
nesquena-hermes
875eb33d97 stage #5328 2026-07-01 17:22:49 +00:00
nesquena-hermes
5f01033c44 test: drop unused pytest import (ruff F401) — from #5346 be894353 2026-07-01 17:17:11 +00:00
Anthony Ruiz
20260e2c29 fix(webui): keep handled clarify 404s out of warn logs 2026-07-01 17:17:11 +00:00
nesquena-hermes
0025466168 test: make cancelStream harnesses tolerate the new reason param + stdout provenance log
Codex gate on #5346 flagged two brittle static extractors that broke on the
cancelStream(reason) signature change + the new '[stream] cancel requested'
stdout log:

- test_cancel_stream_owner_guard.py: the Node harness parsed ALL of stdout as
  JSON; the provenance console.info line (fires during runAll) polluted it.
  Parse the LAST non-empty stdout line (the result JSON is always emitted last,
  after runAll resolves).
- test_sprint36.py: two extractors did src.find('async function cancelStream()')
  (exact, no params). Switched to a signature-tolerant regex and widened the
  catch-block window (the provenance log/comments now precede the try/catch).

Both pre-existing tests, updated to match the intentional #5345 change (not the
code bent to fit the test).
2026-07-01 17:17:11 +00:00
nesquena-hermes
6716f03f4d fix(webui): stop false clarify-unavailable toast; add interrupt provenance (#5345)
/api/clarify/pending always returns HTTP 200 when present (returns
{"pending": null} for an unknown session — it never 404s). The front-end
clarify poller warned "Clarify endpoint unavailable. Please restart
server." on ANY caught error whose message merely contained "404" or
"not found", so an unrelated stale-session 404 ("Session not found", e.g.
an old-profile session polling briefly after a profile switch) or a
transient error produced a misleading missing-endpoint toast that pointed
operators at the wrong layer.

Clarify polling now branches on the structured HTTP status that api()
attaches to the thrown Error (err.status):
- 404 "Session not found" -> handled as a stale-session poll (stop + hide
  card silently), no toast;
- restart-server warning fires only on a genuine route-not-found 404 whose
  body is NOT session-scoped;
- poll failures are logged with path, status, polling session id, and
  current session id for diagnosis.

Interrupt provenance: cancelStream()/cancelSessionStream() now log a
'[stream] cancel requested' line with the trigger reason
(composer-stop / slash-stop / slash-interrupt / busy-interrupt /
sidebar-stop). Passive UI lifecycle events (session switch, tab hide, page
unload) already tear down only the local SSE transport via
closeLiveStream() and never call /api/chat/cancel — only explicit
Stop/interrupt paths interrupt the backend agent/tool run. This is
confirmed by test_clarify_pending_never_404s locking the handler shape.

Supersedes #5343 (which handled only the profile-switch sub-case and kept
the broad message-scrape). Adds tests/test_issue5345_*.py (9 tests).

Co-authored-by: claw-io <claw-io@users.noreply.github.com>
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
2026-07-01 17:17:11 +00:00
Anthony Ruiz
76ae2b795c test(webui): make live-transition guard anchors explicit 2026-07-01 15:50:24 +00:00
Charles Inglis
f9de93f42e refactor: make nested-route reasoning deny boundary-based, not prefix-based
Structural hardening per user request, following the 3rd round of the
same bypass class on PR #5313's reasoning_efforts feature:

Round 1: plain-ordering regression (provider-config short-circuit ran
before the nested-route deny).
Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...)
left a slug fragment that the deny's prefix-match missed.

Both were legitimate, narrowly-targeted fixes, but the pattern —
_nested_route_reasoning_denied() requiring the model string to START
WITH the route prefix — meant every future wrapper/nesting scheme
would need the strip logic to be updated in lockstep, and a missed
case fails OPEN (reasoning re-enabled on a route that must never show
it), which is the wrong failure mode for a security-adjacent guard.

This commit removes that class of bug instead of patching its latest
instance: _nested_route_reasoning_denied() now searches for the
vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at
a non-alphanumeric boundary, rather than requiring it at position 0.
Correctness no longer depends on _strip_provider_hint_for_reasoning()
having stripped exactly the right prefix first — any number of opaque
wrapper layers (@provider:, a named custom-provider slug, or any
nesting scheme not yet invented) can precede the route and the deny
still fires.

Verified: all historical bypass strings (round 1 and round 2, plus a
hypothetical deeper double-wrapped case) now correctly deny; embedded
substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly
don't, thanks to the boundary lookbehind.

Added test_nested_route_deny_is_boundary_based_not_prefix_based
locking in the structural invariant directly, independent of any
particular wrapper scheme.

Verification:
./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py
# 54 passed in 1.31s

Full suite: 11257 passed, 64 pre-existing failures (identical file/test
set as the prior commit's baseline — network/credential/profile-isolation/fd-leak
tests, unrelated to reasoning_efforts).
2026-07-01 10:02:45 -05:00
b3nw
3682d237d7 test: fix CI stubs for profile_config; widen #1855 source window
CI Tests job (run 28525484615) failed one test per shard:
- Stubs for _resolve_compatible_session_model_state lacked profile_config
  (wakeup wiring spy, start_session_turn runtime adapter fixture).
- #1855 structural test used 6k char slice; resolver helper outgrew window.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 15:00:04 +00:00
b3nw
1feb6f8280 fix(model): gate feedback — profile-scoped repair, malformed config (#5317)
Address nesquena-hermes gate certification on PR #5317:
- _repair_bare_custom_provider_model: coerce config values via str();
  use api.config._custom_provider_entries; optional config_obj for
  profile config.yaml (not process-global cfg).
- Thread profile_config from _load_profile_config_dict through session
  display, chat/start, wakeup, goal, and sync resolvers.
- Tests: malformed name=None entry; profile vs global collision.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 14:35:56 +00:00