Skip to content

fix(state): WAL watchdog + v20 view-backed FTS + trigram config gate - #110

Merged
exiao merged 7 commits into
live-configfrom
fix/state-db-hardening
Jul 15, 2026
Merged

fix(state): WAL watchdog + v20 view-backed FTS + trigram config gate#110
exiao merged 7 commits into
live-configfrom
fix/state-db-hardening

Conversation

@exiao

@exiao exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

state.db concurrency + reliability hardening. Live on 2026-07-13 the DB was 9.3 GB with a 3.6 GB -wal; ~70% of the file was FTS duplication. This addresses both root causes in one PR (implements plans/state-db-concurrency-reliability.md).

Base branch: live-config. Patch note: plans/hermes-patches/state-db-hardening.md.

Changes

1. WAL watchdog (hermes_state.py + gateway/run.py)
A long-lived reader pins the WAL so the every-50-writes TRUNCATE checkpoint silently no-ops and the -wal grows without bound — and a giant WAL + hard shutdown is the malformed-image corruption scenario. New SessionDB.wal_watchdog(max_mb): when -wal exceeds the threshold, run wal_checkpoint(RESTART) (drains the log, restarts at offset 0), then TRUNCATE to reclaim disk when no reader is pinning it. The "reader is pinning it" decision keys on the RESTART return's busy flag (not the post-checkpoint file size — RESTART never truncates); on busy it logs a WARNING naming the PIDs holding the DB (psutil, best-effort). The gateway calls it at startup and hourly.

2. Schema v20 — view-backed external-content FTS
v11 switched the FTS tables to INLINE mode (no content=), storing a full copy of every message body in each shadow table (~2.8 GB). v20 introduces a messages_search_v(id, content) VIEW = content || tool_name || tool_calls and rebuilds messages_fts + messages_fts_trigram as external-content (content=messages_search_v, content_rowid=id). This preserves the v11 intent (tool_name/tool_calls searchable, snippet() works) while dropping the duplicate copies. External-content DELETE/UPDATE triggers use the fts5 'delete' command form. The migration is idempotent, VACUUMs (checkpoint before/after to bound WAL), bumps the version only on success, and honors the FTS5-unavailable pattern.

3. sessions.fts_trigram config gate (default true)
When false, the trigram table + its triggers are dropped and _trigram_available=False, so the existing CJK LIKE fallback engages — reclaiming the ~5 GB trigram index. Set false in the live config after merge.

4. Docssession-storage.md FTS section (was stale, still showed content=messages), migration table (v16/v18/v20 rows), and the new config knobs.

New config (config.yaml, no env vars): sessions.wal_watchdog: true, sessions.wal_max_mb: 64, sessions.fts_trigram: true.

Verification

  • Scripted E2E on a synthetic v19 → v20 DB (inline FTS seeded, reopened): version bumps to 20, tables become external-content, web_search / kittens / tool_calls searchable, snippet markers present, incremental insert indexes, idempotent reopen.
  • Trigram-off: table absent, _trigram_available=False, CJK query falls back to LIKE and still hits. WAL watchdog: 4.7 MB → 0 when unpinned; no-op below threshold.
  • scripts/run_tests.sh tests/test_hermes_state.py345 passed (6 new, invariant-only — no change-detector tests). Config + session_search + gateway startup suites green.

Not in this PR (deliberate)

  • E2E against a copy of the real 9.3 GB state.db + a live gateway restart is the QA-card gate (plan §Verification gates), not this PR.
  • Flipping sessions.fts_trigram: false in the live config is a post-merge op.
  • No deploy; main untouched.

state.db was 9.3GB with a 3.6GB -wal live. Two root causes, both addressed:

1. WAL checkpoint starvation — a pinned reader lets the -wal grow unbounded
   (giant WAL + hard shutdown = malformed-image corruption). New
   SessionDB.wal_watchdog(max_mb): RESTART-checkpoint an oversized -wal, then
   TRUNCATE to reclaim when unpinned; on a busy checkpoint log a WARNING naming
   the PIDs holding the DB. Gateway calls it at startup + hourly, gated on
   sessions.wal_watchdog / sessions.wal_max_mb (config.yaml, no env vars).

2. FTS duplication bloat — v11 made messages_fts/_trigram INLINE (no content=),
   storing a full text copy per table (~2.8GB). Schema v20 rebuilds both as
   external-content backed by a new messages_search_v VIEW
   (content||tool_name||tool_calls), preserving the v11 intent (tool-call search
   + snippet()) while dropping the copies. External-content triggers use the
   fts5 'delete' command form. Migration is idempotent, VACUUMs with
   checkpoint before/after to bound WAL, bumps the version only on success, and
   honors the FTS5-unavailable pattern.

sessions.fts_trigram (default true): when false, drop the trigram table+triggers
and fall back to the existing CJK LIKE path — reclaims the ~5GB trigram index.

Docs: session-storage.md FTS section (was stale, showed content=messages),
migration table (v16/v18/v20), and the new config knobs.

Tests: invariant-only — search finds tool_calls pre/post migration, snippet
markers present, migration idempotent, trigram-off falls back to LIKE, WAL
watchdog shrinks an unpinned WAL and no-ops below threshold.

Patch note: plans/hermes-patches/state-db-hardening.md
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

Claude encountered an error after 3s —— View job


I'll analyze this and get back to you.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the database schema to version 20, transitioning the full-text search (FTS) tables to view-backed external-content storage to eliminate duplicate message body storage and reclaim disk space. It also introduces a configuration-gated trigram index and a WAL watchdog to prevent unbounded WAL file growth. The reviewer's feedback highlights several important improvements for robustness and correctness: implementing an explicit transaction rollback on migration failure to prevent database corruption, avoiding a TOCTOU race condition when checking WAL size, resolving potential path matching and AttributeError issues when identifying PIDs holding the database, catching the broader sqlite3.Error exception during checkpoints, and correctly using monkeypatch in tests to suppress periodic checkpoints.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread hermes_state.py
Comment thread hermes_state.py
Comment thread hermes_state.py Outdated
Comment thread hermes_state.py
Comment thread tests/test_hermes_state.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — reviewed the WAL watchdog, the v20 view-backed external-content FTS migration/triggers, and the trigram config gate.

Confirmed correct: external-content FTS wiring (view + content=/content_rowid, 'delete'-command triggers, 'rebuild'), byte-identical concatenation across view/insert-trigger/delete-payload, unbroken search read-path (search_messages returns m.content via JOIN and snippet() still works under external content), consistent trigram gate with the 3-vs-6 trigger-repair threshold, and a WAL watchdog that keys on the RESTART busy flag and never raises. No env vars, no new model tool, and no prompt-cache/role-alternation surface touched. Tests are invariant-based.

One minor, non-blocking note (posted on the PR): flipping sessions.fts_trigram=false on an already-v20 DB drops the trigram table but doesn't VACUUM in the open path, so the ~5 GB isn't returned to the OS until a later vacuum runs — the initial v19→v20 migration does VACUUM, so cutover is fine.

0 blocking, 1 minor.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22c97f7f85

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py
@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Code review — PR #110 (state.db WAL watchdog + v20 view-backed FTS + trigram gate)

Local reviewer pass, ran against live HEAD 22c97f7f8 in an isolated pull/110/head worktree. Reviewed against plans/state-db-concurrency-reliability.md and AGENTS.md. Findings backed by real runs, not reading alone.

Verdict: REQUEST CHANGES — one CI-blocking P2 (event-loop-blocking DB call). The FTS/migration/trigram core is solid and verified.


P2 — hourly WAL watchdog blocks the gateway event loop (CI is RED on this)

gateway/run.py:8071, inside async def _session_expiry_watcher, calls the checkpoint synchronously on the loop:

self._session_db._db.wal_watchdog(max_mb=int(_sess_cfg.get("wal_max_mb", 64)))

The comment says "Runs sync — the checkpoint is fast and this watcher tolerates a brief block", but the watchdog only fires when the WAL is already oversized, and its whole job is wal_checkpoint(RESTART) on a bloated/pinned WAL — precisely the case that is NOT fast. That stalls the entire gateway loop.

The repo already guards this class of bug. tests/gateway/test_async_session_db.py::test_sync_db_escape_confined_to_off_loop_sites asserts at most 3 self._session_db._db. sync-escape sites (construction + the run_sync executor closure). This PR adds two (:3245 startup, :8071 hourly), so:

AssertionError: self._session_db._db. sync escape used 5 times; at most 3 (construction + run_sync) is allowed.
assert 5 <= 3

Reproduced locally (fail) and it is the red check in CI ("Python tests / Run tests slice 2/8").

Fix:

  • The hourly call must go off-loop: await asyncio.to_thread(self._session_db._db.wal_watchdog, max_mb=...) — this both removes the loop stall and drops it out of the ._db. escape count.
  • The startup call at :3245 is genuinely off-loop (in __init__, before the loop serves, like the sibling maybe_auto_prune_and_vacuum at :3235). It legitimately adds one new escape site, so bump _ALLOWED_SYNC_DB_ESCAPES 3 → 4 with a one-line justification in the guard test.

Net: the guard should end at 4 escapes (construction ×2 + run_sync ×2... currently 3235/19485/20316 = 3, +3245 = 4), and the hourly path routes through asyncio.to_thread.


What I verified as PASS (evidence)

  • v11 intent preserved, index not corrupted. Seeded a message with tool_name/tool_calls, then did a raw SQL DELETE and UPDATE to fire the new 'delete'-command triggers. Old entries removed, new text indexed, and INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1) passes on both messages_fts and messages_fts_trigram. The plain-DELETE FROM corruption path the brief warns about is correctly avoided. snippet() still emits >>>/<<< markers.
  • Migration idempotent + interrupted-recovery safe. Forced a crash mid-migration (after the inline FTS tables were dropped): schema_version stayed at 19 (not bumped), and a clean reopen completed to v20 with web_search/kittens search intact and content=messages_search_v in place. Version-bump-only-after-success holds.
  • Trigram gate round-trips. ON→OFF drops the table + 3 triggers and CJK 大别山 falls back to LIKE (1 hit, no exception); OFF→ON rebuilds from the view and finds even rows written while the gate was off (rebuild reads the view, not incremental triggers); trigram integrity-check passes.
  • RESTART-not-TRUNCATE discipline is correct. The watchdog runs RESTART first and only follows with TRUNCATE when the RESTART returns not-busy (fully drained). On a pinned reader it stops at RESTART and logs a WARNING with PIDs — no starvation. Matches the plan.
  • Config, not env. All three knobs (wal_watchdog, wal_max_mb, fts_trigram) live in DEFAULT_CONFIG["sessions"] and are read via load_config / load_config_readonly. No HERMES_* / os.environ added.
  • No change-detector tests. Version asserts use == SCHEMA_VERSION, not a literal.
  • Docs accurate. session-storage.md FTS section, trigger bodies, migration table (rows 16/18/20), and the WAL-watchdog + config subsections all match the code.
  • tests/test_hermes_state.py → 345 passed; the 6 new invariant tests pass; ruff clean on the three changed modules.

The _fts_trigram_enabled config read runs on every SessionDB() construction but fails open on any error and is a plain config read (no cache/prompt impact) — fine.

Fix the P2 (offload the hourly call + bump the guard allowance for the startup site) and this is good to merge.

Local Codex/Claude-persona reviewer pass — ran the repo's own guard suite and hand-built E2E probes against live HEAD 22c97f7f8. I do not merge; leaving the verdict for a maintainer.

- Move the hourly WAL watchdog checkpoint off the gateway event loop via
  asyncio.to_thread (a pinned reader can make TRUNCATE block). This also
  drops the on-loop sync escape; bump _ALLOWED_SYNC_DB_ESCAPES to 4 for the
  legitimate off-loop startup watchdog and document all four sites.
- Roll back the v20 FTS migration when it can't complete so already-dropped
  inline FTS tables aren't committed with the DB stuck at v19 (data loss).
- _wal_size_bytes: drop redundant exists() check, rely on stat()/OSError.
- _pids_holding_db: os.path.abspath the target and guard f.path is None.
- WAL checkpoint: catch sqlite3.Error, not just OperationalError.
- test: patch _CHECKPOINT_EVERY_N_WRITES via monkeypatch on the class.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review posted in the Claude comment: #110 (comment)

1 blocking, 1 minor. P2 (confirmed, matches Codex): SessionDB._read_fts_trigram_config() resolves sessions.fts_trigram from the process HERMES_HOME, but tui_gateway/server.py:1687 and :1813 open other profiles' DBs without binding their home — so once fts_trigram is flipped false in any profile (this PR's live plan), opening a different profile's v20 DB drops that profile's trigram table + triggers. Scope the read to the DB owning self.db_path, or bind the home override at those two sites like :1353 does. The earlier loop-blocking hourly-watchdog P2 is resolved (asyncio.to_thread + guard bumped to 4), and Gemini's four suggestions are already in HEAD.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a3e59755c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
exiao added 2 commits July 13, 2026 23:07
The trigram gate drives destructive DDL (_drop_trigram_schema). A
cross-profile open (e.g. the TUI resuming another profile's session via
SessionDB(db_path=other_home/state.db) without overriding HERMES_HOME)
read the launch profile's sessions.fts_trigram, so a launch profile with
fts_trigram: false could silently drop a different profile's trigram
index. Read the setting from the profile that owns self.db_path: use the
fast cached loader for the same-profile hot path, else read the target
profile's config.yaml directly. Fails open (keeps trigram) on any error.

Adds a cross-profile regression test; updates the two gate-patching
tests to the instance-method signature.
The connection runs with isolation_level=None (autocommit), so the v20
DROP statements committed immediately and the previous rollback() was a
no-op — a failed external-content rebuild left the DB at v19 with the FTS
tables gone (the exact data-loss this fallback must prevent).

Wrap the destructive drop+recreate in an explicit BEGIN and route the
recreate DDL through a no-commit executor (_exec_ddl_no_commit splits on
sqlite3.complete_statement so executescript's implicit COMMIT can't defeat
the rollback). Commit before the VACUUM/TRUNCATE-checkpoint (both need no
open transaction). On failure the rollback now restores the pre-migration
schema and leaves the DB at v19; the next open retries.

Adds test_failed_v20_migration_rolls_back_and_stays_v19 (fails without the
fix — table gone, asserts it survives). Extends _NoTrigramCursor to also
simulate the missing tokenizer on execute() since the recreate now runs
via execute inside the transaction.
@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Babysit summary — merge-ready (pending stale review dismissal)

Head 30a9bcc09. All CI green (8/8 Python test slices, lints, e2e, security scans); 0 unresolved review threads.

Fixes pushed this pass:

  • CI-red guard test (test_sync_db_escape_confined_to_off_loop_sites): the hourly WAL watchdog was a sync _db. call on the gateway event loop — now await asyncio.to_thread(...); startup watchdog stays off-loop (construction), guard bumped 3→4 with justification. (5a3e59755)
  • Gemini's 4 suggestions: v20-migration rollback on incomplete FTS, _wal_size_bytes stat/OSError, _pids_holding_db abspath + f.path None-guard, except sqlite3.Error. (5a3e59755)
  • P2 (Codex + claude): trigram gate read the wrong profile_read_fts_trigram_config is now instance-scoped to self.db_path's profile (reads the target profile's config.yaml on a cross-profile open), so a launch profile's fts_trigram: false can't drive _drop_trigram_schema on another profile's DB. Regression test added. (f27038720)
  • P2 (Codex): v20 rollback was a no-op — the conn is autocommit (isolation_level=None), so the DROPs committed and rollback() did nothing. Now the drop+recreate runs inside an explicit BEGIN with a no-commit DDL executor (executescript would COMMIT), commit precedes the VACUUM. Regression test test_failed_v20_migration_rolls_back_and_stays_v19 (fails without the fix). (30a9bcc09)

On the claude-review red check: it is a Claude Code action infra failure (is_error:true) at the review-posting step — it failed identically across a rerun. Its actual analysis on this head was "0 blocking, 1 minor / LGTM" and it intended to --approve. The reviewDecision: CHANGES_REQUESTED is pinned to claude's earlier review on the superseded head 5a3e759 (the trigram P2, now fixed); the approving re-review couldn't post due to the infra error. Dismissing that stale review clears it.

The 1 remaining "minor" (non-blocking, deferred): flipping fts_trigram: false on an already-v20 DB drops the trigram table in the open path but doesn't VACUUM there (space reclaimed on the next routine vacuum). Not bundled — a naive VACUUM-on-every-open would regress the hot open path; both reviewers agree cutover is fine.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30a9bcc093

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
When sessions.fts_trigram is flipped false on an already-v20 DB, the open
path dropped messages_fts_trigram but only moved its pages to the freelist
and committed — the on-disk file never shrank, so the knob's advertised
multi-GB recovery never happened (the v20 migration path already VACUUMs
for the same reason).

_drop_trigram_schema now returns whether a real table was dropped; the
open path checkpoint→VACUUM→checkpoints only when there is space to
reclaim (never on every steady-state open with trigram disabled).

Regression: test_trigram_gate_flip_off_vacuums_and_reclaims_disk (fails
without the VACUUM — file doesn't shrink; passes now, freelist empty).

Addresses Codex/claude P2 on hermes_state.py:1962.
@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Merge-ready at head 94fbcc000.

  • Last blocking finding fixed (Codex + claude P2, hermes_state.py:1962): flipping sessions.fts_trigram=false on an already-v20 DB now runs checkpoint→VACUUM→checkpoint — but only when a real trigram table was actually dropped, so the ~5 GB is returned to the OS instead of sitting on the freelist, and there's no spurious VACUUM on steady-state opens. Regression test_trigram_gate_flip_off_vacuums_and_reclaims_disk fails without the VACUUM (file doesn't shrink) and passes now.
  • All required checks pass (green): 8/8 test slices, ruff, security scans, check-author, uv.lock, docs.
  • 0 unresolved review threads; every Gemini + Codex + claude finding is fixed and resolved.

Note: mergeStateStatus shows UNSTABLE and reviewDecision still reads CHANGES_REQUESTED only because the non-required claude-review job keeps erroring with is_error:true (a Claude-bot execution/infra failure — empty ANTHROPIC_API_KEY in that run), which survived a fresh re-trigger and is not a code finding. The two substantive blockers from claude's CHANGES_REQUESTED review (trigram-gate profile scope in f270387, VACUUM in 94fbcc0) are both fixed on the live head; the sticky verdict just can't clear until the bot re-reviews successfully.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94fbcc0003

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_state.py Outdated
Comment on lines +1964 to +1967
_expected_triggers = (
len(_FTS_TRIGGERS) if self._fts_trigram_enabled else 3
)
triggers_need_repair = self._fts_trigger_count(cursor) < _expected_triggers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild when base FTS triggers are missing

When sessions.fts_trigram is false, this check still counts the trigram triggers that are about to be dropped. On a DB where any base messages_fts_* trigger was missing while the trigram triggers still existed, the count can be at least 3, so triggers_need_repair stays false; _ensure_fts_schema recreates the missing base trigger, but the base FTS index is never rebuilt, leaving messages written during the missing-trigger window unsearchable once the trigram table is dropped. Count the base triggers separately for the trigram-disabled path, or rebuild whenever any base trigger was absent.

Useful? React with 👍 / 👎.

The trigram-disabled open path counted the full six-trigger set against
a hardcoded expected count of 3. Three surviving trigram triggers from a
prior trigram-enabled run could mask a dropped base trigger (2 base + 3
trigram = 5 >= 3), so triggers_need_repair stayed False and the base FTS
was never rebuilt. Messages written during the missing-base-trigger
window then stayed permanently unsearchable once the trigram table was
dropped.

Split _FTS_TRIGGERS into _BASE_FTS_TRIGGERS + _TRIGRAM_FTS_TRIGGERS and
give _fts_trigger_count an explicit names arg. The open path now counts
base triggers alone when trigram is off, all six when on.

Regression test fails on the old logic (gap message unsearchable, 0==1)
and passes now.

Addresses Codex P2 (hermes_state.py:1967) on PR #110.
@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Fixed the Codex P2 (hermes_state.py:1967) in c066d9f.

Root cause confirmed: the trigram-disabled open path counted the full six-trigger _FTS_TRIGGERS set against a hardcoded expectation of 3. On a DB that had trigram enabled previously (3 trigram triggers + table on disk) and then lost a base trigger during a no-FTS5 runtime window, the count was 2 base + 3 trigram = 5 ≥ 3, so triggers_need_repair stayed False. _ensure_fts_schema recreated the missing base trigger via CREATE TRIGGER IF NOT EXISTS, but _rebuild_fts_indexes never fired, leaving messages written during the gap window permanently unsearchable once the trigram table was dropped.

Fix: split _FTS_TRIGGERS into _BASE_FTS_TRIGGERS + _TRIGRAM_FTS_TRIGGERS; _fts_trigger_count(cursor, names=…) now takes an explicit trigger set. The open path counts base triggers in isolation when trigram is off, all six when on. The trigram-enabled path was already correct.

Verified: new regression test test_base_fts_rebuilds_when_base_trigger_missing_gate_off reproduces the exact scenario (seed trigram-on, drop one base trigger, write during the gap, reopen trigram-off). It fails on the pre-fix logic (AssertionError: 0 == 1, gap message unsearchable) and passes with the fix. All 79 FTS/trigram/trigger/WAL/migration tests green; ruff clean.

@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Review: PR #110 — fix(state): WAL watchdog + v20 view-backed FTS + trigram config gate

Reviewed at live HEAD c066d9f2 in an isolated pull/110/head worktree off the exiao remote. This is a real, reproduced review: I exercised every high-risk path against the actual hermes_state.py module on a temp HERMES_HOME, ran the changed-area suites, and ran ruff. All green.

Verdict: APPROVE. No blocking issues. The change is well-engineered and the risky paths are correct under test.

What I verified (fail-hard reproductions, not reasoning)

  • External-content FTS integrity (the classic footgun): DELETE and UPDATE triggers feed the exact original concatenated text to the FTS5 'delete' command. Deleted/updated rows leave a clean index (integrity-check passes; fox→0 after delete, alpha→0 / beta→1 after update). tool_name + tool_calls stay searchable via the view. snippet() still works on the read path.
  • v19→v20 migration: simulated an inline v19 DB, reopened → migrates to v20, messages_fts becomes content=messages_search_v external-content, view created, prior data still searchable. Idempotent on re-open.
  • Migration rollback is genuinely transactional: forced _ensure_fts_schema to fail mid-rebuild → the explicit BEGIN + no-commit DDL executor rolls back, DB stays at v19 with the intact inline tables, data still searchable. This is the scariest path and it's correct — the _exec_ddl_no_commit splitter handles trigger BEGIN…END; bodies (verified directly: 3 triggers + table created).
  • Trigram config gate off: drops the trigram table + its 3 triggers, base search intact, CJK query transparently falls back to LIKE, new writes after the drop are still searchable (base triggers untouched — the "count base triggers in isolation" fix in c066d9f is what protects this), base FTS integrity clean.
  • WAL watchdog: truncates an unpinned WAL (0.8MB→0.0MB), correctly detects a pinned reader via the RESTART busy flag (does NOT truncate, logs the WARNING with PIDs), skips read-only connections, no-ops below threshold.
  • Cross-profile config read: a cross-profile open reads the target profile's sessions.fts_trigram, not the launch profile's; fails open (True) on missing/malformed config. This correctly prevents dropping a different profile's trigram index.
  • Suites: tests/test_hermes_state.py 349✓, tests/gateway/test_async_session_db.py 14✓, plus test_session_search / test_state_db_malformed_repair / test_lazy_session_regressions 85✓. ruff check clean on all three changed source files.

Non-blocking notes (P3)

  • P3 — bool(sess.get("fts_trigram", True)) string quirk. _read_fts_trigram_config (hermes_state.py:1118, :1131) coerces with bool(). A hand-edited fts_trigram: 'false' (a YAML string, not a bool) evaluates truthy → gate stays ON. The only writer is the DEFAULT_CONFIG default, which emits a real YAML bool, so this is a latent robustness quirk, not a live defect. If you want to harden it, compare against a parsed bool or use a str-aware coercion. Not required for merge.
  • P3 — pinned-reader WAL path not unit-tested. test_wal_watchdog_shrinks_unpinned_wal covers the unpinned truncate and the below-threshold no-op, but not the busy/pinned branch (the WARNING + no-truncate). I reproduced that branch manually and it works; a test would lock the behavior. Optional.
  • P3 (observation, no action) — Strategy-0 rebuild recovery depends on the view. run_full_maintenance Strategy 0 does 'rebuild', which for external-content now reads messages_search_v; if a corruption dropped the view but kept the FTS table, Strategy 0 would fail — but it's wrapped in try/except and falls through to Strategy 1/2, and next-open re-asserts the view. Defense-in-depth intact.

Rubric

  • correctness: pass (every risky path reproduced correct)
  • scope-fidelity: pass (WAL watchdog + v20 view-backed FTS + trigram gate + reclaim VACUUM — all in-scope, no drive-by)
  • test-coverage: pass (new tests hit migration/rollback/gate/watchdog; only the pinned-WAL branch is uncovered, verified manually)
  • plan-compliance: MATCH

0 P0, 0 P1, 0 P2, 3 P3 — No blocking issues. Top note: bool() coercion on fts_trigram treats a string 'false' as True, but the sole writer emits a real YAML bool so it's a latent robustness quirk, not a defect.

— Local Codex/Claude-persona review (review bots budget-exhausted; ran the repo's AGENTS.md rubric locally and reproduced every finding against live HEAD c066d9f). Not a formal approval gate — Eric merges.

@exiao

exiao commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Local independent review — PR #110 (fix/state-db-hardening)

Local Codex/Claude-persona review ran the repo's rubric against live HEAD c066d9f2e in an isolated worktree off origin/fix/state-db-hardening, and reproduced the key claim with a fail-before/pass-after mutation. Review-only: not a merge or approval.

Verdict: APPROVE (comment). 0 P0, 0 P1, 0 P2, 2 P3.

The one outstanding blocker is stale, not live

The claude CHANGES_REQUESTED review raised a P2: _read_fts_trigram_config() resolving sessions.fts_trigram from the launch HERMES_HOME, so tui_gateway/server.py:1687/:1813 opening another profile's DB (without a home override) would drop that profile's trigram table once the gate is flipped false live.

That review was posted against commit 22c97f7f8. The next commit, f27038720 ("scope fts_trigram gate to the DB's own profile"), fixed exactly this: _read_fts_trigram_config() now keys on self.db_path.parent, and on a cross-profile open (target_home != get_hermes_home()) reads the target profile's config.yaml directly instead of the launch profile's. Sites :1687/:1813 therefore read the correct profile's gate with no home override needed.

I verified this is genuinely fixed and genuinely guarded:

  • test_trigram_gate_scoped_to_target_profile (launch=OFF, target=ON) passes on HEAD.
  • Faithful fail-before mutation: pointing the cross-profile branch at get_hermes_home()/config.yaml instead of target_home/config.yaml makes the test FAIL (assert False is True). Restored, passes again. So the scoping is real and the test catches its regression.

Verification (all run locally against HEAD c066d9f)

  • scripts/run_tests.sh tests/test_hermes_state.py349 passed, 0 failed (15.3s).
  • scripts/run_tests.sh tests/gateway/test_async_session_db.py → 14 passed (sync-escape guard bumped 3→4 with a justifying comment; correct: startup prune+vacuum, startup watchdog, two run_sync reads — hourly watchdog goes through asyncio.to_thread, not counted).
  • scripts/run_tests.sh tests/tools/test_session_search.py tests/hermes_cli/test_web_server_session_search.py → all passed.
  • CI: every Python tests slice (1–8) + e2e pass; claude-review "fail" is the stale request-changes above, not a test failure.

Correctness spot-checks (confirmed)

  • External-content wiring: view messages_search_v(id, content) = content||' '||tool_name||' '||tool_calls; the same concat is byte-identical across the view, INSERT trigger, and the 'delete'-command payload in DELETE/UPDATE triggers — so 'rebuild' and incremental maintenance index identical text.
  • _exec_ddl_no_commit splitter: confirmed sqlite3.complete_statement correctly separates the virtual-table CREATE (commas inside fts5(...)) from the trigger BEGIN...END; body — 2 clean statements. The migration runs inside an explicit BEGIN, so a recreate failure rolls the DROPs back (guarded by test_failed_v20_migration_rolls_back_and_stays_v19).
  • Search read-path under third-party content: snippet(messages_fts, 0, ...) + JOIN messages m ON m.id = messages_fts.rowid still emits >>>/<<< markers and returns m.content; proven by the migration/search test.
  • Trigger-count-in-isolation fix (c066d9f2e): counting base triggers alone on a gate-off open prevents leftover trigram triggers from masking a missing base trigger; guarded by test_base_fts_rebuilds_when_base_trigger_missing_gate_off.

Non-blocking (P3)

  1. hermes_state.py _read_fts_trigram_configbool(sess.get("fts_trigram", True)) coerces a hand-written string fts_trigram: "false" to True (any non-empty string is truthy). The sole config-writer emits a real yaml bool, and this fails open (keeps the index), so it's a latent robustness nit, not a defect.
  2. Trigram-disable VACUUM in the open path (already noted by the claude approving comment): flipping fts_trigram: false on an already-v20 DB drops the table but the reclaim VACUUM only runs when _drop_trigram_schema reports a real drop — which it does on the first gate-off open (test_trigram_gate_flip_off_vacuums_and_reclaims_disk proves the file shrinks and freelist is empty). No action needed; noting for completeness.

Rubric

  • correctness: pass — reproduced RED/GREEN on the scoping fix; migration/rollback/search/watchdog all exercised.
  • scope-fidelity: pass — 6 files, all on-plan (gateway wiring, 3 config keys, hermes_state core, 2 test files, 1 doc). No drive-by refactors.
  • test-coverage: pass — invariant-based (content= set, version bump, freelist empty, search parity), not change-detectors; new paths (migration, rollback, gate on/off, cross-profile, watchdog) each have a test.
  • plan-compliance: MATCH — all 4 planned items done; real-9.3GB-DB E2E + live gateway restart correctly deferred to the QA card per plan §Verification gates.

Recommend the claude review be dismissed/refreshed since its P2 was fixed in f27038720; no code changes needed from this pass.

Local rubric review; review bots' verdicts predate the fix commits.

@exiao

exiao commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: dafa781eea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant