Commit Graph
775 Commits
Author SHA1 Message Date
nanw e8f1bb10da Prune orphan native-WebUI zero-message sessions from sidebar (#4985)
Sidebar rows whose backing state.db session has zero messages (a '+'-click
that opened a row but the first turn never committed, or a sidebar nav that
opened then closed before any message landed) now get pruned instead of
lingering forever. The existing #3238/#4591 orphan-prune path only handled
CLI/API-server sidecars; this adds a parallel pass for native-WebUI rows.

- api/models.py: add agent_session_zero_message_sids() batch helper that
  mirrors agent_session_rows_existing()'s safe-degrade contract (returns
  frozenset() on any error so a transient failure never causes data loss).
  Add a small tombstone file at SESSION_DIR/_pruned_webui_orphans.json
  (capped at the last 500 sids) so the next poll's
  recover_missing_index_sidecars does not re-add a pruned orphan to the
  sidebar index — the per-poll fsync'd index write + state.db probe loop
  the original PR would have hit on every poll for every orphan. The
  helper, the load, and the full-scan fallback all honor the tombstone.
  new_session() and import_cli_session() clear any matching tombstone
  entry so a fresh session with the same id isn't shadowed.
- api/routes.py: extract the webui zero-message prune into a private
  _prune_orphaned_webui_zero_message_sessions() helper, then call it
  from BOTH branches of _build_session_list_cache_payload. Established
  installs pin show_cli_sessions=False (per api/config.py:7637-7648)
  and are exactly the long-time users who accumulated the #4985 404
  orphans — the original PR only ran the prune in the if branch and
  silently skipped the else branch (maintainer review
  IC_kwDOR1LuPM8AAAABHsyFGg). The helper also honors the tombstone
  defensively so the prune can't thrash on a row the index still
  carries from a prior poll.
- tests/test_issue4985_orphaned_webui_zero_message.py: 39 regression
  tests — the original 25 (7 helper-direct + 12 monkeypatch predicate
  tests + 6 real-pipeline end-to-end tests) plus 14 follow-up tests
  driven by the MUST-FIX / tombstone / r6 signal-swap reviews:
    * test_helper_prunes_orphan_keeps_safety_retained_rows — direct call
      to the extracted helper with a row set covering every shape the
      gate is responsible for.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false
      — explicit regression for the established-install case the
      original PR missed.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control.
    * test_tombstone_persists_across_polls — drives two pipeline builds,
      asserts prune fires once total and the tombstoned sid is NOT
      re-added to SESSION_INDEX_FILE.
    * test_tombstone_does_not_block_new_session_with_same_id — defensive
      cover for the new_session() / import_cli_session() tombstone clear.
    * test_tombstone_trimmed_to_last_N_entries — verifies the cap.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control (show_cli_sessions=True path).
    * test_tombstone_self_heals_when_message_added — confirms
      Session.save()'s belt-and-suspenders tombstone clear fires when
      a sidecar commit lands with len(messages) > 0.
    * test_tombstone_loader_is_fail_open_on_corrupt_file[non-json|version-mismatch|non-dict-root]
      — 3-parameter regression for the fail-open corrupt-file contract
      (#5023 lesson).
    * test_sidecar_only_webui_session_is_retained — sidecar JSON carries
      real messages but state.db.messages is empty (mirroring lag); the
      r6 loaded-sidecar probe retains the row.
    * test_sidecar_only_webui_session_self_heals_tombstone — the r4.5
      self-heal path combined with the r6 sidecar-only retention rule.
    * test_truly_empty_sidecar_with_title_still_pruned — companion
      negative test that the r6 fix does NOT over-retain empty sidecars
      with non-Untitled titles.
    * test_sidecar_only_webui_session_retained_when_show_cli_sessions_false
      — established-install path also respects the r6 retention rule.
- CHANGELOG.md: Unreleased entry under Fixed.

fix: target post-#1171 survivors + hoist + tombstone + r6 sidecar-load probe

The r5 sidecar-only retention signal keyed off the row's cached
``message_count`` — which is stale-positive on the very phantom rows
#4985 exists to prune (sidecar ``messages`` empty but cached count > 0).
The r6 signal swaps that cached-count probe for a ``Session.load(sid)
.messages`` probe, gated to ``state.db``-empty candidates only so the
common live-row path stays a no-op. Three retain tests now write real
populated ``messages`` arrays (not just ``[]``) so the loaded-sidecar
check has something to find; the stale-count phantom test is left as
the canonical r6 regression (its ``messages=[]`` + ``message_count=5``
sidecar is exactly the shape the r6 loaded-sidecar check correctly
classifies as orphan). Maintainer review 4584722701.
2026-06-28 12:14:58 +08:00
Frank SongandPaperclip f15f178b5e Guard state db prefix keys with tool calls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-26 10:43:28 +00:00
Frank SongandPaperclip 7a38b09af1 Fix lint for state db prefix key guard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-26 10:43:28 +00:00
Frank SongandPaperclip fd81b6cb08 Guard state db tail reads by prefix keys
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-26 10:43:28 +00:00
Frank Song dc9d6275cb Address state db tail-window coordinate guards 2026-06-26 10:43:27 +00:00
Frank Song 91f7e7f816 Address state db tail window review 2026-06-26 10:43:27 +00:00
Frank Song 8bd31891e3 Bound state.db reads for paginated session loads 2026-06-26 10:43:27 +00:00
nesquena-hermesandb3nw 74cee77738 fix(approval): stale local approval card clears instead of dead-ending (#4948 local variant, #4771 follow-up)
On the default local in-process backend, an approval card whose stream
ended while still pending (cancel/fork/provider-error/completion) left the
agent queue entry dropped and the _pending mirror reconciled away. Clicking
Approve/Deny then sent an approval_id that matched nothing, so the handler
returned a bare {ok:false}; since #4771 the frontend surfaces that as
'Approval response not accepted.' with a stuck card (reported by
@santastabber on v0.51.666 and b3nw).

Distinguish a genuinely stale card (no pending approval for the session ->
benign {ok:true, stale_cleared:true} so the UI clears the orphan) from a
stale-id click made while a DIFFERENT approval is live (still ok:false so it
can never resolve the wrong command -- #527 guard preserved). Frontend
clears the orphan card on stale_cleared even if the displayed id drifted.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-26 06:46:22 +00:00
Frank Song 0e2214b6bd Fix WebUI state.db session reconciliation 2026-06-26 00:34:58 +00:00
nesquena-hermes bac8108b38 fix(#4771): don't 409 local approvals as "gateway run unavailable"
#4771 added an explicit relay-failure 409 (code: gateway_run_unavailable)
to /api/approval/respond so a gateway approval whose run is gone keeps its
card actionable instead of silently failing. But the 409 was emitted
regardless of backend.

On the DEFAULT local in-process backend, every guarded command parks an
entry in tools.approval._gateway_queues (via _await_gateway_decision), and
the local streaming notify callback mirrors it into _pending tagged with
_GATEWAY_MIRROR_FLAG. There is no gateway run and no _STREAM_RUN_IDS entry
by design. _gateway_pending_approval_without_run_id() therefore returned
True for that purely local approval, so the handler 409'd
("active run unavailable") and refused to resolve an approval that resolves
perfectly well locally. Local-backend users hit this on every approval
click (reported by b3nw on Discord; independently filed as #4948 by
claw-io, which also notes the local-runtime trigger).

Fix: gate the 409 on the WebUI actually running the gateway chat backend
(webui_gateway_chat_enabled). Local approvals fall through to the local
resolution path; the legitimate #4771 gateway behaviour is unchanged.

Tests:
- New tests/test_issue4771_local_approval_regression.py drives the real
  _handle_approval_respond handler and asserts: local backend -> 200 +
  agent thread unblocked; gateway backend -> 409 preserved. These fail
  before the fix (409) and pass after.
- The two existing #4771 tests that asserted the 409 are updated to pin
  HERMES_WEBUI_CHAT_BACKEND=gateway at respond time, since the 409 is now
  gateway-deployment behaviour.

Reported-by: b3nw
Reported-by: claw-io (#4948)
2026-06-25 22:21:56 +00:00
nesquena-hermes 7791370d49 docs(#4908): correct cache-invalidation comment per Opus gate — TTL is the backstop for externally-driven changes
Opus SHIP-WITH-FIXES (no code change required): the comment/docstring overstated
that the session-list-change listener covers ALL structural mutations. In-app
mutations fire it, but externally-driven changes (scheduled cron completion,
external CLI writes) don't — for those the 30s streaming TTL is the load-bearing
backstop (≤30s bounded, self-healing lag). Comment-only accuracy fix; logic
unchanged (both gates: perf fix correct, freeze effective, staleness bounded).
2026-06-25 21:57:36 +00:00
nesquena-hermes b87a605306 perf(#4842): freeze the CLI/cron sidebar cache key during streaming
/api/sessions re-ran the expensive CLI/cron session projection on every
poll while a turn was streaming — a major cause of the multi-second sidebar
latency + 100% CPU on cron-heavy installs (continuing #4672/#4808/#4889).

Root cause: the CLI/cron projection is gated by _CLI_SESSIONS_CACHE, whose
key folds in _sqlite_file_stat_cache_key -> _sqlite_content_fingerprint
(MAX(rowid) FROM messages). During a live turn the gateway writes a message
row per streamed delta, so that fingerprint advances on essentially every
~5s poll — busting the cache and re-running the full candidate-join +
projection (and the lineage-metadata pass) on every poll, contending for the
same SQLite/global lock the streaming worker holds.

The route-level session-list cache already froze its key during streaming
(#4808 _session_list_cache_streaming_freeze_marker), but that freeze never
reached this inner CLI-sessions cache, so the heavy CLI/cron query still
re-ran whenever the outer cache validated.

Fix: while any stream is active, the CLI-sessions cache key folds in the same
stable streaming-freeze marker (keyed only on the set of active stream ids)
instead of the volatile content fingerprint, and the cache TTL widens to a
streaming window. The projection is reused across polls mid-stream and
rebuilt at most once per window. The instant a stream starts/stops the marker
changes, so freshly-finished rows surface promptly. Structural mutations
(cron completion, new/renamed/archived sessions, attention) now also clear
the CLI cache via the existing session-list-changed listener — those signals
never fire per streamed token, which is what makes the freeze safe.

Idle behavior is byte-identical (freeze only engages with active streams).
Applies to both the active-profile and all-profiles cache paths.

Adds tests/test_issue4842_cli_sessions_streaming_freeze.py (6 tests): marker
idle/stable/changes semantics, the core 'key stable across message writes
while streaming' guarantee, idle key still advances with the fingerprint,
streaming TTL > idle TTL, and the structural-mutation listener clears the
CLI cache.
2026-06-25 21:57:36 +00:00
Rod Boev 528fa72390 fix(#4771): keep gateway approval failures actionable 2026-06-25 13:09:42 +00:00
Rod Boev ddd5c8d936 feat(extensions): add gallery browse, install, and uninstall (#4746) 2026-06-25 04:21:50 +00:00
Rod Boev 0d6932dcce fix: invalidate skills cache on toggle/save/delete and stat skills_dir for deletion detection 2026-06-24 20:52:31 +00:00
Rod Boev 897322eac3 fix(#4728): keep main default-model routes from persisting auto provider 2026-06-24 20:09:30 +00:00
Rod Boev bede035a5e fix(#4728): persist provider-only default-model changes end to end 2026-06-24 20:09:30 +00:00
nesquena-hermesandAlexeyDsov aac220b625 fix(#4767): thread truncation_boundary through remaining full-load merge call sites (CORE-A)
The contributor's empty-sidecar recovery uses the persisted truncation_boundary
to distinguish a legitimate prefix from a deleted suffix, but two production
call sites still passed only truncation_watermark — the default /api/session
full reload (routes.py ~9504) and _merged_session_messages_for_display
(routes.py ~6098) — so a normal full reload fell back to the old 'drop one
turn pair' heuristic and resurrected deleted suffix turns when an OLDER message
was edited leaving >=2 later turns. Thread truncation_boundary through both.

Co-authored-by: AlexeyDsov <alexeydsov@gmail.com>
2026-06-24 18:08:50 +00:00
AlexeyDsov 037253e4fc fix(#4767): review fixes - more edge cases 2026-06-24 18:08:50 +00:00
AlexeyDsov e1bbf3e807 fix(#4767): advance truncation_watermark instead of clearing to None after edit/retry/undo (#2914, #3831) 2026-06-24 18:08:50 +00:00
Ruyan a7788792fb fix: prefer agent cron package for task APIs 2026-06-24 17:52:57 +00:00
nesquena-hermes f4e150e105 Merge remote-tracking branch 'origin/master' into fix/4768-cron-graceful-degrade 2026-06-24 09:23:37 +00:00
nesquena-hermes 1e3132376b fix(#4768): only treat absent cron package as unavailable, re-raise real import bugs (Codex gate)
ModuleNotFoundError fires both when cron.jobs is absent AND when an existing
cron/jobs.py fails to import its own dependency. Catch as exc and only return the
graceful {jobs:[],cron_unavailable:true} when exc.name is 'cron'/'cron.jobs';
re-raise otherwise so a real cron bug isn't silently swallowed.
2026-06-24 09:12:46 +00:00
nesquena-hermes 34694a1f02 fix(#4768): degrade GET /api/crons gracefully when the cron module is absent
In split-container / minimal Docker deployments the WebUI image may not ship the
agent's cron package on its import path, so the Tasks tab's GET /api/crons did
'from cron.jobs import list_jobs' and 500'd with ModuleNotFoundError — breaking the
whole tab. Now the import is guarded and returns {jobs: [], cron_unavailable: true}
instead, so the Tasks tab loads (empty) instead of erroring. Happy path unchanged.
Co-authored-by: reporter
2026-06-24 08:42:50 +00:00
nesquena-hermes 1fc24ddf68 feat(#4241): add Vietnamese (vi) as 14th UI locale + wire parity
Maintainer-completed @thanhtantran's #4241 (Nathan approved bringing it in):
- vi locale block in static/i18n.js (contributor's 1615-line translation) rebased onto master.
- Added vi to _LOGIN_LOCALE in api/routes.py (login-page localization, issue #1442 parity).
- Bumped the per-locale hardcoded-count assertions 13->14 across the locale-parity
  tests (rtl, quota_chip, hide_suggestions, virtualize_transcript, no_git, help_tab,
  verdigris/zeus skins, auxiliary_models) and added 'vi' to the composer-voice-buttons
  LOCALES tuples. vi already carries all current English keys (verified 14x per key).
All 497 locale/parity tests pass. Co-authored-by: thanhtantran
2026-06-24 08:39:14 +00:00
nesquena-hermes 8eed95a25e fix(#4808): widen /api/sessions cache TTL while streaming to stop per-poll rebuilds
The frontend polls /api/sessions every 5s while a turn streams
(static/sessions.js _streamingPollMs=5000), but the cache TTL was 2.5s
(_SESSIONS_CACHE_TTL_SECONDS) — so every streaming poll landed in a fresh window
and forced a full all_sessions() rebuild on the hot path under the global store
LOCK, pinning CPU to 100% and starving token rendering on large stores
(recurrence of #4672). The #4680 freeze-marker only de-dupes rebuilds WITHIN one
TTL window; with polls spaced wider than the TTL it never helped.

Fix: while _session_list_cache_streaming_freeze_marker() indicates an active
stream, use _SESSIONS_CACHE_STREAMING_TTL_SECONDS=10.0 (> the 5s poll) so the
sidebar holds steady across streaming polls. Live runtime state (active stream,
sort order, pending flags) is overlaid on every response regardless of cache, and
structural/settings changes still evict immediately via the source stamp — only a
streaming session's own persisted title/message_count can lag up to 10s, which the
#4672 design already deems acceptable. Idle path byte-for-byte unchanged.

Diagnosis credit: investigation reproduced 6 streaming polls -> 6 rebuilds before,
1 after. Co-authored-by: batkuip <reporter>
2026-06-24 08:06:45 +00:00
nesquena-hermes f3086f4974 Merge branch 'peek-4809' into gate/4809-worklog-dur
# Conflicts:
#	CHANGELOG.md
2026-06-24 07:25:32 +00:00
nesquena-hermes b9f1491de7 Merge branch 'peek-4798-v2' into gate/4798-v2 2026-06-24 06:25:59 +00:00
Frank Song cafeda63ca Preserve processed Worklog duration during message merges 2026-06-24 11:32:27 +08:00
nesquena-hermes 7db3aa123f Merge branch 'peek-4656' into stage/4656-escape-readonly 2026-06-23 20:43:53 +00:00
nesquena-hermes 235bfffaf0 Merge remote-tracking branch 'origin/master' into peek-4741
# Conflicts:
#	CHANGELOG.md
2026-06-23 17:06:17 +00:00
Rod Boev e5aa548d67 fix(#4756): refresh stale model catalogs on session visit 2026-06-23 12:50:28 -04:00
Rod Boev 165433fea6 Keep legacy sidebar tests aligned with visible-row filtering 2026-06-23 11:56:25 -04:00
Rod Boev e9baeff2b2 fix(sidebar): preserve attention rows in filtered session payloads (#4775) 2026-06-23 10:51:33 -04:00
Rod Boev e27b7cf042 perf(sidebar): trim hidden and empty sidebar payload rows (#4775) 2026-06-23 10:29:06 -04:00
boriken72 c6994b501a perf: cache app-shell template instead of re-reading index.html per request
The `/`, `/index.html`, and `/session/<id>` routes are the hottest
navigations. Each request re-read the ~190 KB static/index.html from disk
and re-applied two process-constant token substitutions
(__WEBUI_VERSION__, __MAX_UPLOAD_BYTES__), then ran inject_extension_tags()
(which itself reads the extension state + manifest from disk).

WEBUI_VERSION and MAX_UPLOAD_BYTES are fixed for the process lifetime, so
this caches the partially rendered template in an in-process cache keyed by
(size, mtime_ns) -- mirroring the existing _STATIC_CACHE pattern in this
file -- so a redeploy is still picked up without a restart. The per-session
CSRF token and runtime extension tags are still applied per request, so
output is byte-identical to before.

Verified: byte-identical to the old inline render, CSRF still varies per
request, cache invalidates on mtime change, ~5x faster shell render in a
local microbenchmark. Adds tests/test_index_shell_template_cache.py.
2026-06-23 09:01:59 -04:00
Rod Boev 73a209aca7 fix(#4766): close review gaps in sidebar source filtering 2026-06-23 06:10:16 -04:00
Rod Boev 9a5a3eb6d5 fix(#4766): filter /api/sessions by active source bucket 2026-06-23 05:56:00 -04:00
Rod Boev 40fc69b1cf Require real browser origins for escape grant minting (#4582) 2026-06-23 02:53:12 -04:00
Rod Boev decb1037bc Keep escape grants browser-confirmed and refreshable (#4582) 2026-06-23 02:53:12 -04:00
Rod Boev e8c4cb85c3 Preserve explicit inline markers for HTML preview guards 2026-06-23 02:53:12 -04:00
Rod Boev d20e9dd852 Prevent stale escape errors from collapsing live browse grants 2026-06-23 02:53:12 -04:00
Rod Boev 29f38902be Keep escape navigation on the tested UI route path (#4582)
# Conflicts:
#	static/workspace.js
2026-06-23 02:53:12 -04:00
Rod Boev 0f80113c12 feat(workspace): allow browser-only escape-target navigation (#4582)
# Conflicts:
#	static/workspace.js
2026-06-23 02:52:44 -04:00
nesquena-hermes 2f250a4198 review(#4741): address greptile P2s — drop redundant lazy import, deep-copy raw config cache
Two greptile findings on #4741:
- api/routes.py: removed the redundant 'from api.config import load_settings'
  inside _session_list_payload_to_response — load_settings is already imported at
  module scope, and this function only runs at request time (after module load).
- api/config.py: _load_yaml_config_file_raw now deep-copies the cached raw dict by
  default so a caller can never mutate the shared _yaml_file_cache entry. The
  internal _load_yaml_config_file passes _copy=False (it immediately runs
  _expand_env_vars, which returns a fresh structure and never mutates its input)
  so the /api/reasoning hot path keeps its cheap no-copy fast path; reload_config
  uses the safe default copy.
2026-06-23 05:43:17 +00:00
nesquena-hermes 4f071895cc perf(#4662): read redaction setting once per sidebar response, not per row
Phase 3 of #4662. _session_list_payload_to_response read settings.json once
per row via _redact_text's per-call load_settings() fallback (the multi-second
response_write stage in /api/sessions diagnostics on large lists). The
_sidebar_session_response_item -> _redact_text(_enabled=...) threading already
existed; this wires the caller to read the api_redact_enabled flag once and
thread it through every row, matching the read-once optimization
redact_session_data() already uses. Behavior-preserving; redaction still
applies (or not) exactly as before.
2026-06-23 05:42:32 +00:00
Frank Song a4470bd30c Deduplicate live progress reasoning echoes 2026-06-23 12:51:16 +08:00
nesquena-hermes 239529f9bd stage #4544 (rodboev): route provider/model credential reads through thread-local profile channel (fixes #3961) 2026-06-22 18:47:59 +00:00
c5bb1fcaa0 stage #4688 (franksong2702): running-first session ordering in sidebar (#4699)
Overlays live pending_started_at/updated_at/last_message_at onto cached /api/sessions rows
(when newer), sorts active/running sessions first then by freshest effective timestamp, applies
the same running-first order in the frontend sidebar renderer, and groups date buckets with the
same runtime-aware timestamp (no stale header on resumed running sessions). routes.py +
sessions.js + 81-line test. Composes with #4680 cache-freeze + #4597 (98 session-list tests pass).

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 03:52:19 -07:00
a66913ac26 stage #4680 (nesquena-hermes): freeze /api/sessions cache during streaming (fixes #4672) (#4690)
Freezes the session-list cache source-stamp on the SET of active stream ids (not per-write
state) so mid-stream sidebar polls hold the cache instead of rebuilding all_sessions() every
poll and contending for the global LOCK the streaming worker holds (the ~2 tok/s + 15s
/api/sessions cause). Structural mutations still invalidate via publish_session_list_changed;
only a streaming session's own title/message_count lags <=TTL. routes.py + 109-line test.
99 session-list/sidebar/cache tests pass.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-22 02:11:04 -07:00