Commit Graph
1943 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-hermes 2f7c5f01ee fix(#4973): replace per-request diagnostics Timer with a process-global watchdog thread
A threading.Timer per RequestDiagnostics spawns one OS thread per request, held
alive until finish() cancels it. Under sustained /api/sessions poll load this
exhausts the per-process thread cap (RuntimeError: can't start new thread) and
the server stops accepting connections. Replace with a single lazy-init
watchdog daemon thread that scans pending diags every ~1s; public API
unchanged. Caps timeout-tracking at 1 thread/process regardless of request rate.
2026-06-26 07:58:13 +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
Rod Boev 3979ea6023 fix(sidebar): restrict cron pre-aggregation to missing-index scans 2026-06-26 03:06:40 +00:00
Rod Boev 810707d47a fix(sidebar): gate cron pre-aggregation to large scans 2026-06-26 03:06:40 +00:00
Rod Boev eb10cecf0b fix(sidebar): keep cron candidate ordering scoped 2026-06-26 03:06:40 +00:00
Rod Boev 709086a29b docs(sidebar): update the index-prime rationale 2026-06-26 03:06:40 +00:00
Rod Boev 814fe56150 perf(#4842): pre-aggregate latest-message ordering for cold CLI scans 2026-06-26 03:06:40 +00:00
Rod Boev 97b551c528 fix(cli): restore all-profiles session scans after the cache rework (#4842) 2026-06-26 02:43:11 +00:00
Rod Boev 0301c8f8d2 fix(cli): keep CLI cache followers from hanging or dropping rows (#4842) 2026-06-26 02:43:11 +00:00
Rod Boev 8f0ede0e8b Make cache invalidation atomic across rebuild stores 2026-06-26 02:43:11 +00:00
Rod Boev 5b29b97993 Keep the retry path valid under ruff 2026-06-26 02:43:11 +00:00
Rod Boev 9130edbc31 Keep CLI cache rebuilds singleflight across invalidation 2026-06-26 02:43:11 +00:00
Rod Boev 6a5a514e8c Keep the review branch publishable 2026-06-26 02:43:11 +00:00
Rod Boev c1bb1a42e9 fix(#4842): stop lock-held CLI cache rebuild stalls during streaming 2026-06-26 02:43:10 +00:00
Frank Song 5b0e409d3b Handle equal-timestamp tool-call tail ordering 2026-06-26 02:18:18 +00:00
Frank Song 7e8834d8ca Fix tool-call tail ordering in session merge 2026-06-26 02:18:18 +00:00
Rod Boev 61ea7360a0 fix(#4536): reject openai-codex provider before empty-model early return 2026-06-26 01:46:57 +00:00
Rod Boev 2f2d36d4eb fix(#4536): gate service tier on effective send model, not saved default 2026-06-26 01:46:57 +00:00
Rod Boev 4d64c18832 fix(#4536): block Codex models from priority service tier 2026-06-26 01:46:57 +00:00
Rod Boev 6f9d23eb48 fix(#4536): gate service tier to real OpenAI model ids 2026-06-26 01:46:57 +00:00
Rod Boev a340c69b08 fix(#4536): clear stale service tier on model switch 2026-06-26 01:46:57 +00:00
Rod Boev 24cf7069a6 fix(#4536): persist OpenAI priority tier in main model settings 2026-06-26 01:46:57 +00:00
Frank Song fda5a241b2 Fix sidebar reconciliation review issues 2026-06-26 00:36:15 +00:00
Frank Song 0e2214b6bd Fix WebUI state.db session reconciliation 2026-06-26 00:34:58 +00:00
Frank Song 6079b7ba24 Address compaction marker and redaction review 2026-06-26 00:12:09 +00:00
Frank Song 9f0864ea82 Fix compaction marker writeback and code redaction 2026-06-26 00:12:09 +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
nesquena-hermes 0562bdbd31 fix(#4933): one-click extension install works with no env (bootstrap managed default root)
Settings -> Extensions offered an Install button even when extensions were
unconfigured, then dead-ended on 'Extensions not configured' because
install_extension required HERMES_WEBUI_EXTENSION_DIR to be set to an existing
directory. Default to a WebUI-managed root under STATE_DIR/extensions and
bootstrap it on first install so install just works with zero config; the env
var remains an optional override (never auto-created). Gallery-installed
extensions already auto-load via the runtime manifest, so the extension loads
on the next app-shell render with no further setup.

- api/extensions.py: _default_extension_root + _writable_extension_root;
  _extension_root/_extension_root_status fall back to the managed default;
  install_extension bootstraps via _writable_extension_root; dir-unavailable
  warning only fires for an explicitly-set-but-broken env path.
- tests: update disabled-by-default expectations (configured=True, valid=False
  pre-install) + new regression proving install bootstraps + auto-loads with
  no env.
- docs/EXTENSIONS.md: document zero-config one-click install + env now optional.
- CHANGELOG [Unreleased].
2026-06-25 18:45:09 +00:00
nesquena-hermes bd6e57fb42 fix(#4928): don't cap tool-call content args to 120 chars
Tool-call args were truncated to 120 chars at three sites (_truncate_tool_args,
the live _tool_args_snapshot in api/streaming.py, and _toolArgsSnapshot in
static/ui.js). For incidental args that's fine, but content/diff-bearing keys
(command/cmd/script/code/patch/diff/old_string/new_string/content/path/
file_path) feed the rendered tool card AND the diff reconstruction in
recovery-rebuilt sessions, so the cap silently corrupted long commands/paths and
produced broken/empty diffs. Exempt those keys with a much larger cap
(_TOOL_ARG_CONTENT_CAP = _TOOL_RESULT_SNIPPET_MAX = 4000) at every site, keyed by
key name; incidental args keep the 120 cap; storage stays bounded. Part of #4925.
2026-06-25 18:18:17 +00:00
nesquena-hermes f377215a0d harden: wrap _log_stream_writeback_timings in try/except so a misbehaving log handler can't skip the bg-title thread (Opus gate nicety, #4923) 2026-06-25 16:54:20 +00:00
Frank Song 8b1f8fe5ca Add final stream writeback diagnostics 2026-06-25 16:42:53 +00:00
Frank Song 7d69b80012 Avoid repeated session loads in live usage metering 2026-06-25 16:12:18 +00:00
nesquena-hermes 3ca84b61d3 docs: clarify _INDEX_WRITE_LOCK serializes the on-disk index RMW (Opus gate nicety) 2026-06-25 16:03:36 +00:00
Frank Song 3bae596ae8 Reduce session index global lock window 2026-06-25 22:02:34 +08:00
Rod Boev 528fa72390 fix(#4771): keep gateway approval failures actionable 2026-06-25 13:09:42 +00:00
nesquena-hermesandmydelren 0cecfb13a3 fix(#4912): deliver async_delegation completions to the parent chat
format_wakeup_prompt() in api/background_process.py returned None for any event
type other than 'completion'/watch_*, so async_delegation events (produced when
a background delegate_task subagent finishes) were silently dropped — the
subagent showed as perpetually 'running' and its consolidated result never
re-entered the parent conversation.

Add an async_delegation branch that delegates to the agent-side
tools.process_registry.format_process_notification (which already knows the
async_delegation shape), so the subagent result is rendered into the wakeup
prompt and re-enters chat. Scoped to async_delegation specifically (not all
unknown types) so genuinely-unrecognized events are still skipped — preserving
the existing skip-unknown-event contract.

Reported + root-caused by @mydelren.

Co-authored-by: mydelren <mydelren@users.noreply.github.com>
2026-06-25 11:38:02 +00:00
nesquena-hermesandRod Boev fb76846b38 fix(#4914): don't abort force update when git clean -fd fails
On Windows a reserved-device-name file (nul/con/prn/aux/com1-9/lpt1-9) can land
in the working tree (e.g. a shell command redirecting to `> nul` under Git Bash,
which treats it as a literal filename). git can't delete it via the normal Win32
path, so `git clean -fd` exits non-zero — and force_apply_update treated that as
fatal, aborting the whole force update with 'Failed to remove untracked files
before force reset' and leaving the user stuck.

The clean is now best-effort: a failure is logged (stderr captured for
diagnostics) but non-fatal, because the subsequent `git reset --hard` overwrites
any tracked-file collisions regardless, and residual untracked files git can't
delete are harmless. Updates the prior test that asserted the (buggy) fatal
behavior to the corrected non-fatal contract, plus a new regression test.

Reported + root-caused by @rodboev.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-25 11:13:57 +00:00
Frank Song 5f17ba55ed Fix gallery-installed extension activation 2026-06-25 07:10:17 +00:00
nesquena-hermes b149a975c6 fix(#4842): update #4385 archive tests to real sidecar files + document stat-gate assumption
Opus gate finding (BLOCKING): the two #4385 archive-reappears regression tests
mocked Session.load_metadata_only to return an archived stub WITHOUT writing a
real sidecar file, and never set SESSION_DIR. The new stat-gate in
_state_projection_sidecar_metadata short-circuits on SESSION_DIR/{sid}.json
before consulting metadata, so those mocks were never reached -> archived=False.

This is NOT a production regression (in prod, load_metadata_only reads exactly
SESSION_DIR/{sid}.json and returns None when absent, so archived metadata only
exists when the file does — in which case the stat() succeeds and behavior is
identical). The divergence existed only at the tests' mock boundary.

Fix: both tests now write a real archived sidecar JSON under a monkeypatched
SESSION_DIR, exercising the actual production read path (strictly better
coverage than mocking load_metadata_only). Also documented the
SESSION_DIR-as-sole-source assumption on the cache function per Opus's
forward-looking note.
2026-06-25 04:56:00 +00:00
nesquena-hermes a6d8319acb perf(#4842): cache sidebar-projection sidecar reads + hoist per-row I/O on the cron path
GET /api/sessions pinned CPU to 100% and took multiple seconds on profiles with
many cron sessions. The state.db -> sidebar projection did three pieces of
redundant per-row file I/O, in BOTH the visible pass and the higher-capped
(CRON_PROJECT_CHIP_LIMIT=200) cron-only second pass:

1. _state_projection_sidecar_metadata(sid): uncached open() + 64KB prefix read +
   JSON-key scan, once per row.
2. get_last_workspace(): up to two file reads + an is_dir()/remote probe,
   returning the SAME workspace for every row.
3. cron/jobs.json: full read + parse per untitled cron row.

On a cron-heavy install that is hundreds of file reads per build, and because
the enclosing _CLI_SESSIONS_CACHE is keyed on a state.db content fingerprint
that advances on every streamed message row, the whole scan was re-paid on
essentially every 5s poll during a live turn (the recurring 100% CPU / slow
/api/sessions from #4672 -> #4808 -> #4842).

Fix:
- Memoize _state_projection_sidecar_metadata by the sidecar's
  (path, mtime_ns, size, ctime_ns) stat signature in a bounded module cache, so
  a warm build re-stats instead of re-reads. A rename/archive/edit bumps the
  signature and invalidates just that one entry (no staleness). This survives
  the 5s CLI-cache busting during streaming, which is the actual trigger.
  Mirrors the Claude Code parse cache (#4718). Folded into
  clear_cli_sessions_cache() for explicit-reset/test parity.
- Build-scoped memos for cron jobs.json (parse once -> job_id->name map) and
  get_last_workspace() (resolve once), matching the existing _cron_pid() pattern.

Output is byte-identical. Adds tests/test_issue4842_cron_projection_perf.py
(7 tests: warm-rebuild no re-read, stat-change invalidation, dict-copy isolation,
missing-sidecar default, jobs.json once, workspace once, lifecycle clear).
2026-06-25 04:56:00 +00:00
Rod Boev 235059265b Remove unused Request import (ruff F401) 2026-06-25 04:21:50 +00:00