Commit graph

6779 commits

Author SHA1 Message Date
Konstantin M
ec31891c60 perf(webui/session-load-latency) tier1a: cheap-first freshness check in _cached_session_lags_disk
Prior to this change _cached_session_lags_disk() called
Session.load_metadata_only(sid) on every LRU cache hit, parsing the
full sidecar JSON (~15-20ms even for a 1.3MB sidecar on Celeron+ eMMC).
For draft auto-saves hitting get_session() on every keystroke debounce
(~400ms cadence while typing), that 15-20ms multiplied out to ~75% of
the request's wall time on the Chromebook.

Reorder the check: read only the JSON metadata prefix
(_persisted_message_count, already defined for the LRU-eviction path)
to compare message counts. For INACTIVE sessions
(no active_stream_id, no pending_user_message) with matching counts,
the cache is provably at parity with disk — anchor-scene records cannot
have advanced without the message count advancing too, and the full
Session.load_metadata_only() is skipped. For ACTIVE sessions, or when
counts differ, fall through to the original anchor-scene comparison
path so correctness is preserved.

Measured cost reduction on the request hot path: 15-20ms -> <1ms on
the typical cache-hit case (inactive session, count matches). The
slow path is unchanged for cases that genuinely need the full reload.

No public API change. No behavior change observable on user-facing
flows. The LRU eviction safety invariant (#4765) is preserved —
_session_is_evictable() still calls _persisted_message_count under
the same conditions, and Tier 1a adds NO new eviction gate.
2026-07-08 22:39:08 +00:00
nesquena-hermes
3730a4a551
Merge pull request #5796 from nesquena/release/stage-5784
Release: evict run-journal writer-lock cache on journal delete (#5784)
2026-07-08 14:23:09 -07:00
nesquena-hermes
37adf8b7ce docs(changelog): #5784 evict run-journal writer-lock cache on delete 2026-07-08 21:14:04 +00:00
ai-ag2026
c5b15850e5 Fix run-journal writer-lock cache leak on session delete
`_lock_for` caches one threading.Lock per (dir, file, pid) in the module-global
`_WRITER_LOCKS`, but nothing evicted those entries: `delete_run_journal` rmtree'd
the on-disk `_run_journal/{sid}/` directory yet left the cached lock objects
behind, so a long-lived gateway leaked one entry per deleted run forever.

Delete now evicts every cached lock whose parent directory matches the removed
session (pid-independent) under `_WRITER_LOCKS_GUARD`, leaving unrelated
sessions' locks intact.

Refs #4633, #2097.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:13:32 +00:00
nesquena-hermes
6ab95bffa4
Merge pull request #5794 from nesquena/release/stage-5783
Release: serialize daemon-thread start under a lifecycle lock (#5783)
2026-07-08 14:03:05 -07:00
nesquena-hermes
81a77efc91 docs(changelog): #5783 serialize daemon-thread start under lifecycle lock 2026-07-08 20:58:54 +00:00
ai-ag2026
d505b45545 fix(bg): serialize daemon-thread start under a lifecycle lock
start_drain_thread and start_session_channel_reaper checked is_alive()
and then created + started the daemon thread without holding a lock, a
check-then-act race: two concurrent callers could both see "not alive"
and each spawn a thread. The loser's thread was never stored in the
module global and ran forever, un-joinable by the matching stop_*. Both
check-then-start sequences now run under a dedicated
_THREAD_LIFECYCLE_LOCK (kept separate from the purpose-bound
SESSION_CHANNELS_LOCK / _EMIT_COALESCE_LOCK), so exactly one thread is
ever created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:58:18 +00:00
nesquena-hermes
460f3eb47d
Merge pull request #5793 from nesquena/release/stage-5781
Release: sweep completion-dedup map by delivery lifecycle (#5781)
2026-07-08 13:47:35 -07:00
nesquena-hermes
4f9d63218b docs(changelog): #5781 sweep completion-dedup map by delivery lifecycle 2026-07-08 20:42:35 +00:00
ai-ag2026
15470a9a35 fix(bg): sweep completion-dedup map by delivery lifecycle, not channel (#4633)
BG_TASK_COMPLETE_EVENTS_SEEN gained a session_id -> set[process_id] entry the
first time a bg task completed for a session and was never deleted anywhere, so
it grew unbounded for the server lifetime.

The entry is created in _process_one for EVERY completion, whether or not any
SSE channel/tab exists, so pruning it only when the SessionChannel is reaped
would miss the dominant headless case (task fires, tab closed or never opened —
no channel to collect). Instead the reaper now sweeps the map by DELIVERY: once
a completion is drained (its session_id removed from PENDING_BG_TASK_COMPLETIONS)
the short _move_to_finished dedup window is closed and the entry is swept, every
tick, regardless of any channel. The registry's per-process_id
_completion_consumed gate remains the primary idempotency backstop, so sweeping a
delivered session's set can never resurrect an already-delivered completion.

Session deletion also prunes the entry (new forget_bg_task_completion_dedup),
covering a session deleted while a completion is still pending (which the
delivery-gated sweep deliberately retains).

Refs #4633
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:41:59 +00:00
nesquena-hermes
c008e4443a
Merge pull request #5791 from nesquena/release/stage-5780
Release: open state.db read-only for lineage reads + watcher (#5780)
2026-07-08 13:22:57 -07:00
nesquena-hermes
3a8de74758 docs(changelog): #5780 open state.db read-only for lineage reads + watcher 2026-07-08 20:18:25 +00:00
ai-ag2026
b967d89c3a fix(sessions): open state.db read-only for lineage reads + watcher projection
The session-listing path already opens the live agent state.db read-only
(file:...?mode=ro) so a write-capable handle doesn't add checkpoint/lock
surface while the agent streams into the same WAL DB (#5455). Three pure-read
projections were missed and still opened a read-WRITE connection:

  - read_session_lineage_report
  - read_session_lineage_metadata
  - the gateway-watcher fingerprint projection (a 5s poll)

Route all three through a shared open_state_db_readonly() helper that mirrors
the listing path: read-only file: URI with a writable fallback that warns. The
self-heal write path (missing idx_messages_session) is intentionally left
writable. No behavior change beyond open mode; reads perform zero writes.

Refs #5455

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:17:45 +00:00
nesquena-hermes
16272dd646
Merge pull request #5777 from nesquena/release/stage-5772
Release: scope MCP list config reads to active profile (#5772)
2026-07-08 09:23:22 -07:00
nesquena-hermes
c95c1f492d docs(changelog): #5772 scope MCP list reads to active profile 2026-07-08 16:19:34 +00:00
Rod Boev
f4172f73b0 fix(mcp): preserve config overrides for profile reads (#5619) 2026-07-08 16:18:52 +00:00
Rod Boev
3c8d9f851e fix(mcp): scope list config reads to active profile (#5619) 2026-07-08 16:18:52 +00:00
nesquena-hermes
07b9708cde
Merge pull request #5756 from nesquena/release/stage-5751
Release: non-virtualized transcript scroll jump-back (#5751)
2026-07-07 14:27:32 -07:00
nesquena-hermes
e4eb330da5 docs(changelog): #5751 non-virtualized transcript scroll jump-back 2026-07-07 21:17:41 +00:00
allenliang2022
e8a3412939 fix(webui): reserve real user-row height in non-virtualized transcripts to stop scroll jump-back
When transcript virtualization is disabled (the #4325 opt-out,
_virtualizeTranscript===false), renderMessages() renders every row with no
windowing and never runs the virtualized measure pass
(_updateMessageVirtualMeasurements early-returns when !virtualized). Under
@media (pointer: coarse), .msg-row[data-role="user"] carries
content-visibility: auto; contain-intrinsic-size: auto 96px. Every rebuild does
inner.innerHTML='' then recreates rows as fresh elements, so a fresh off-screen
tall user row (a long paste measuring thousands of px) reserves only the flat
estimate instead of its real height. scrollHeight shrinks by (realHeight -
estimate), the browser force-clamps scrollTop, and the viewport jumps backward
(a browser clamp, JS=none, so scrollTop-write compensation cannot catch it).
#5638 fixed this for the virtualized wipe-and-rebuild path but left the
non-virtualized full-rebuild path uncovered.

Fix, three coordinated parts:
- _estimateUserRowIntrinsicHeight weights CJK / full-width characters as ~2
  columns (they wrap at ~24 chars/line, not 48), so a CJK paste reserves close
  to its real height even before it is ever measured.
- _applyUserRowIntrinsicHeight reserves max(remembered, estimate): a remembered
  height can be a partial paint (a row taller than the viewport only paints its
  intersecting slice under content-visibility:auto), so the estimate floors it.
- _rememberRenderedUserRowIntrinsicHeights, called pre-wipe inside
  renderMessages, reads the still-laid-out rows' real heights and persists them
  keyed by session-relative index, only for rows within the viewport (a fully
  off-screen never-painted row reports its collapsed reserve and must not poison
  the map), floored at the estimate.

Desktop rests at content-visibility:visible so intrinsic-size is inert there;
verified no behavior change with pointer:fine.

Adds tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py (7
mutation-checked node-harness tests). Existing #5637/#5638 suites and the
render/virtualization suites pass.
2026-07-07 21:17:03 +00:00
nesquena-hermes
923cadcb80
Merge pull request #5754 from nesquena/release/stage-5742
Release: desktop stale-snapshot scroll compensation (#5742)
2026-07-07 13:19:28 -07:00
nesquena-hermes
b40bc50d67 docs(changelog): #5742 desktop stale-snapshot scroll compensation 2026-07-07 20:13:23 +00:00
allenliang2022
fbfb6da8fe fix(webui): require an actual captured topPadBefore before the gone-anchor topPad-delta (greptile P1)
When the anchor row is gone and a virtual top-spacer is present but the snapshot
carries no captured topPadBefore (null), Number(null) is 0, so the topPad-delta
branch would add the ENTIRE current spacer height to scrollTop and fling the reader
far from their content. Guard on an actual (non-null) captured topPadBefore; else
keep the raw fallback target. Mutation-checked (drop the null guard -> the reader is
flung 1300px and the test fails).
2026-07-07 20:12:41 +00:00
allenliang2022
7164847255 fix(webui): realign desktop stale-snapshot fallback with the app's scrollTop += delta idiom (#5637 follow-up)
Round-2 gate-cert proved the round-1 'snapshot.top + delta' + nearest-to-current
arbiter is mathematically unwinnable: a row's rect offset is scroll-relative
(rect.top - containerRect.top = rowContentPos - scrollTop), so a staged absolute
target only equals the true content-hold when scrollTop == snapshot.top — and in
that exact case the arbiter measures |raw - cur| = 0 and picks raw, writing the
stale value (the reported up-jump, unfixed).

Rework to the app's own realign idiom (mirrors _restoreMessageViewportAnchor and
_compensateScrollForMeasurementDelta): when a measurable anchor row exists, write
el.scrollTop += (currentOffset - capturedOffset) from the LIVE scrollTop — a no-op
when already aligned, a heal when not, no arbiter. Restore the per-tier lookup
guard (key -> sessionIdx, never the rawIdx degradation that maps to a different
message after a virtualization re-window). Genuinely-gone anchor: mirror the
topPad-delta idiom the snapshot already carries (topPadBefore), else keep raw.

Gate-cert tests now use scroll-dependent rect mocks (rect.top = rowContentPos -
scrollTop) so certified hold numbers are physically realizable; mutation-checked
(revert to raw -> the hold tests fail; drop topPad-delta -> that test fails).
2026-07-07 20:12:41 +00:00
nesquena-hermes
57aa5be2ed
Merge pull request #5753 from nesquena/release/stage-5463-v2
Release: mirror WebUI usage counters to state.db (#5463)
2026-07-07 13:12:06 -07:00
nesquena-hermes
5915f9b5b5 docs(changelog): #5463 mirror WebUI usage counters to state.db 2026-07-07 20:05:18 +00:00
kosta
0560f2615b fix(state-sync): mirror WebUI API call counts 2026-07-07 19:48:19 +00:00
kosta
059a06b70d fix(state-sync): mirror WebUI cache counters 2026-07-07 19:48:19 +00:00
nesquena-hermes
1b58e4df47
Merge pull request #5750 from nesquena/release/stage-5744
Release: clear dead steer busy state (#5723)
2026-07-07 09:45:35 -07:00
nesquena-hermes
cf769dc823 docs(changelog): #5723 clear dead steer busy state 2026-07-07 16:42:08 +00:00
rodboev
8b02a8c260 fix(#5723): complete stale-busy cleanup on dead-run recovery (Fable UX-gate)
_steerClearCurrentOwnerDeadRun cleared busy/activeStreamId/inflight but left
the status line, elapsed timer, and optimistic streaming badge lingering after
a recovered dead run. Add the rest of the app's stale-busy cleanup idiom
(setStatus/setComposerStatus/_clearActivityElapsedTimer/clearOptimisticSessionStreaming),
still bypassing setBusy(false) so its queue-drain can't clobber the restored draft.
2026-07-07 16:32:26 +00:00
nesquena-hermes
c3ff9f41c7
Merge pull request #5741 from nesquena/release/stage-5738
Release: keep the composer above iPad keyboards (#5701)
2026-07-06 22:20:06 -07:00
nesquena-hermes
b65158c0ce docs(changelog): #5701 iPad keyboard composer inset 2026-07-07 05:16:34 +00:00
rodboev
56a395c5f3 fix(#5701): ignore pinch-zoom scale in keyboard-inset (Fable UX-gate)
vv.scale != 1 means the visual-viewport shrinkage reflects zoom, not the
keyboard; on Chromium 'force enable zoom' that produced a large spurious inset
that jittered on pan. Bail out of the inset when |vv.scale-1|>0.05. + test.
2026-07-07 05:07:19 +00:00
rodboev
ae8d0bf3fb fix(#5701): keep the composer above iPad keyboards
On touch-primary devices (iPad Safari) the on-screen keyboard shrinks the
visual viewport but not the layout viewport, so the bottom-pinned composer
sits behind the keyboard. Compute a --keyboard-bottom-inset from visualViewport
and pad the composer up by it; clear it when the keyboard closes / on
non-touch / no-visualViewport. Reuses the existing visualViewport + pageshow
hooks; no-op on desktop/fine-pointer.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-07 05:05:11 +00:00
nesquena-hermes
021bc1280f
Merge pull request #5740 from nesquena/release/stage-5737
Release: drop empty tool_calls arrays before API send (#5737)
2026-07-06 21:56:50 -07:00
nesquena-hermes
1a2dcc7231 docs(changelog): #5737 drop empty tool_calls before API send 2026-07-07 04:52:55 +00:00
Swan-S
ccd62f9b9d fix(streaming): drop empty tool_calls arrays before API send (#5737)
Strict providers (DeepSeek v4, newer OpenAI) reject an assistant message
carrying tool_calls: [] with HTTP 400. _sanitize_messages_for_api already
strips orphaned tool results, but not an assistant message that literally
stores an empty tool_calls list.

Drop the empty key on ALL THREE sanitized-dict sites that must agree:
_sanitize_messages_for_api, _api_safe_message_positions, and the
_safe_projection inside _restore_reasoning_metadata (so a tool_calls: [] row
projects identically on both sides of the metadata-carry-forward alignment and
doesn't lose its reasoning/id/timestamp every turn — Fable gate follow-up,
folded in).

Added mutation-checked regression tests: empty tool_calls dropped + populated
chain preserved (both sanitizer paths), and metadata carry-forward survives a
tool_calls: [] row.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-07 04:43:46 +00:00
nesquena-hermes
ad7d0abf44
Merge pull request #5736 from nesquena/release/stage-tlsflake
test(tls): drain full stdout for the TLS-fallback assertion (flake fix)
2026-07-06 21:14:27 -07:00
nesquena-hermes
f36dad7b23 test(tls): drain full stdout for the TLS-fallback assertion (flake fix)
test_tls_startup_failure_fallback_to_http read only the first 2000 bytes of
server stdout to assert 'TLS setup failed'. When the test interpreter lacks
optional deps (requests/websockets), startup emits a burst of plugin/tool
import warnings + the startup-config banner BEFORE the TLS line, pushing the
marker past the 2000-byte window -> spurious failure. Drain all
currently-available stdout (5s deadline, short-circuit on the marker) instead
of a single fixed-size read. Verified failing->passing in a fresh venv without
the optional deps. Test-only; distinct from the agent-package-isolation flake
cause already fixed in the same test.
2026-07-07 04:09:15 +00:00
nesquena-hermes
b0c79fd4d3
Merge pull request #5735 from nesquena/release/stage-5567-resolver
Release: #5567 model-picker provider resolution fix
2026-07-06 20:56:50 -07:00
nesquena-hermes
4fb7b03200 docs(changelog): #5567 model-picker provider resolution fix 2026-07-07 03:52:04 +00:00
nesquena-hermes
8d496ceaef fix(#5567): resolve model provider by matching value, not the selected option
Frontend fix for the model-picker provider-contamination vector of #5567 (the
backend HERMES_HOME vector shipped in #5577). Credit @b3nw for the root-cause trace.

_modelStateForSelect(sel, modelId) resolved the provider from sel.selectedOptions[0]
(the dropdown's CURRENTLY-selected option) instead of the option whose value matches
modelId. During a profile/tab switch or a model-list rebuild the dropdown transiently
still holds the PREVIOUS profile's default option (e.g. an ollama model), so the wrong
provider was stamped onto a model it doesn't own, persisted into the session's
model_provider, and (since _modelProviderForSend reads the stored value first) re-sent
on every turn -> 'Provider ollama...no API key' for a provider the session never used.

Fix: resolve the provider from the option whose VALUE === modelId; trust the
currently-selected option only when its value matches (preserving the user's exact
pick in the same-value/different-provider collision case). A genuinely-absent model
yields a null provider so the backend re-infers.

This is the WRITE-site fix — it stops all NEW contamination (new-session + send paths
route through _modelStateForSelect). Both Codex and Fable reviewed it SAFE.

Repairing sessions ALREADY poisoned before this shipped is deliberately deferred to a
separate follow-up: a 3-round Codex+Fable gate proved a frontend load-time repair that
reads the shared #modelSelect DOM is a cross-session race surface (it re-manufactured
the reverse-contamination it aimed to fix), and it can't reliably heal live-only-model
sessions anyway. The robust place for that repair is the backend chat-start boundary,
where session + profile identity is unambiguous. Already-poisoned sessions self-heal on
their next healthy send / manual re-pick in the meantime.

Tests: node-behavioral resolver scenarios (tab-switch race, same-value collision,
missing-model null, match-by-value, default->null) + the unrelated-selected-option
case from #5727. Partially addresses #5567 (write-vector); follow-up tracks the
already-poisoned-session repair.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-07 03:42:16 +00:00
nesquena-hermes
1d8208026c
Merge pull request #5728 from nesquena/release/stage-5419
Release: auto-switch profiles for cross-profile session links (#5419)
2026-07-06 19:39:22 -07:00
Daniel Harcek
407451977e fix: auto-switch profiles for session links (#5419)
A valid session:// deep link to a session owned by a DIFFERENT Hermes
profile used to look identical to a deleted session (404 -> frontend
self-heals away). Now GET /api/session returns a structured 409
session_profile_mismatch envelope (error/code/session_id/profile ONLY,
no transcript) ONLY when the owning profile is KNOWN, and loadSession()
catches it, switches to the owning profile, and retries once. Truly
missing/deleted or legacy None-profile sessions keep the 404 self-heal.

Gate fixes applied (Codex + Fable):
- Codex CORE: added a post-await stale-load guard after
  _switchProfileForSessionLoad so a navigation during the switch can't
  hijack the UI back to the old session.
- Fable Finding 1: only emit 409 when _session_profile is truthy;
  a None-profile (missing/legacy) session under a non-default active
  profile now keeps 404 instead of a useless profile=null 409 (which
  skipped self-heal + spun the SSE reconnect against a dead sid). Both
  detail branches. + 2 regression tests.
- Fable Finding 2: _switchProfileForSessionLoad now clears the sidebar
  skeleton + re-renders from cache on switch-POST failure (mirrors the
  #4671 canonical-switch catch), then rethrows, so a failed switch can't
  strand the sidebar on the skeleton.

Reconciled tests/test_issue1611_session_profile_filtering.py (4 tests) to
the 409 contract while preserving the no-leak boundary assertion.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-07 02:35:02 +00:00
nesquena-hermes
243c0f698d
Merge pull request #5725 from nesquena/release/stage-5692
Release: Windows git subprocess console-window fix (#5692)
2026-07-06 19:25:03 -07:00
nesquena-hermes
eb02c8ecd0 docs(changelog): Windows git subprocess console-window fix (#5692) 2026-07-07 02:19:40 +00:00
Jinho Seok
f106fcd1f5 fix(workspace-git): hide git subprocess console windows on Windows (#5692)
Add creationflags to _run_git() and _config_names_for_scope() so git
child processes don't accumulate hidden cmd.exe/conhost windows on Windows.

Uses a local module-level _windows_hide_flags() helper (mirrors the
api/updates.py pattern: CREATE_NO_WINDOW on win32, 0 elsewhere) rather than
importing from the OPTIONAL hermes_cli package — a standalone/agent-less
WebUI (and the repo's own CI runners, which don't pip-install hermes-agent)
must keep full workspace-git functionality. windows_hide_flags() is a safe
no-op (0) on non-Windows. Scoped to workspace git commands.

Adds mutation-checked tests asserting the kwarg is wired on both sites.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-07 02:07:39 +00:00
nesquena-hermes
8fee7da3ee
Merge pull request #5724 from nesquena/release/stage-5717
Release: iteration-limit graceful summary (#5717)
2026-07-06 18:48:36 -07:00
nesquena-hermes
6c7746aef6 docs(changelog): iteration-limit graceful summary (#5717, #5494) 2026-07-07 01:44:48 +00:00