Commit Graph

171 Commits

Author SHA1 Message Date
DannyFengTianYu b066f2b373 fix(history): isolate branch transcripts from parent updates
(cherry picked from commit 57c51cc401e867e0b315e051ba138d0b8f7a5f27)
2026-08-15 00:35:40 -07:00
Adolanium ee9ec6164c perf(state): stop selecting full message content in session search
Every search route in _search_messages_impl (FTS, CJK bigram, trigram,
LIKE fallback, rebuild-gap supplement) selected m.content, then the
result tail popped it unread. On DBs with multi-MB tool rows, each
search read and materialized up to `limit` full rows only to discard
them. Snippets come from snippet()/substr() in SQL and the context
window is re-fetched by id, so no code path ever read the column.

Drop the column from all six SELECT lists. Returned dicts are
unchanged: content was never part of the public result (the pop ran
before return), and tests/test_hermes_state.py already documents that
contract.

(cherry picked from commit d0c3af167e7dd4eb18e1bea29ba107a94911ea24)
2026-08-15 00:34:29 -07:00
Teknium dac3c44afc test: fix salvage test imports; drop WAL worker-thread test superseded by read pool
- tests/cron/test_sessiondb_init_hang.py: add threading/time imports the
  salvaged late-close regression tests rely on.
- tests/test_hermes_state.py: drop
  test_close_closes_wal_read_connection_created_on_worker_thread — main
  replaced per-thread WAL reader ownership with the pooled read-connection
  design (permits + checkout/return), so cross-thread reader draining no
  longer exists in the form the test asserted.
2026-08-14 21:41:26 -07:00
joaomarcos 39e480c051 fix(state): close leaked SessionDB connections on exception paths (#83226)
SessionDB could leave native SQLite handles open when construction failed
partway through schema/pragma/FTS/repair/lock/interrupt handling. Other
short-lived callers (MCP reads/polling, session search, reactions, trace
upload, insights, shutdown recovery) opened temporary SessionDB handles
without a complete ownership boundary. API-server profile caches and
RetainDB shutdown had similar late-close races. Under sustained load this
exhausted file descriptors (EMFILE).

- Close partially initialized SessionDB connections on every constructor
  exception path via a finally block guarded by an initialization-complete
  flag.
- Close temporary/cross-profile SessionDB handles in finally blocks across
  CLI, MCP, search, trace, reactions, insights, and recovery paths.
- Add API-server per-profile cache ownership and disconnect cleanup.
- Make RetainDB writer-queue shutdown exception-safe: track connections per
  thread, close on worker exit, reject new enqueues after shutdown starts,
  and sweep any connections left by short-lived threads.
- Add regression coverage for constructor failures, worker-thread readers,
  API disconnect failures, shutdown recovery, RetainDB late enqueue, and
  foreign-loop async clients.

Salvage notes: the original PR's per-thread WAL-reader ownership changes
were superseded by main's read-connection pool (permits + checkout/return);
its cron timeout-abandon fix is credited separately to #72822's earlier
identical fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:41:26 -07:00
Teknium f45813ea77 fix(sessions): run state.db schema migration eagerly at backend startup and stop swallowing locked ALTERs
After `hermes update`, an existing state.db on an old schema made every
GET /api/sessions poll fail with sqlite3.OperationalError "no such
column: s.last_read_at" (or s.last_activity_at) until something
unrelated forced a writable open — the desktop sidebar showed "No
sessions yet" while every row sat intact on disk (#79531, #80037).

Two remaining root causes (the stale hand-written read probe was
already replaced by the SCHEMA_SQL-derived probe on main, prototyped in
draft PR #80030 by @Tilly-YL):

1. Migrations ran lazily: _init_schema/_reconcile_columns only ran on a
   writable open, typically the user's first NEW session. The dashboard
   backend now schedules one writable open of its own state.db from the
   lifespan (daemon thread, never blocks the ready-probe socket, never
   raises), so the store is brought current before the first session-
   list poll on every `hermes serve` / `hermes dashboard` / Desktop
   headless entrypoint.

2. _reconcile_columns caught sqlite3.OperationalError around every
   ALTER TABLE ADD COLUMN and logged at DEBUG. Lock contention from
   orphaned sibling backends made the ALTER fail silently — startup
   "succeeded" with a half-reconciled schema, and the open-time lock
   patience (#74478) never saw the error because it was swallowed
   inside first. Now: "duplicate column" races stay at DEBUG,
   locked/busy re-raises so _connect_and_init_with_lock_patience
   retries the whole idempotent init with jittered backoff, and any
   other failure (e.g. un-ADDable NOT NULL) logs at WARNING.

Regression tests: a store missing sessions.last_read_at is healed by
the eager startup reconcile and serves list_sessions_rich; a locked
ALTER propagates and is retried to success by the open lock patience;
duplicate-column races stay quiet; other ALTER failures warn.

Fixes #79531
Fixes #80037

Reported-by: @yenhunghuang (#79531) and @FLOW3R0111 (#80037)
Root-cause analysis: @wangyi0177-eng (stale read probe) and
@www654cc-pixel (_reconcile_columns DEBUG-swallow under lock
contention); draft PR #80030 by @Tilly-YL prototyped the probe fix.
2026-08-14 21:36:29 -07:00
kshitij 91c7a67f44 fix(sessions): close the session_switch legacy gap and fence the resume walker at reset boundaries
Follow-ups to the salvaged #84009 commits:

- Add 'session_switch' to _RESET_END_REASONS: a reset continuation's
  parent can be promoted to session_switch (resume the reset parent,
  then switch away), which permanently hid pre-marker legacy children —
  reopen-time stamping cannot rescue them because the parent is being
  ended, not reopened. Probe-verified before/after.
- Share the legacy reset-child heuristic via _legacy_reset_child_sql()
  so _RESET_CHILD_SQL and reopen_session()'s stamping UPDATE cannot
  drift, and derive find_latest_gateway_session_for_peer's two recovery
  fence literals from _RESET_END_REASONS_SQL (was a third hand-written
  copy of the same set).
- Exclude reset children (marker + legacy shape) from the
  resolve_resume_session_id forward walker: resuming a reset parent
  could redirect into the post-reset conversation — the exact context
  the user reset away. Regression tests cover both shapes plus the
  walker's original compression-tip behavior; mutation-checked.
2026-08-13 23:45:21 +05:30
embwl0x ce89afa59c fix(sessions): keep reset conversations listable 2026-08-13 23:45:21 +05:30
Aldo 9cf4fe5513 fix(state): bound WAL growth and checkpoint after VACUUM
`sessions optimize` could consume several GB of disk instead of freeing
any, filling the host to 100% on exactly the large databases it exists to
shrink.

Two causes, both in the WAL lifecycle:

1. No `journal_size_limit`. SQLite defaults to -1 (unlimited), so after a
   checkpoint the WAL is reused in place and never truncated —
   `state.db-wal` permanently keeps the high-water mark of the largest
   transaction ever run. `hermes_cli/kanban_db.py` already bounds its WAL
   with `wal_autocheckpoint=100`; the session store, by far the larger
   database, had no equivalent.

2. `vacuum()` checkpoints BEFORE `VACUUM` but not after. VACUUM rewrites
   every page through the WAL, so the pre-checkpoint does nothing about
   the slack VACUUM itself creates.

Measured on a 3.0 GB state.db: `hermes sessions optimize` reported
"3143.9 MB -> 3155.1 MB (reclaimed -11.2 MB)" while leaving a 3.07 GB
state.db-wal behind. Free space fell from 6.9 GB to 772 MB (100% full)
and stayed there. A manual `PRAGMA wal_checkpoint(TRUNCATE)` recovered
the full 3.07 GB, confirming it was slack, not data.

Fix: set `journal_size_limit` (64 MiB) when enabling WAL, and truncate
the WAL again after VACUUM. Both are best-effort and never raise — a
failure costs disk slack and must not stop the DB from opening.

Tests assert the contract (limit is a finite positive bound; VACUUM does
not leave an oversized WAL) rather than pinning the byte count, which is
a tunable. They skip where WAL is unavailable — including hosts where
Hermes falls back to journal_mode=DELETE due to the SQLite 3.50.4
WAL-reset bug.

Verified: 462 passed / 3 skipped in tests/test_hermes_state.py, and
_apply_wal_size_limit flips a real WAL database from -1 to 67108864.
Tested on Linux (aarch64, Python 3.11).
2026-08-12 19:43:35 -07:00
lkz-de ba80f3b86d fix(state): PASSIVE not TRUNCATE for all state.db checkpoints (#45383)
SessionDB.close() ran `PRAGMA wal_checkpoint(TRUNCATE)`. Every cron
run_agent opens and closes its own transient SessionDB, so on a busy
fleet this fired a full WAL reset many times an hour, racing the
gateway's long-lived writer on a large WAL database and tearing hot
B-tree pages -- structurally the same corruption this module's own
periodic checkpoint was already switched to PASSIVE to avoid (#45383).
Only close() and two manual-maintenance paths still used TRUNCATE.

Route every checkpoint on the shared state.db through PASSIVE:
  - close()                    (hermes_state.py)
  - pre-VACUUM in vacuum()     (hermes_state.py)
  - post-optimize-storage      (hermes_state_search.py)

PASSIVE never resets/truncates the WAL and never takes the exclusive
checkpoint lock, so it cannot lose a transient closer's race with the
live writer. The WAL is instead bounded by `journal_size_limit` and the
writer's natural post-checkpoint reset. TRUNCATE belongs only on a
sole-opener/quiescent connection (e.g. offline maintenance); this change
does not try to detect that -- PASSIVE is the safe default.

Diagnosed as the root cause of three state.db B-tree corruptions in
2026-08: damage localized to the hottest-written pages (gateway_routing
and the sessions indexes), with whole zero-filled pages still live and
off the freelist -- the checkpoint/reset-race signature, not disk or
application SQL.

Tests: tests/test_wal_checkpoint_strategy.py now asserts PASSIVE at
close(), before vacuum(), and after optimize_fts_storage() VACUUM;
tests/test_hermes_state.py asserts close() likewise. Focused run:
226 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 19:43:22 -07:00
Tranquil-Flow 6e99531c8e fix(gateway): respect reset boundaries during recovery (#68539)
find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)
2026-08-09 15:02:27 -07:00
Teknium 90311ee75f fix(search): strip % from non-CJK FTS5 queries
Closes the residual the contributor's own triage comment flagged: % was
excluded from the special-char class to protect the CJK LIKE fallback,
but a non-CJK query never reaches that fallback (is_cjk gates it), so
'50%' still hit MATCH raw and silently returned zero results. Strip %
whenever the sanitized query contains no CJK; the CJK path keeps its
pre-existing contract. Regression tests for both directions.
2026-08-08 19:17:09 -07:00
Drexuxux c595dcb955 fix(search): strip the FTS5 special characters the sanitizer was missing
_sanitize_fts5_query's strip step only removed +{}():"^ . Every other
character FTS5's grammar rejects outside a quoted phrase reached MATCH
raw and raised, and — as the step's own comment says about the colon it
was fixed for — the execute site swallows that into zero results. Session
search silently found nothing for ordinary queries:

  it's            fts5: syntax error near "'"
  gateway/run.py  fts5: syntax error near "/"
  user@host       fts5: syntax error near "@"
  a,b             fts5: syntax error near ","
  why?            fts5: syntax error near "?"
  e=mc2           fts5: syntax error near "="

Complete the class and assemble it with re.escape, because written as a
regex literal the backslash was eaten as an escape and never made it in
(C:\path\file still raised after the first pass).

Measured against a real FTS5 table over 651 realistic queries:
373 unparsable before, 77 after. The remainder is leading/trailing "." and
"-", which #43889 already covers.

% is deliberately left in: the CJK path falls back to a LIKE search that
needs it literal and escapes wildcards itself, so stripping it widened
those queries onto unrelated rows (test_cjk_like_escapes_wildcards).
2026-08-08 19:17:09 -07:00
kshitij edf2cb4bf8 perf(sessions): skip counting entirely when transcript guards are disabled
With sessions.max_*_messages: 0 the guards previously still ran an
unbounded COUNT (full lineage for resume) — the exact pathological work
disabling them is meant to avoid. Live callers use the raise side
effect only, so return 0 without touching the messages table.
2026-08-08 13:36:08 +05:30
kshitij e8b05dc6c2 perf(dashboard): keyset pagination for streaming session export
OFFSET paging made the streaming export O(n^2) on huge transcripts;
after_id keyset paging keeps each page seek O(1). Adds after_id to
SessionDB.get_messages (ascending-only, guarded against latest/offset
combos).
2026-08-08 13:36:08 +05:30
kshitij f0794640f6 feat(sessions): config-gate transcript safety limits
sessions.max_resume_messages / sessions.max_export_messages (default
20000, 0 disables) replace the hardcoded hard-rejects, and the CLI
'sessions export' guard becomes per-session instead of cumulative so
full-DB backups of many small sessions keep working. Error guidance now
points at the config override instead of the (corruption-only) repair
command.
2026-08-08 13:36:08 +05:30
kinsolee c750d5354a fix(sessions): prevent oversized transcripts from exhausting memory 2026-08-08 13:36:08 +05:30
Chen Jin 23dce021a5 perf(fts): drain trash tables with a high-water marker instead of re-scanning
_fts_teardown_trash_step deleted rows via 'WHERE key IN (SELECT key
LIMIT N)' — each chunk's subquery re-scanned from the start of the
table, so chunk k skipped past (k-1)xN already-deleted rows: O(n²)
total row visits. On a v22 shadow table with ~230K rows that is on the
order of 10^8 row visits, turning optimize-storage teardown into a
multi-hour grind on slow disks, with a write lock held per chunk.

Single-column INTEGER-PK trash tables now drain via a fts_teardown_<tbl>_progress
high-water marker mirroring fts_rebuild_step: each chunk claims rows
past the marker (SELECT ... WHERE key > ? ORDER BY key LIMIT N), deletes
the claimed range, and publishes the new marker in the same transaction.
Per-chunk work is bounded → O(n) total.

TEXT-PK tables (the FTS config shadow table, pk like 'version') and
compound-key tables fall back to the legacy chunked delete — those are
small by construction.

Fixes #79324
2026-08-07 18:40:47 +05:30
kshitij 169758d42f perf(tests): cut test_hermes_state.py 52s -> 10s — kill sleep throttle + per-row seeding
test_hermes_state.py was the slowest file in the suite (46.7s in CI's
durations cache) and therefore the LPT floor: no test slice can finish
faster than its slowest file, which caps how far slicing the test matrix
wider can cut the merge-gate critical path.

Profiling (cProfile on the slowest tests) found the time was dead, not
work:

1. time.sleep in optimize_fts_storage's inter-chunk throttle — 4.1s of
   a 4.6s migration test. The throttle exists so a LIVE gateway/CLI
   sharing the DB isn't starved of the write lock; tests run against a
   private tmp-path DB with no concurrent process, so the sleep protects
   nobody. New autouse fixture zeroes _FTS_REBUILD_MIN_PAUSE /
   _FTS_REBUILD_DUTY_FACTOR for this file (~20s saved). No test asserts
   on wall-clock pacing, so nothing weakens.

2. TestGetMessagesPagination._seed appending 3000 messages one
   append_message (= one commit, and off WAL one fsync) at a time —
   ~10s of seeding before the query under test even ran. Switched to
   append_messages_batch (one write transaction), the API the docstring
   of which exists for exactly this shape. The perf contract the seed
   feeds still discriminates: measured 11 progress-handler steps on the
   indexed path vs 855 on the forced scan path, against the unchanged
   300 threshold.

Measured (local, 3 runs + canonical runner):
  before: 187 passed in 51.8s
  after:  187 passed in 9.1-16.1s (canonical scripts/run_tests.sh: 14.9s)

Zero production code touched; 187 tests before and after.
2026-08-06 05:38:04 +05:30
kshitij 55e70f570e test(sessions): guard the Windows backslash child arm of _cwd_prefix_clause
The quadruple-backslash pattern arm is the trickiest byte sequence in the
fix and had no direct coverage — 'simplifying' it to a double backslash
would break Windows child matching with every test still green. Mutation
checked: weakening the arm fails this test.
2026-08-06 04:16:12 +05:30
Drexuxux b37de01926 fix(sessions): escape LIKE wildcards in the cwd-prefix clause
_cwd_prefix_clause builds "cwd is this directory or under it" for session
listing, workspace resume and prune/archive. The two LIKE arms bound the
raw prefix, so `_` and `%` acted as wildcards on a value that is a path:

  cwd_prefix="/home/me/my_project"
    main -> ['sibling', 'target']    # /home/me/myXproject/src matched too
    fix  -> ['target']

`_` matches any single character, so a same-length sibling directory with
children falls inside the pattern. prune_sessions() deletes the rows it
matches (and their on-disk transcripts), so an unrelated project's history
goes with it.

Escape the needle and pair both arms with ESCAPE, the convention the rest
of this file already uses; the literal separator backslash in the Windows
pattern is escaped for the same reason. The `=` arm is an exact compare and
keeps the raw prefix, so directory-and-children matching is unchanged.

Follow-up to the *_like filters in #78681, kept separate because this helper
is shared by four call sites beyond prune.
2026-08-06 04:16:12 +05:30
Drexuxux 1d2dabce56 fix(sessions): escape LIKE wildcards in prune/archive substring filters
_prune_filter_where documents title_like / model_like / branch_like as
"case-insensitive substring matches", and the CLI confirmation renders them
as "title contains 'X'". They were bound straight into a bare LIKE, so `_`
matched any single character and `%` any run.

The builder backs prune_sessions(), which deletes session rows and their
on-disk transcripts, so the over-match is unrecoverable: pruning
title_like="user_auth" also destroys "user-auth", "userXauth" and
"user auth". `_` is not exotic here -- git branch names and session titles
carry it routinely.

Escape the operator's needle and add ESCAPE '\' to the three clauses, the
same convention the rest of this file already uses for LIKE queries. Match
direction is unchanged for needles without wildcards.

Left alone: _cwd_prefix_clause has the same unescaped shape but is shared
by four call sites beyond prune, so it is a separate change.
2026-08-06 04:16:12 +05:30
kshitij 7026177b30 test(session): guard config-gated performance PRAGMAs across all connection types
E2E guard for the salvaged PR #71755: database.cache_size/mmap_size/
temp_store from config.yaml must reach the writer connection, the
read-only cross-profile attach, and the WAL per-thread reader — and a
default install (no database: keys) must keep byte-identical SQLite
defaults on every connection type. Also covers integer-coercion
rejection of garbage values for the three new keys.

cache_size uses -16000 (not the doc example -2000) because -2000 is
SQLite's compiled-in default and would not discriminate a regression.
2026-08-03 20:28:35 +05:30
Kyzcreig 2f32092b38 hermes sessions optimize-storage aborts with
```
Error: optimization failed: no such table: messages_fts_trigram
No data was lost. Re-run to resume.
```

on any install where the trigram FTS index is legitimately absent. The failure is
deterministic — re-running can never make progress, because the crash happens at the same
point every time — so the database is permanently stuck on the legacy high-footprint FTS
layout with no supported way forward.

Observed on a 5.4 GB production `state.db`. After the fix the same database optimized
successfully and shrank to 3.3 GB.

The trigram index is absent whenever the runtime cannot maintain it. On a SQLite build
without the `trigram` tokenizer, `_ensure_fts_schema()` returns `False`, so `__init__`
leaves `self._trigram_available = False` and no `messages_fts_trigram` table on disk. This
is a **supported degraded runtime**, not damage — CJK/substring search falls back to
`LIKE` and everything else works normally. `_is_fts5_unavailable_error()` and
`_warn_trigram_unavailable()` exist specifically to make this path graceful.

Two code paths write the boundary sweep for the deferred FTS rebuild, and only one of them
respects that flag:

| Function | Trigram `INSERT` guarded? |
|---|---|
| `fts_rebuild_step()` | ✅ `if include_trigram:` where `include_trigram = self._trigram_available` |
| `_fts_rebuild_finish()` | ❌ unconditional |

`_fts_rebuild_finish()` runs the boundary sweep at the *end* of the backfill. Its
unguarded `INSERT INTO messages_fts_trigram …` raises `OperationalError`, which propagates
out of `optimize_fts_storage()` and aborts the entire optimization — *after* the backfill
has already completed. Hence the characteristic output showing 100% progress immediately
before the error:

```
Rebuilding index: 100% (909,671/909,671)
Error: optimization failed: no such table: messages_fts_trigram
```

There is a second, quieter consequence. The teardown phase that reclaims the demoted
`fts_v22_trash_*` shadow tables runs *after* the backfill phase in
`optimize_fts_storage()`. Because the crash happens before teardown is ever reached, those
tables are never emptied or dropped — so the space the migration was supposed to reclaim
stays allocated indefinitely, and the leftover trash tables look (misleadingly) like
evidence of a half-finished migration.

Build a populated v23 database, set the deferred-rebuild markers, then reopen it on a
runtime where `_ensure_fts_schema('messages_fts_trigram', …)` returns `False` (exactly
what a SQLite build without the trigram tokenizer produces) and call
`optimize_fts_storage()`:

```
[precondition] trigram absent, _trigram_available=False, rebuild pending  ✓

RED  ✗ optimize_fts_storage raised OperationalError: no such table: messages_fts_trigram
```

With this patch applied, unchanged harness:

```
     optimize_fts_storage returned {'ok': True, 'vacuumed': None}
GREEN ✓ optimize ok; markers cleared; base FTS 'zebra' -> 200 hits
```

Full harness and transcripts in `TEST-EVIDENCE.md`.

Gate the sweep on `self._trigram_available`, exactly as `fts_rebuild_step()` already does:

```python
include_trigram = self._trigram_available

def _do(conn):
    ...
    if include_trigram:
        conn.execute("INSERT INTO messages_fts_trigram(...) ...")
```

The base `messages_fts` sweep and the marker cleanup are untouched, so the rebuild still
finalizes correctly and the index remains complete for every row it is responsible for.
The fix does not disable or weaken search to dodge the error — the regression tests assert
that base FTS still returns results afterwards.

`TestFtsRebuildFinishWithoutTrigram` in `tests/test_hermes_state.py`:

- `test_rebuild_finish_skips_trigram_when_unavailable` — drives `_fts_rebuild_finish()`
  directly on a trigram-less runtime; asserts it completes, clears both rebuild markers,
  and leaves base FTS searchable.
- `test_optimize_fts_storage_succeeds_without_trigram` — end-to-end through the public
  `optimize_fts_storage()` entry point; asserts `ok=True`, markers cleared, search intact.

Both use the existing `_NoTrigramConnection` helper already in the file. Both fail on
`main` with `no such table: messages_fts_trigram` and pass with this patch.

`tests/test_hermes_state.py` passes in full (463 tests → 465 with these two). `ruff` clean.

This PR is the crash only.

A companion PR narrows `_db_opens_cleanly()` so that
`hermes sessions repair --check-only` stops reporting a write-broken FTS schema as
healthy — the gap that makes this class of problem hard to diagnose in the first place.
The two are independent and can land in either order.
2026-08-03 19:09:15 +05:30
Jakub Wolniewicz f795d542f6 test(session-search): guard projected enrichment 2026-08-03 17:50:58 +05:30
Jakub Wolniewicz ffb54305c4 perf(session-search): project fields before enrichment 2026-08-03 17:50:58 +05:30
kshitij b2e1d57466 test(state): guard compact_rows threading through batched tip-row fetch
Adds two regression tests for the #59077 batch: (1) _get_session_rich_rows_batch(compact_rows=True) uses the schema-derived compact projection (no system_prompt, git_branch/git_repo_root kept); (2) list_sessions_rich(compact_rows=True) threads compact_rows through the compression-tip projection call site. Mutation-checked: hardcoding compact_rows=False at the call site fails test 2.
2026-08-03 17:32:17 +05:30
jasoisjaso adcdf9dc63 perf(state): batch compression-tip row fetch in list_sessions_rich
list_sessions_rich()'s compression-root projection called
_get_session_rich_row() once per root — a separate single-row query per
compression root on every session-list render. Resolve every tip id
first, then fetch all tip rows in one WHERE id IN (...) query via the
new _get_session_rich_rows_batch().

_get_session_rich_row() is now a thin wrapper over the batch method, so
the enriched SELECT (preview + last_active) lives in exactly one place —
future column changes (e.g. #42196's include_system_prompt) only touch
one query.

get_compression_tip()'s chain walk is untouched; it's a genuine
per-session graph walk with branch/delegate-exclusion and race handling,
and batching it safely is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:32:17 +05:30
PRATHAMESH75 7f1d84fe7f perf(insights): pin partial index on assistant tool-call queries
Review follow-up (#67341): on a freshly initialized state.db (before
ANALYZE has run) the source-filtered branches of _get_tool_usage /
_get_skill_usage did not select idx_messages_assistant_calls_by_session
— the optimizer drove from idx_sessions_source_id and probed each
session's messages via idx_messages_session_active, scanning non
tool-call rows. Pin the index with INDEXED BY on all four fixed-predicate
branches so the plan is deterministic for both the unfiltered and
source-filtered scopes without depending on statistics.

Safe because the index is declared in SCHEMA_SQL (created by every
read-write SessionDB._init_schema) and every InsightsEngine caller opens
a read-write SessionDB; read-only attachments (which skip schema init)
are never used for insights.

Extract the four queries into class constants and add tests: query-plan
coverage for both scopes without ANALYZE, row-level equivalence between
pinned and un-pinned forms, and an assertion that INDEXED BY fails loudly
if the index is absent.
2026-08-03 17:32:13 +05:30
PRATHAMESH75 034eadb326 perf(state): index assistant tool-call rows for Insights queries
InsightsEngine._get_tool_usage and _get_skill_usage scan messages for
role='assistant' AND tool_calls IS NOT NULL, but no index aligns with
that predicate, so SQLite scans the full messages table on a large
state.db. Add a partial index over exactly those rows.

role and tool_calls are base columns in the messages table, so the index
lives in SCHEMA_SQL (created on both fresh and existing databases via the
executescript on every open) rather than DEFERRED_INDEX_SQL.

Adds schema regression coverage (fresh + reopened DB, plan uses the index)
and an Insights regression test proving tool/skill output is identical with
and without the index present.

Fixes #67341
2026-08-03 17:32:13 +05:30
skywind be3be06182 perf: replace COUNT(*) with LIMIT-based existence checks
Two places were using SELECT COUNT(*) when they only needed a boolean:
- has_any_sessions() called session_count() > 1 (full table scan)
- delete_session() used SELECT COUNT(*) WHERE id=? (full matching scan)

Fix:
- Add session_count_ge(n) to SessionDB — short-circuits via
  SELECT 1 FROM sessions LIMIT n, returns bool
- has_any_sessions() uses session_count_ge(2) instead of session_count() > 1
- delete_session() uses SELECT 1 ... LIMIT 1 with fetchone() is None
- Add tests for session_count_ge
2026-08-03 17:31:29 +05:30
fangliquanflq c2088efe9e feat(gateway): session activity watchdog, stall notify, compress timeout (#72424)
Three mechanisms to detect and notify when gateway sessions stall silently:

1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
   and hermes status show progress during long turns without new message rows.

2. Stall watchdog: when a busy session has pending inbound and the shared
   activity clock is idle past agent.session_stall_timeout (default 300),
   log a WARNING and notify the user once to try /new. Notify-only; does
   not kill the turn.

3. Compaction timeout: fenceless compress_context callers get a progress-aware
   host budget (compression.context_timeout_seconds default 120 idle,
   compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
   cancel via commit fence, skip compaction without dropping messages, and
   continue the turn.

Closes #72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).

Cherry-picked from PR #72424 by @fangliquanflq.
2026-08-02 16:16:36 -07:00
Adolanium b2d5995fc6 fix(state): do not stamp empty FTS after interrupted optimize-storage demote
Demote wrote the empty v23 schema via executescript inside BEGIN IMMEDIATE,
which commits early and can leave trash + empty indexes without rebuild
markers. Re-run then tore down trash and stamped fts_storage_version with
docsize=0, permanently losing historical session search.

Stage markers with the demote, create schema only after they are durable,
heal empty-index bookkeeping on resume, and refuse settle until the base
index is populated. Settle refusal returns ok=False instead of raising,
and resume fails fast if the base v23 table cannot be re-created.

Orphan-marker repair only resets a missing fts_rebuild_progress to 0 once
the index is known empty: the chunk worker replays its whole selected id
range without an anti-join, so a partially indexed DB that lost only its
progress key is first reset to a known-empty surface, then rebuilt.

Ported onto the SessionDB mixin split (hermes_state_search.py /
hermes_state_schema.py).
2026-08-02 21:36:11 +05:30
Hermes agent 4e0a775580 fix(state): make VACUUM interval configurable 2026-08-02 21:32:13 +05:30
Hermes agent bd856a02f2 [verified] fix(state): throttle repeated VACUUM rewrites 2026-08-02 21:32:13 +05:30
kshitijk4poor e38055a85e fix(state): close the read-only connection when the FTS probe fails
The RO branch's new FTS capability probe raises sqlite3.DatabaseError on
a malformed store (the probe itself only catches OperationalError). The
outer __init__ handler re-raises without closing self._conn, leaking a
tracked connection for the process lifetime — which makes
_backup_db_file refuse its raw-copy, so the writable heal that follows
(web_server's stale-schema/malformed reopen) repairs the store WITHOUT
the forensic backup repair_state_db_schema promises. Close-then-reraise
on any probe failure, mirroring _open_probed's cleanup discipline.

Regression test: corrupt sqlite_master (duplicate messages_fts row),
assert the failed RO open leaves no live tracked connection and the
subsequent writable heal creates its malformed-backup file. Mutation-
checked: no-oping the cleanup handler makes the test fail.
2026-08-02 21:30:54 +05:30
joelbrilliant 57197cd48d fix(dashboard): preserve maintenance writes on read polling
Signed-off-by: joelbrilliant <joelbrilliant1@gmail.com>
2026-08-02 21:30:54 +05:30
spfcraze 8f91e249e4 perf(state): add messages(session_id, id) index for window/ordering queries
Every ORDER BY id query on the messages table sorted or scanned the
whole session: get_messages_around's window seek, latest_message_row_id
(LIMIT 1), and get_messages' full-load ordering all paid O(session
history) per call — hot mid-turn via session_search and reactions.
messages.id is an original column (INTEGER PRIMARY KEY AUTOINCREMENT),
so the index lives in SCHEMA_SQL next to idx_messages_session — no
legacy-column migration hazard (the kanban lesson from #28776 does not
apply).

Measured (real schema, one 20k-message session, median of 30):
get_messages_around 7.08 -> 0.22 ms (32x), latest_message_row_id
3.37 -> 0.011 ms (307x), get_messages full load 111.6 -> 98.6 ms
(1.13x — remaining cost is row deserialization, not the sort).
Window results byte-identical at probe points across the session.

Tests: VM-step pin (get_messages_around bounded work, calibrated
~12 vs ~855 handler calls, threshold 300 — fails without the index)
and window parity with/without the index. No EXPLAIN/plan text
(behavior contracts, AGENTS.md).
2026-08-02 21:16:17 +05:30
GodsBoy fa9e967a2d fix(gateway): scope session lists before limiting 2026-07-31 22:36:55 -07:00
Lavya Tandel 74d6cc2209 fix(config): respect database.journal_mode from config.yaml
Config-driven `journal_mode`, `wal_autocheckpoint`, and `journal_size_limit`
are now honored in `SessionDB` init. Users running SQLite on NFS/SMB or
with custom tuning had no supported config surface; values were hardcoded
at connection time.

What
- New `apply_database_pragmas()` in `hermes_state.py`
- Reads nested `database:` keys from `config.yaml` via existing `cfg_get`/`load_config`
- Called after `apply_wal_with_fallback()` in `SessionDB._connect_and_init()`

Fix
- Adds optional PRAGMA switches for journal_mode, wal_autocheckpoint, journal_size_limit
- On Darwin; Windows keeps DELETE unless config explicitly requests WAL

Runtime Proof
$ /opt/homebrew/bin/pytest tests/test_hermes_state.py::TestApplyDatabasePragmas -q
3 passed in 0.72s

Regression Checks
- Full `tests/test_hermes_state.py`: 306 passed in 11.32s
2026-07-29 18:13:09 -07:00
Teknium 6b81590c55 test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00
Brooklyn Nicholson 1b317d23fb fix(sessions): a pinned conversation can't be paged out of the list
`list_sessions_rich` returns one recency-ordered window, so a pinned
conversation that hadn't been touched in a while simply wasn't in the
payload. The desktop's Pinned section resolves pins against the loaded
rows, so the pin rendered as nothing until something dragged the row
back onto the page.

A pin is a "this must always be reachable" statement, which makes
falling off the page a bug rather than a paging outcome. `include_pinned`
adds one bounded query for the rows carrying `pinned = 1` that the
window missed, reusing the page's own WHERE clause — an archived or
filtered-out conversation stays out, and a pin is never a filter bypass.
It runs before compression projection, so a back-filled root surfaces
under its live tip exactly like a row that made the page on its own.

Co-authored-by: hrnbld <260600092+hrnbld@users.noreply.github.com>
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: Tamaz-sujashvili <56168197+Tamaz-sujashvili@users.noreply.github.com>
Co-authored-by: ferminquant <14808645+ferminquant@users.noreply.github.com>
2026-07-29 12:00:21 -05:00
Shannon Sands cb00495551 Add dashboard session filtering 2026-07-28 22:41:56 -07:00
Teknium df841d342c fix(state): complete the bounded-merge protocol — usermerge floor, progress-bounded continuation, tolerate mid-rebuild missing index
Follow-up on top of #65554 (@the3asic):

- Lower FTS5 'usermerge' to its minimum of 2 (persisted in the config
  shadow table, applied once per SessionDB instance). Without this a
  positive-rank 'merge' skips any level holding fewer than 4 segments
  (SQLite FTS5 §6.8), so the fragmented-index case the cadence targets
  never converges.
- Run up to _FTS_MERGE_COMMANDS_PER_PASS (4) bounded merge commands per
  index per cadence, stopping early on the documented no-progress
  signal (total_changes delta < 2). Each command is its own implicit
  transaction, so the write lock is released between commands.
- Skip a missing messages_fts instead of raising: the chunked
  optimize-storage rebuild legitimately drops + backfills FTS tables
  while writers keep running; warning every 1000 writes for the whole
  backfill window would be noise, and optimize_fts() has always
  treated missing tables as skippable.
- Replace traced-SQL shape assertions with behavioral tests: real
  fragmented-index convergence (automerge suppressed, 60 segments) and
  a 3-segment below-default-usermerge compaction test that fails
  against a bare positive-rank merge (sabotage-verified).

Validation on a sqlite3.backup() copy of a real 10.7 GB production
state.db (1.49M messages, 1.3 GB + 2.9 GB FTS shadow tables):
worst per-command write-lock hold 41.8 ms (was 9.2 s / 18.1 s per
index with 'optimize'), search results byte-identical, fts5
integrity-check and PRAGMA integrity_check clean, steady-state pass
0.0 ms.
2026-07-28 18:18:17 -07:00
3ASiC db16c5ce51 fix(state): bound routine FTS merge work 2026-07-28 18:18:17 -07:00
Teknium 76b0ea5118 fix(state): rebuild legacy gateway_routing PK; guard session_store in dispatch hook
Two log-spam bugs found in live gateway logs:

1. gateway_routing UNIQUE-constraint spam (261 warnings in one errors.log):
   early builds of the #59203 routing-index migration created
   gateway_routing with 'session_key TEXT PRIMARY KEY' and no scope
   column. _reconcile_columns() ADDs the missing scope column but SQLite
   cannot ALTER a primary key, so the shipped composite
   PRIMARY KEY (scope, session_key) never lands on those databases. Both
   write paths then fail on every save:
   - save_gateway_routing_entry: 'ON CONFLICT clause does not match any
     PRIMARY KEY or UNIQUE constraint'
   - replace_gateway_routing_entries: 'UNIQUE constraint failed:
     gateway_routing.session_key' whenever the same session_key exists
     under another scope (e.g. test-suite scopes leaked into a live DB).
   New _heal_gateway_routing_pk() rebuilds the table once with the
   composite key, preserving rows (newest wins on collisions, NULL scope
   coalesced to ''). Same one-time-heal pattern as the #51646 active-
   column repair. Verified E2E against a copy of a real affected state.db.

2. pre_gateway_dispatch warned ''GatewayRunner' object has no attribute
   'session_store'' and silently dropped the hook for every message on
   partially-initialized runners (bare object.__new__ runners in tests,
   and any future init-order change). Pass
   getattr(self, 'session_store', None) so the hook always fires
   (pitfall #17 pattern).

Both regression tests fail without their fixes (sabotage-verified).
2026-07-28 11:58:54 -07:00
liuhao1024 9e2f07e704 perf(dashboard): use GROUP BY for session stats instead of fetching 10k rows
Replaces the O(N) list_sessions_rich histogram in /api/sessions/stats
with a single GROUP BY query, reducing response time from ~575ms to
<1ms on large databases.

Original PR #48921 by @liuhao1024. Salvage fixes based on review
feedback from teknium1 and @wernerhp:

1. Preserve try/except guard — a DB error still degrades to empty
   by_source instead of failing the whole stats response.
2. GROUP BY COALESCE(source, 'cli') — the original GROUP BY source
   could emit duplicate 'cli' keys (NULL group + literal 'cli' group)
   that the dict comprehension silently dropped.
3. Add exclude_children=True — list_sessions_rich excludes subagent
   runs, delegates, and compression continuations by default; the
   bare GROUP BY counted all rows, inflating source counts.

Aggregate shape (exclude_children/include_archived/limit params)
adapted from closed duplicate #61120 by @mijanx.

Closes #48914
Co-authored-by: mijanx <mijanx@users.noreply.github.com>
2026-07-28 19:07:28 +05:30
kshitijk4poor 2c1809e6ca revert: PR #72817 — session activity watchdog, stall notify, compress timeout
Reverting #72817 (salvage of #72424) pending further review.
All 4 commits reverted: feat, refactor, chore (contributor map), CI fix.
2026-07-28 00:15:00 +05:00
fangliquanflq cfb206fe2e feat(gateway): session activity watchdog, stall notify, compress timeout (#72424)
Three mechanisms to detect and notify when gateway sessions stall silently:

1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
   and hermes status show progress during long turns without new message rows.

2. Stall watchdog: when a busy session has pending inbound and the shared
   activity clock is idle past agent.session_stall_timeout (default 300),
   log a WARNING and notify the user once to try /new. Notify-only; does
   not kill the turn.

3. Compaction timeout: fenceless compress_context callers get a progress-aware
   host budget (compression.context_timeout_seconds default 120 idle,
   compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
   cancel via commit fence, skip compaction without dropping messages, and
   continue the turn.

Closes #72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).

Cherry-picked from PR #72424 by @fangliquanflq.
2026-07-28 00:44:02 +05:30
Frowtek a228b81501 fix(sessions): preserve recently active sessions during pruning 2026-07-26 19:30:21 -07:00
teknium1 2c69316742 test(compression-lock): cover the holder-only refresh predicate
The salvaged predicate change (expires_at dropped from the WHERE clause)
had no test that failed without it — a sabotage run reverting it left the
suite fully green. Add the two missing cases:

- a refresher starved past its own TTL revives its still-unclaimed row
- a holder whose lock was legitimately reclaimed cannot resurrect it

Both verified to fail against the pre-fix predicate.
2026-07-25 22:47:07 -07:00