fix(state): retire orphaned FTS v2 during storage optimize - #2
Conversation
`hermes sessions optimize-storage` aborted on this host with:
error in trigger messages_fts_v2_delete after rename:
vtable constructor failed: messages_fts_v2
`messages_fts_v2` (`tokenize='cjk_unicode61'`) was superseded by
`messages_fts_cjk` in f13f845, but a DB that stopped mid-transition keeps
the v2 vtable, its three `AFTER INSERT/UPDATE/DELETE ON messages` triggers,
and its `fts_v2_*` state_meta markers, while no code references it. Two
defects turn that residue into a hard stop:
1. `_demote_legacy_fts_to_trash` collected shadow tables with a
`messages_fts_%` LIKE glob, which also matches every sibling generation's
shadows (`messages_fts_v2_data`, ...). Renaming them re-parses the v2
triggers; without the loadable `cjk_unicode61` tokenizer on the connection
the vtable cannot be constructed and the whole migration rolls back.
2. Nothing retired the orphan, so the failure recurred on every re-run and the
DB stayed on the pre-v23 layout — 20.0 GB for 4 GB of messages here, with
~14 GB of duplicate index copies.
The orphan also breaks plain `PRAGMA integrity_check` (`no such tokenizer`),
so the DB cannot be verified with stock sqlite3 while it exists.
Fix, both in the single shared path so sync/async callers inherit it:
- Derive the shadow set from the vtable names actually being demoted, using
FTS5's fixed shadow suffixes, and match exactly. A sibling generation can no
longer be swept in by name resemblance.
- Retire `messages_fts_v2` explicitly (named allowlist, not a prefix scan, so
a future generation cannot be dropped for merely looking similar) before the
demote. Trigger and vtable definitions are removed via `writable_schema`, so
the tokenizer is not needed; shadows are handed to the existing chunked
teardown. Preflight is fail-closed: messages present, live base index
covering every row, integrity check clean, rowcount parity — otherwise the
orphan is left untouched.
Verified on the reporting host: 20041.3 MB -> 8164.3 MB (11.9 GB reclaimed),
`integrity_check` ok afterwards, no `messages_fts_v2*` or trash residue, and
EN/CJK/trigram searches all serve correctly with the gateway running
throughout. Tests fail on the unpatched code and pass with it.
Origin: local-author
Upstream-PR: none
Patch-State: local-only
💡 Codex Reviewhttps://github.com/Soju06/hermes-agent/blob/32f562255969e62f4001be25145814a9883f4806/.github/workflows/upload_to_pypi.yml#L1 This deletion removes the only repository workflow that builds and uploads distributions on CalVer tag pushes; a repo-wide search of AGENTS.md reference: AGENTS.md:L170-L174 hermes-agent/hermes_state_search.py Lines 337 to 341 in 32f5622 When the external base index has one missing message row and one stale row, its internal FTS integrity check succeeds and AGENTS.md reference: AGENTS.md:L80-L83 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Addresses both review findings on the remote-gateway download PR: 1. Unbounded buffering (finding #1). fetchBuffer / fetchBufferViaOauthSession accumulated the entire response (then copied it again via Buffer.concat) before saveGatewayFile even opened the save dialog, so a large gateway file could exhaust the native process. Both auth paths now stream: once response headers arrive the connect timeout is cleared, the filename is derived, the save dialog is shown, and the body is piped to the chosen destination with backpressure. A read/write error tears down the stream and unlinks the partial file. The byte-moving, data-URL decoding, and filename/path helpers are extracted into gateway-file-download.ts so they're unit-testable without Electron. 2. No fallback for older gateways (finding #2). saveGatewayFile required the new /api/fs/download route. Desktop and the remote gateway update independently, so a gateway predating this PR 404s. Added a 404-only compatibility fallback to the existing capped /api/fs/read-data-url route (bounded, so it only serves smaller files — enough to keep older backends working). Tests: gateway-file-download.test.ts covers streaming, backpressure, error-cleanup (unlink on write/response error), data-URL decoding, filename derivation (incl. traversal reduction), and 404 detection; gateway-file-download-transport.test.ts asserts both transports stream (no whole-body Buffer.concat) and that the 404 fallback is wired. Both registered in the desktop platform test list. Server-side /api/fs/download tests (streaming + sensitive-file reject) already pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent bugs let a deleted profile reappear / leave orphaned resources on next launch: 1. hermes_cli/profiles.py's backend-process scanner required argv[0] to resolve to an executable literally named "hermes". Electron's pool-backend spawn resolves the hermes console-script shim's path and execs it via the interpreter directly (python3 /path/to/hermes ...), so argv[0] reports as "python3" and the scanner never matched the running backend -- delete removed the profile's files but left its live backend process running (still bound to a port via uvicorn), which accumulates across repeated delete/recreate cycles. 2. The desktop sidebar's ProfileRail only refreshed its cached profile list once, on mount, so a delete/create/rename from another surface (another window, or the CLI) left a stale ghost entry until something unrelated triggered a refetch. Note: a delete via this window's own Manage-Profiles view already refreshes the shared $profiles atom ProfileRail subscribes to (confirmed by reading refreshProfiles() and handleConfirmDelete()) -- this fix only covers the cross-window/cross- process staleness gap, not a duplicate of the already-merged NousResearch#57329's Manage-Profiles rail-refresh work. Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named console-script shim via argv[1]. Fix 2: refresh the profile list on window focus/visibilitychange, matching the existing pattern used elsewhere in the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx, use-gateway-boot.ts all use the same focus+visibilitychange pattern). ## Related work already on main PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279 (deleted profile respawns) via a different, non-overlapping mechanism: routing profile-delete through the primary backend instead of spawning a fresh pool backend, plus a separate recreation guard in ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a deleted profile's directory raise FileNotFoundError instead of silently recreating it. This PR is NOT a duplicate of that fix. Verified: even with both of those merged, a backend process that survives because of gap #1 above still holds a bound port via uvicorn -- it just can no longer resurrect the profile directory. That's real resource-hygiene, not a symptom already covered. Gap #2 touches a different file/component (ProfileRail / profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the Manage-Profiles view's own $profiles.ts / index.tsx) and covers a distinct staleness path (cross-window/cross-process, not same-window delete-then-refresh). Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing + regression coverage for the argv[0] python-interpreter detection case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Optional owner-agnostic persona layer: when HERMES_HOME/personas/modes.yaml exists, an audience mode is selected per session as a pure deterministic function of session-constant inputs (platform, chat_type, chat_id, chat_name, user_id) — first-match rules with string-or-list exact matching (chat_name case-insensitive, missing key = wildcard, AND semantics), then a non-owner guard (non-empty user_id not listed in owners[platform] forces guards.non_owner_mode; missing owners entry fails safe; empty user_id skips), then default_mode with unknown-mode fallback. The selected mode's persona markdown is injected as stable-tier slot #2, immediately after the SOUL.md/DEFAULT_AGENT_IDENTITY identity block, after passing the same threat scan and truncation cap as SOUL.md. With no modes.yaml, prompt assembly is byte-identical to before (strict no-op); every failure path degrades to the same no-op at DEBUG. Cache correctness: while active, the volatile tail gains an "AudienceMode: <mode>" line next to Model:/Provider:, and _stored_prompt_matches_runtime recomputes the expected mode via a cheap mode-only resolver (no persona scan) — both-absent passes so pre-deploy stored prompts stay valid, mismatch or one-sided presence rebuilds exactly once per session. The label is distinct from Model:/Provider: so the fallback model-swap regex rewrite cannot touch it. Loader/resolver are re-exported through run_agent (load_soul_md pattern) for test patchability. Plumbing parity with SOUL.md: personas/ added to the profile clone copy set and the default-export include root, and the hermes_config_mod threat pattern now also covers .hermes/personas/ paths. The artifact_register section of modes.yaml is intentionally not consumed by core (tone-gate plugin contract). Origin: local-author Upstream-PR: none Patch-State: local-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
hermes sessions optimize-storageaborts on this host:messages_fts_v2(tokenize='cjk_unicode61') was superseded bymessages_fts_cjkinf13f845116. A DB that stopped mid-transition keeps the v2 vtable, its threeAFTER INSERT/UPDATE/DELETE ON messagestriggers, and itsfts_v2_*state_metamarkers — while no code references it any more (grep -c messages_fts_v2 *.py= 0 on HEAD).Two defects turn that residue into a hard stop:
_demote_legacy_fts_to_trashgathered shadow tables with amessages_fts_%LIKE glob, which also matches every sibling generation's shadows (messages_fts_v2_data,messages_fts_v2_idx, ...). Renaming them re-parses the v2 triggers; without the loadablecjk_unicode61tokenizer on that connection the vtable cannot be constructed, and the whole migration rolls back.Worth calling out separately: while the orphan exists, plain
PRAGMA integrity_checkfails withno such tokenizer: cjk_unicode61, so the database cannot be verified with stocksqlite3at all. The size is a symptom; the stuck generation is the disease.Fix
Both changes live in the single shared path, so sync and async callers inherit them.
_data,_idx,_content,_docsize,_config), and matched withIN (...). A sibling generation can no longer be swept in by name resemblance.messages_fts_v2is retired via a named allowlist, not a prefix scan — deliberately, so that a future generation cannot be dropped for merely looking similar. Trigger and vtable definitions are removed throughwritable_schema, which is why this works without the tokenizer present; the shadow tables are handed to the existing chunked teardown rather than deleted inline.messagesis non-empty, a live base index exists and covers every row, the integrity check is clean, and rowcount parity holds. Otherwise the orphan is left untouched and the run behaves as before.Verification
Tests fail on unpatched code and pass with the patch — verified by reverting
hermes_state_search.pyalone while keeping the new tests:test_demote_only_stages_exact_legacy_generation,test_optimize_retires_poisoned_v2_but_preserves_live_cjk,test_orphaned_v2_alone_is_offered_and_retired3c27eb623)4 files, 27 tests passed, 0 failedincl.test_state_db_malformed_repair.py,test_fts_runtime_rebuild.py,test_fts_update_of_narrowing.pyLive run on the affected 20 GB database, gateway serving traffic throughout:
PRAGMA integrity_check(tokenizer loaded):okmessages_fts_v2*orfts_v22_trash*residue;state_metareduced tofts_storage_version=1messages1,268,987 =messages_fts1,268,987, parity holding while rows kept arriving mid-runmessages_fts_cjkpresent and serving: EN 87,848 hits / CJK 2-char 811 / trigram 3,100, all sub-10 msNotes
hermes_state_search.pyis untouched by every other patch in the stack, so this applies directly on base and does not interact with the assembly fixes.cjk_unicode61generation, not host-specific. Filed aslocal-onlyfor now since it needs the v2 generation to exist to be observable.