Commit graph

7187 commits

Author SHA1 Message Date
nesquena-hermes
45575f588a
Merge pull request #5812 from nesquena/release/stage-5770
Release: contain streaming code block layout (#5770)
2026-07-08 20:12:05 -07:00
nesquena-hermes
7b6a23f902 docs(changelog): #5770 code-block containment 2026-07-09 03:08:48 +00:00
Rod Boev
837a552746 fix(chat): contain streaming code block layout (#5760) 2026-07-09 03:08:19 +00:00
Konstantin M
db99f72505 perf(webui/session-load-latency) tier3d: Greptile P1 followups — inconclusive scene check + full-content config verification
Two Greptile P1 comments from review of b5c0447a (tier3c):

1. Inconclusive Prefix Stays Fresh (discussion_r3548477859):
   When _persisted_session_meta_prefix() returns None and cached has scene
   records, the fast-path skipped scene comparison entirely and returned
   False (cache fresh) for inactive equal-count sessions. Added
   _scene_check_inconclusive flag that forces fall-through to the full
   Session.load_metadata_only() comparison when the cheap prefix read
   fails, preventing stale anchor_activity_scenes.

2. Prefix Check Misses Edits (discussion_r3548477915):
   The 128-char content-prefix backstop in _read_profile_config_cached
   only detected edits within the first 128 bytes of config.yaml. Replaced
   with a full-content comparison that reads the entire file (~1-10KB)
   and compares it to a copy stored in the cache entry. This detects
   edits anywhere in the file at a fraction of the cost of yaml.safe_load().
   Updated cache type hint from tuple[float, str|None, dict] to
   tuple[float, str, dict].

Tests: 108 passed (41 reconciliation + 39 anchor-scene + 28 profile-config)
2026-07-09 03:08:11 +00:00
Frank Song
493b08d734 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 11:01:58 +08:00
nesquena-hermes
862a121b24
Merge pull request #5811 from nesquena/release/stage-5721
Release: workspace switcher screen reader accessibility (#5721)
2026-07-08 19:46:24 -07:00
Frank Song
60eacbef34 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 10:42:48 +08:00
nesquena-hermes
db7b146de9 docs(changelog): #5721 workspace switcher a11y 2026-07-09 02:41:05 +00:00
sheldon-im
67f8414656 Keep closed workspace dropdown out of SR navigation 2026-07-09 02:40:36 +00:00
sheldon-im
06e0df4656 Announce New Chat workspace transiently 2026-07-09 02:40:36 +00:00
sheldon-im
23de53c004 Improve workspace switcher screen reader state 2026-07-09 02:40:35 +00:00
nesquena-hermes
d6e6e91de4
Merge pull request #5810 from nesquena/release/stage-5729
Release: keep child streams from moving parent sidebar bucket (#5729)
2026-07-08 19:40:04 -07:00
nesquena-hermes
e0a23ba0f5 docs(changelog): #5729 sidebar child-stream bucket 2026-07-09 02:36:20 +00:00
Rod Boev
745e0be0df fix(#5699): keep child streams from moving parent sidebar buckets 2026-07-09 02:35:52 +00:00
nesquena-hermes
522e90b5b8
Merge pull request #5809 from nesquena/release/stage-5786
Release: handle terminal output EventSource transport errors (#5786)
2026-07-08 19:35:21 -07:00
nesquena-hermes
6c8944450c docs(changelog): #5786 terminal EventSource error recovery 2026-07-09 02:26:49 +00:00
ai-ag2026
d05f3e4b7d Handle terminal output EventSource transport errors
The composer terminal's output EventSource registered handlers for
output/terminal_closed/terminal_error but none for the transport-level 'error'
event, so a session expiry, killed gateway, or network drop left the terminal
frozen with no feedback and no recordClientSSEError telemetry.

Add an 'error' handler behind the existing identity guard: let the browser
auto-reconnect a CONNECTING source (one-time "reconnecting" note, no manual
loop/backoff), and on a permanently CLOSED source surface a "disconnected" line
and dispose it (null TERMINAL_UI.source) so a restart can reconnect. Notify once
per outage to avoid flooding the pane/telemetry on a flapping connection. Emits
recordClientSSEError('terminal', {ready_state, reason}) like the chat/session
consumers.

UI-evidence (screenshot of terminal on gateway-kill) still needed before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:26:20 +00:00
Frank Song
153207eaad fix(wakeup): allow distinct config key recovery 2026-07-09 10:24:49 +08:00
Konstantin M
b5c0447a4a perf(webui/session-load-latency) tier3c: fix scene comparison direction in cache fast-path
The fast-path scene comparison in _cached_session_lags_disk had two bugs
flagged by nesquena-hermes review:

1. Direction: used symmetric != instead of directional subset check.
   When the cache had scene keys that disk lacked (cache-ahead), the old
   code force-reloaded from disk, silently dropping un-persisted scene
   data. Now mirrors master: reload only when disk is strictly ahead
   (disk_keys.issubset(cached_keys)). The 'not disk_scenes' early-return
   is also removed — empty disk scenes means disk is not ahead, so keep
   the cache.

2. Key filtering mismatch: used raw set(disk_scenes.keys()) instead of
   _anchor_scene_record_keys() helper which filters to truthy keys with
   dict values. Now filters disk keys the same way as master, and uses
   _anchor_scene_record_keys(cached) for the cached session object.

Also updated the tier3b content-prefix docstring to clarify it is a
best-effort backstop limited to the first 128 chars, not a full
integrity check.
2026-07-09 02:21:25 +00:00
Frank Song
33859b2704 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:43:54 +08:00
Frank Song
d8b7ac23d6 fix(wakeup): require pool recovery before clearing pause 2026-07-09 09:43:48 +08:00
Konstantin M
aa78590164 perf(webui/session-load-latency) tier3b: content-prefix verification in profile config cache
Greptile P1 (discussion_r3548069113): the inode+mtime+size cache key
misses in-place rewrites on coarse filesystems. When a file is rewritten
in-place (same inode, same mtime tick, same byte size), the cache returns
the old parsed config for up to 60s.

Fix: on cache hit, read the first 128 characters of the file and compare
against the stored prefix from when the entry was populated. If they differ,
the content has changed — fall through to re-parse. Cost on hot path: one
128-char read + string comparison (negligible vs YAML parse).
2026-07-09 01:28:56 +00:00
nesquena-hermes
9765f0e45b
Merge pull request #5808 from nesquena/release/stage-5761
Release: refresh OpenCode Go static catalog (#5761)
2026-07-08 18:28:43 -07:00
nesquena-hermes
5f2ec9e1f0 docs(changelog): #5761 OpenCode Go catalog refresh 2026-07-09 01:23:33 +00:00
sbe27
68d406f7ff fix(models): refresh OpenCode Go static catalog 2026-07-09 01:23:08 +00:00
nesquena-hermes
f8f60d5fdd
Merge pull request #5807 from nesquena/release/stage-5782
Release: back off bg drain loop instead of silent tight-spin (#5782)
2026-07-08 18:22:36 -07:00
nesquena-hermes
a1aca345e1 docs(changelog): #5782 drain-loop backoff 2026-07-09 01:18:50 +00:00
ai-ag2026
141b49b74c test(bg): fix drain-loop backoff test import so it actually collects
The regression test used a bare `from _wakeup_helpers import install_fake_registry`,
which raises ModuleNotFoundError under the repo test runner (rootdir = repo root,
tests is a package). pytest treats that as a COLLECTION error for the file, not a
failure — so the whole suite stayed green while this file's tests silently never
ran, leaving the backoff fix with zero effective coverage.

Use the package-qualified `from tests._wakeup_helpers import install_fake_registry`,
matching the four sibling bg_task_complete suites. Confirmed the import now
resolves from the repo root (the bare form fails).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:18:21 +00:00
ai-ag2026
8dde1de4fa fix(bg): back off drain loop instead of silent tight-spin (#2476, #4633)
_drain_loop read process_registry.completion_queue directly under a bare
`except Exception: continue`. A registry missing that attribute raised
AttributeError that was swallowed and retried with no backoff — a
100%-CPU tight loop with no log line. The loop now reads the queue via
getattr(..., None) and backs off on the stop event when it is absent
(mirroring streaming.py), catches queue.Empty explicitly for the normal
idle path, and logs a warning + backs off (_DRAIN_STOP.wait(1.0)) on any
other queue error so a persistent fault can neither spin the CPU nor stay
invisible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:18:21 +00:00
Frank Song
a5974d0ae9 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:15:09 +08:00
Frank Song
9d9d52958e fix(wakeup): restore pause on streaming stop race 2026-07-09 09:15:04 +08:00
nesquena-hermes
8e00277ce5
Merge pull request #5805 from nesquena/release/stage-5785
Release: frame passkey-login 200 with Content-Length (#5785)
2026-07-08 18:10:07 -07:00
Frank Song
b21038f015 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:06:27 +08:00
Frank Song
59c868fe86 fix(wakeup): preserve pause on gateway stop race 2026-07-09 09:06:19 +08:00
nesquena-hermes
80276850a6 docs(changelog): #5785 passkey-login Content-Length 2026-07-09 01:05:56 +00:00
ai-ag2026
b10116bffc Frame passkey-login 200 response with Content-Length
The passkey-login success handler wrote its 200 JSON body without a
Content-Length header, unlike the password-login and logout handlers. Under
HTTP/1.1 keep-alive that response is unframed, so the browser's fetch().json()
waits for connection close and appears to hang until it times out.

Encode the body once and send Content-Length before end_headers(), mirroring
the /api/auth/login block. Header order is unchanged so set_auth_cookie's
Set-Cookie still precedes end_headers().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:05:28 +00:00
nesquena-hermes
9dacbe60de
Merge pull request #5804 from nesquena/release/stage-5787
Release: pair metering begin_session with end_session teardown (#5787)
2026-07-08 17:55:24 -07:00
nesquena-hermes
2c46e9e251 docs(changelog): #5787 metering session begin/end pairing 2026-07-09 00:51:59 +00:00
ai-ag2026
4fc51072a2 fix(streaming): pair metering begin_session with end_session teardown (#4633, #2476)
The streaming worker called meter().begin_session(stream_id) but never a
paired end_session. A turn that produced zero output tokens (pre-flight
cancel, or a setup exception before the first token) left a _SessionMeter
in GlobalMeter._sessions forever: get_stats() only prunes sessions with
first_token_ts > 0, so a zero-token session is never reclaimed, and each
leaked entry inflates the `active` count sent over the SSE metering event.

begin_session() and the metering ticker's .start() are now registered as
the first statements inside the worker's outer `try`, and paired with an
idempotent meter().end_session(stream_id, 0) plus a deterministic
_metering_stop.set() in that try's outer `finally` (the same block that
pops STREAMS/CANCEL_FLAGS) — so every exit path tears the session down.
Deferring .start() until after put() is defined also closes a latent
start-before-put ordering window in the ticker closure.

The metering payload is unchanged; end_session only pops the session.

Verification: tests/test_metering_session_lifecycle.py (leak reproduction,
end_session reclaim, idempotency, begin->cancel-before-token->empty) plus a
plain-assert run on python3.11 (conftest gates pytest to 3.11-3.13; system
python is 3.14). py_compile clean.

WARNING streaming-contract: adjusts the streaming worker's begin/end
teardown ordering. Needs RFC/contract review against docs/rfcs/
(session-sse-contract-v1.md) before merge — draft PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:51:28 +00:00
Konstantin M
f8cd40d74a perf(webui/session-load-latency) tier3a: Greptile follow-up fixes (P1 empty-scenes skip, P2 draft-log sink, P2 config-cache inode)
Three unaddressed Greptile review comments fixed:

P1 - Empty scenes skip check (api/models.py):
When the cached session had no anchor_activity_scenes, the scene-comparison
branch was skipped entirely by the 'if cached_scenes:' guard. An inactive
session would return False (cache fresh) even when disk had gained its first
scene record. Add an else clause that checks disk for scene records when the
cache has none.

P2 - Draft slow log sink (api/routes.py):
The /api/session/draft handler's slow-path log (>200ms) used logger.warning,
which is silently dropped in the WebUI process (root logger has no handler).
Route through handler._safe_webui_print() instead, the same fix applied to
the /api/session slow log in tier2c.

P2 - Config cache same-timestamp stale (api/routes.py):
The profile config.yaml cache key (profile_name, mtime, size) could collide
on coarse-mtime filesystems (FAT, some network FS) when a config edit within
the same timestamp tick happened to produce the same byte count. Add st_ino
(inode) to the cache key — most editors use atomic-rename writes which assign
a new inode, so the key changes even when mtime+size are identical.
2026-07-09 00:28:26 +00:00
Frank Song
ff0e6347cb Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 08:22:26 +08:00
nesquena-hermes
4fc637d8d0
Merge pull request #5790 from nesquena/feat/update-channels
Release channels: stable / experimental self-updater
2026-07-08 17:12:50 -07:00
Konstantin M
78dab75fc3 perf(webui/session-load-latency) tier2c-followup2: ensure RequestDiagnostics.finish() always fires
Greptile review caught that /api/session handler calls _diag.maybe_start()
at entry and _diag.finish() only on the success path. Three exit paths
leaked the watchdog entry:

  1. early-return when sid is missing (400)
  2. early-return when session is not visible to active profile (409)
  3. early-return when session not found (404)
  4. the existing 'except KeyError:' branch at the bottom of the handler

Without _diag.finish() the watchdog's _watchdog_pending dict held the request
until the 5s slow-request timeout fired _on_timeout and emitted a spurious
'Slow WebUI request still running' log. The pending dict also stayed
unbounded by in-flight count.

Fix: every early-return path now calls _diag.finish() before returning;
the except KeyError branch calls it once at entry so all paths through
it are covered. finish() is idempotent (no-ops if already called).
2026-07-08 23:44:04 +00:00
Konstantin M
fabcd5239b perf(webui/session-load-latency) tier1a-followup: fix anchor-scene staleness in cache fast-path
Greptile review caught that the Tier 1a fast-path returns 'cache fresh' when
disk_count == cached_count AND the session is inactive, but api/routes.py
saves the sidecar after editing only anchor_activity_scenes (no message
change). That path leaves the cached scene records stale until the cache is
otherwise invalidated.

Fix: when the cached session has scene records, also compare them against
the disk's scenes via the same metadata-prefix read that _persisted_message_count
already does. anchor_activity_scenes is in METADATA_FIELDS and serializes
before messages, so it lands in the prefix.

New _persisted_session_meta_prefix() helper exposes the parsed prefix dict;
_cached_session_lags_disk reads it and compares scene key sets + max
updated_at against the cached session.

Cost: one extra dict parse per cache hit on sessions that have scene
records, no cost on sessions without. Inactive sessions still take the
fast-path exit when scenes match.
2026-07-08 23:43:57 +00:00
nesquena-hermes
d4120b47b5 test(updates): update timeout-guard assertions for the channel-threaded apply/force bodies 2026-07-08 22:52:53 +00:00
nesquena-hermes
236a2c8032 fix(updates): thread offered channel through apply/force to close the debounce race
Codex gate CORE: /api/updates/apply + /force ignored the request-body channel
and re-read the saved setting, so a channel switch whose debounced autosave
hadn't landed could apply the OLD channel. The banner now sends the channel the
CHECK reported for each target; the endpoints validate it against the enum and
thread it into apply_update/apply_force_update (None → saved-setting fallback,
preserving prior behavior). Agent stays channel-neutral server-side.
2026-07-08 22:42:54 +00:00
Konstantin M
bc2b588915 perf(webui/session-load-latency) tier2-cleanup: route slow logs through _safe_webui_print for the other 4 RequestDiagnostics call sites
Tier 2c-followup added print_fn=handler._safe_webui_print to the
/api/session handler so its [SLOW] line and structured
'RequestDiagnostics' log actually land in the systemd journal (the
WebUI process boots with no root-logger handler, so logger.warning
calls are silently dropped — the per-request ms line has always
worked because it bypasses logging and writes via _safe_webui_print).

There are 4 OTHER call sites of RequestDiagnostics.maybe_start() in
api/routes.py (for /api/models, /api/sessions, /api/profiles, and the
generic POST dispatch). All four were wired with the same broken
logger.warning path, so their structured slow logs have been silent
in production as well.

This commit:
  - passes print_fn=getattr(handler, '_safe_webui_print', None) to
    the same 4 call sites
  - keeps the existing stage() and finish() call shape intact
  - the existing /api/sessions handler already has stage() markers
    (load_settings, profile_lookups, response_write) so the [SLOW]
    log on sidebar-list will now show real per-stage breakdown

No behavior change on the happy path. No public API change. The
allowlist still gates which paths get a diag at all; this only
fixes the sink for the structured log on the 4 already-instrumented
endpoints.
2026-07-08 22:39:09 +00:00
Konstantin M
5f167b23f9 perf(webui/session-load-latency) tier2c-followup: route slow logs through _safe_webui_print
Tier 2c added per-stage instrumentation on /api/session and added
'GET /api/session' to the RequestDiagnostics allowlist, but the
structured log emitted by RequestDiagnostics.finish() and the
pre-existing [SLOW] line both call logger.warning() — which the
WebUI process silently drops because the root logger has no handler
configured at boot.

The per-request 'ms' line has worked all along because it goes
through handler._safe_webui_print(), which writes directly to the
systemd journal socket.

This commit:
  - adds a print_fn= parameter to RequestDiagnostics.__init__ and
    RequestDiagnostics.maybe_start. When provided, finish() and
    _on_timeout() route through it instead of self.logger.warning.
  - routes the [SLOW] line in routes.py through handler._safe_webui_print
    instead of logger.warning.
  - passes print_fn=handler._safe_webui_print from the /api/session
    handler to RequestDiagnostics.maybe_start.

After this fix, every slow /api/session request (and the existing
[SLOW] line) actually lands in the journal — the very data Tier 2c
was supposed to produce. Without this, the allowlist + handler
instrumentation would be a no-op in production despite the code
being present.

Measured: the same 2.97s /api/session call (sid 295592fd560e,
m=1 ml=30) which produced a per-request ms line but no [SLOW] line
before this fix, now produces both lines with full per-stage breakdown.

No public API change. The new print_fn parameter is optional and
defaults to None, preserving the existing logger.warning behavior
for any caller that hasn't been updated.
2026-07-08 22:39:09 +00:00
Konstantin M
31cb8b4e01 perf(webui/session-load-latency) tier2a: cache profile config.yaml parse
_read_profile_model_config() fires on every chat-open that hits the
resolve_model=1 path (one of the 3 calls in the chat-open waterfall).
For sessions under a named profile (non-default), the function
performs:
  - 1× os.path.isfile() call
  - 1× open() of config.yaml
  - 1× yaml.safe_load() of the full file

On a Celeron N3350 with eMMC the open+safe_load alone is single-digit
ms per call, repeated on every chat-open. The /api/session 24h log
showed ?messages=0&resolve_model=1 at p50 1514ms with this YAML
parse as one of the dominant costs.

This wraps the parse in a per-process cache:
  - key = (profile_name, mtime, size)
  - value = parsed dict + monotonic timestamp
  - TTL 60s as a backstop for filesystem mtime resolution issues
  - cap 32 entries (bounded; profiles are bounded in practice)

mtime+size auto-invalidates on edit. The TTL only fires when mtime
resolution is too coarse to detect a same-second write; in normal
operation, every config edit lands as a different mtime and the next
chat-open rebuilds the cache entry.

Threading: a single Lock guards the dict. yaml.safe_load is the slow
step, not the lock, so contention is bounded. The lock is held for
microseconds on hit and milliseconds on miss (parse).

Field-semantics check: the function's signature is unchanged
((provider, default, config_dict) tuple) and the only call sites
(_resolve_effective_session_model_for_display and its sibling)
treat it the same as before. The cache stores the parsed dict;
the function still does the per-call (provider, default) extraction
inline so the tuple's content is identical to the pre-patch path.

No public API change. No behavior change observable other than
faster chat-open for sessions under named profiles.
2026-07-08 22:39:09 +00:00
Konstantin M
3a330ceff5 perf(webui/session-load-latency) tier2c: per-stage breakdown on /api/session
The /api/session handler already has _t0.._t6 local timers plus an
env-var-gated [SLOW] WARNING that logs the total wall time and the
_tN-to-_tN+1 deltas, but only when HERMES_DEBUG_SLOW is set or total
>= 2s. In production we cannot rely on the env var, and the 2s
threshold means the typical chat-open (sub-2s on Tier 1) never logs
at all — we only see the worst cases, never the marginal ones that
would tell us where the next bottleneck is.

This wires the handler into the existing RequestDiagnostics framework:
  - add ('GET', '/api/session') to the allowlist
  - call RequestDiagnostics.maybe_start() at the _t0 boundary
  - call stage() at each of _t1.._t6
  - call finish() before the response returns

The watchdog fires _on_timeout (with thread stack snapshot) when the
request crosses the per-path slow threshold, and finish() emits a
single structured log line on the same threshold crossing. The
existing env-var-gated [SLOW] log stays as a no-diag fallback.

Greptile P1 trap avoidance: previous allowlist additions for similar
endpoints were flagged as dead code because the handler was never
updated to call maybe_start/finish. Both halves are present in this
commit so the watchdog entry actually registers.

No behavior change observable on the happy path (diag is None for
non-allowlisted paths; the existing _tN log is the fallback for both
None-diag and slow cases). No public API change. The /api/sessions
and /api/profiles handlers already use this pattern.
2026-07-08 22:39:09 +00:00