#75017: Telegram polling conflict retry used drop_pending_updates=False,
starting a new getUpdates session that immediately got 409'd by the
previous still-expiring session — creating the very conflict it was
trying to recover from. Switch to drop_pending_updates=True so Telegram
terminates stale sessions. Also add a recovery-generation guard so the
first transient getUpdates success after a retry doesn't reset the
conflict counter back to 0 (defense-in-depth from PR #75096).
#75153: The WAL-reset warning always said 'hermes update can repair'
even for git/pip/system Python installs where it can't. Now uses
detect_install_method() + recommended_update_command_for_method() to
give a context-appropriate hint (hermes update for git, docker pull for
docker, nix message for nix, generic install hint as fallback).
purge_stale_tool_call_markers ran a permanent, irreversible UPDATE with
no backup — inconsistent with repair_state_db_schema's backup-by-default
convention for destructive state.db operations elsewhere in this file.
Take a full snapshot via VACUUM INTO (safe against a live connection,
unlike the raw-copy _backup_db_file used for malformed-schema repair)
before the write, timestamped beside state.db. Skipped when dry_run or
when there's nothing to change. Add --no-backup to `hermes sessions
clean-markers`, mirroring `sessions repair`.
Verified end-to-end: the CLI run against a real temp state.db produces
the backup file before printing the cleared-row count.
The load-on-read repair (_strip_stale_tool_call_markers) fixes affected
sessions in memory on every resume, but never touches the DB — long-lived
sessions re-scan and re-repair the same rows on every load, and the
contaminated bytes stay in state.db (and any backup/cache snapshot of it)
indefinitely.
Add SessionDB.purge_stale_tool_call_markers(dry_run=False): a one-time,
idempotent UPDATE that permanently blanks the content column on affected
rows. Only content is touched — tool_calls and every other column are
left untouched, so provider tool_call/tool_result pairing survives.
dry_run reads through the no-lock read path and never writes.
Wire it up as `hermes sessions clean-markers [--dry-run]`, mirroring the
existing optimize/repair subcommands. Verified end-to-end against a real
temp state.db: dry-run reports the row without writing, the real run
clears it and preserves tool_calls, and a second run is a no-op.
The conversation_loop fix (previous commit) stops new "[memory]"-style
bare tool-call markers from being cached/persisted, but sessions written
before that fix can still carry rows where a bare marker was saved as
the assistant's "final response".
Add a load-on-read repair pass in hermes_state.py, mirroring the existing
_strip_background_review_harness defense-in-depth: on session restore,
any assistant row whose content is only a bracketed marker (e.g.
"[memory]", "[skill_manage]") AND that carries tool_calls has its content
blanked before the history re-enters the model's context. The tool call
and its result are left untouched so provider tool_call/tool_result
pairing stays intact. Sessions with no affected rows pass through the
normal path unchanged.
get_messages_as_conversation, get_resume_conversations, and
get_ancestor_display_prefix still took self._lock — the same global
choke point the read-path split (WAL per-thread read-only connections)
was meant to remove from every recall/browse read. These three are the
hottest reads in the file: every session resume across the gateway,
CLI, and ACP adapter goes through one of them, so a resume racing a
burst of concurrent-session writer flushes still convoys behind them
exactly like the fixed paths used to.
_session_lineage_root_to_tip (the lineage walk shared by all three,
plus get_conversation_root) had its own independent self._lock use and
needed the same conversion — without it the outer functions still
blocked on the very first line.
Verified empirically: a reader thread calling all three functions
while another thread holds self._lock blocked for the writer's full
hold duration before the fix, and returned immediately after (SQLite
3.50.4 in this dev venv falls back to journal_mode=DELETE per the
WAL-reset-bug guard, so the requires_wal-marked regression test is
exercised via a local WAL-forced script instead; it still runs and
passes on any runtime where WAL is actually active).
Simplify-pass folds on the #23254 salvage:
- REUSE (HIGH): append_messages_batch now delegates row serialization to
the pre-existing _insert_message_rows helper (already shared by
replace_messages / archive_and_compact / portability import) instead
of adding a third serialization path (_prepare_message_row +
_MESSAGE_INSERT_SQL are gone). One row-writer for every multi-row
path; the row-ID return was consumed by no production caller, so the
batch returns the inserted count.
- QUALITY (HIGH): the compression-lock + compression-closed admission
guards are extracted into _check_transcript_write_guards, shared by
append_message and append_messages_batch (previously duplicated 23
lines that had already needed targeted fixes, #74478). The role-gated
reasoning filtering is no longer duplicated in run_agent.py — it
lives at its one site inside _insert_message_rows.
- EFFICIENCY (MEDIUM, measured): unbounded seed copies hold one BEGIN
IMMEDIATE for seconds (10k rows ~= 2.4s; FTS triggers dominate) and
monopolize the in-process write lock. append_messages_batch grows a
chunk_rows param; all seed/copy call sites use chunk_rows=500. Same
recovery semantics as the old per-row loops, bounded lock holds.
- REUSE (MEDIUM): the two remaining per-row branch-copy loops found by
the pass (gateway/slash_commands.py /branch, hermes_cli
cli_commands_mixin.py branch) are converted to chunked batches too
(AsyncSessionDB's generic to_thread forwarder covers the async site).
Turn-flush benchmark unchanged after the refactor: 2.43 -> 0.87 ms
median per 5-message flush (64% faster).
Re-derivation of #23254 (@devsart95) on today's flush loop. The turn
flush in _flush_messages_to_session_db wrote one BEGIN IMMEDIATE
transaction per message row; a typical agent turn (user + assistant +
tool results) paid 3-8 transactions -- and, off WAL (the default on
macOS while the WAL-reset guard is active), 3-8 fsyncs -- per turn.
Adds SessionDB.append_messages_batch: same row shape as append_message
(shared _prepare_message_row serializer + _MESSAGE_INSERT_SQL column
list, so the two writers cannot drift), same compression-lock and
compression-closed guards, one aggregated session-counter UPDATE, one
transaction for the whole batch. Row serialization stays outside the
write lock.
The flush loop now collects the turn's new rows and writes them in one
call. All-or-nothing pairs exactly with the persisted-marker stamping:
on failure no rows landed and no markers were stamped, so the next
flush re-writes the whole tail (same recovery contract as before,
minus the partial-prefix case that could double-count).
Measured (same harness, 5-message turn, journal_mode=DELETE,
synchronous=FULL): 2.32ms -> 0.83ms median per turn flush (64% faster,
5 fsyncs -> 1). On WAL the win is smaller but the atomicity fix holds.
Retarget #73639 onto the SessionDB mixin split (hermes_state_common /
hermes_state_schema). Fresh installs create UPDATE OF content/tool_*
triggers; existing broad AFTER UPDATE triggers are inspected and
replaced under schema init without an FTS rebuild (WHEN clauses already
guarded content correctness; OF skips non-content status writes that
saturated disk I/O on large state.db).
Tests: tests/test_fts_update_of_narrowing.py (4)
Addresses review from @teknium1 on PR #71755:
- Extended apply_database_pragmas() to handle cache_size, mmap_size,
and temp_store from config.yaml (alongside existing wal_autocheckpoint
and journal_size_limit). No hardcoded defaults — all values are
opt-in via config.yaml, avoiding policy conflicts with other PRs.
- Applied to ALL connection types: writer (_connect_and_init),
read_only cross-profile attach, and WAL per-thread readers
(_get_read_conn). Previously PRAGMAs only ran on the writer path.
- Removed inline PRAGMAs from _connect_and_init — single source of
truth in apply_database_pragmas().
- Documented config keys with examples in function docstring.
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>
Review fold-ins on top of #56768 (@Skywind5487):
- session_count_ge ran its query without self._lock, unlike every
sibling counter on SessionDB (session_count, session_count_by_source).
- Document the deliberate semantics change: session_count() defaults to
archived = 0, which is both the expensive part (full index scan,
measured 543us vs 4us on 20k sessions) and wrong for the only caller
(has_any_sessions asks 'has this install ever had sessions' -- an
archived session is still a created one).
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
A session's YOLO bypass lived only in the in-memory
tools.approval._session_yolo set (or the process-frozen --yolo env
var), so resuming a session in a fresh process silently reverted the
user's /yolo ON — dangerous commands started prompting again.
Persist a yolo_mode flag in the session row's model_config JSON and
restore it on every CLI resume path:
- SessionDB.set_session_yolo() merges the flag into model_config
(same lineage-preserving merge as update_session_runtime_lock);
SessionDB.session_yolo_enabled() reads it back, false on any parse
failure.
- /yolo toggle persists ON and OFF through the new helper; the
compression/branch session-id rotation carries the flag onto the
continuation row.
- --yolo launches record the flag at session creation (agent_init),
and a /yolo toggled before the lazily-created row exists is carried
into the creation-time model_config (_ensure_db_session).
- HermesCLI._restore_session_yolo() re-enables the bypass on startup
--resume/-c, the deferred init path, and mid-chat /resume, with a
visible '⚡ YOLO mode restored from session' notice. No-op under a
frozen process-wide --yolo and never enables on absent/garbage flags.
- tests/agent/test_session_activity.py asserts against
ACTIVITY_DESCRIPTION_MAX instead of the literal 120.
- The session-stall WARNING log line names its config knob
(agent.session_stall_timeout) so operators can find the setting.
- hermes_state.py: collapse the triple blank line near line 191.
- hermes_cli/status.py no longer imports the private
hermes_cli.main._relative_time: the helper moved to a public home
(hermes_cli.timefmt.relative_time); main._relative_time stays as a
thin back-compat wrapper (sessions_cmd and external patchers keep
working).
Activity heartbeat writes and turn-end label clears ran synchronously on
the response-critical path with the full ~20s routine write-patience
budget — under contention an otherwise-finished reply could stall for
seconds just to update observation labels, mimicking the very stall the
watchdog detects.
touch_session_activity and clear_session_activity_labels now use a
dedicated 0.5s patience budget (they are observation-only; the next
heartbeat window retries naturally), and a no-op label clear (labels
already empty) skips the write transaction entirely.
Regressions: with another connection holding BEGIN IMMEDIATE, both writes
give up well under the routine budget; the no-op clear performs zero
write transactions.
PR #76354 review, 'activity writes are synchronous on critical paths' /
merge gate 9.
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.
Makes interrupt-protected context compression cancellable by an explicit
user or lifecycle stop, without weakening protection against ordinary
incoming messages, voice interjections, or active-turn redirects.
Separates explicit hard cancellation from ordinary interrupt/redirect
state with a dedicated threading.Event; introduces
AuxiliaryExplicitCancellation as an attempt-local frozen-cause signal;
isolates the synchronous provider callback in a bounded daemon worker
during protected compression; atomically linearizes Codex timeout
cleanup against explicit cancellation; propagates hard cancellation
through child agents and explicit stop surfaces; serializes hard-cancel
admission against compression commit admission with
CompressionCommitFence; aborts before session rotation or late DB commit,
restores in-place transcript mutations and compressor state, and releases
the heartbeat and compression lease.
Based on #74449 by @suparious. Resolved merge conflicts in
agent/context_compressor.py (feasibility check + try/except) and
tui_gateway/methods_session.py.
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.
The rebase onto main's extracted _sleep_before_write_retry() method left
three call sites pointing at the dropped local helper; rewire them.
Also adds contributors/emails mappings (Dannou, trippyogi, spfcraze).
Under dual gateway/agent WAL contention (FTS5 trigram sync holding the
write lock on large appends) the SQLite engine can raise a transient
'no more rows available' error. The exception CLASS varies with the
build — some surface it as InterfaceError, a SIBLING of DatabaseError —
so it escaped both existing retry branches in _execute_write on attempt
0 and killed the turn as session_persistence_failed even though the
identical write succeeds standalone.
Port of #74934 onto the deadline-patience rewrite (8da8a7887d): the
PR's attempt-counted constants (60 retries / 300ms jitter / 2.0s engine
timeout) predate that rewrite and are superseded by the patience
budget, so they are intentionally NOT carried over. Instead the check
is message-scoped and rides the existing deadline/patience loop:
- extract the jittered-sleep-within-deadline logic into a shared
_sleep_before_retry helper (behavior-preserving for locked/busy)
- retry 'no more rows available' from OperationalError, DatabaseError
(checked BEFORE the FTS-corruption rebuild path so it is not
misrouted), and a message-scoped sqlite3.Error catch-all
- any other error in any class propagates untouched on attempt 0
Tests: transient InterfaceError retried to success; unrelated
InterfaceError propagates immediately; DatabaseError variant retried;
exhausted patience surfaces the original error.
append_message refused immediately when another writer held the session's
compression lock. The conversation loop turns that into
session_persistence_failed and tells the operator to check disk space and
permissions, when the store is healthy and merely busy. The two append
attempts in the reported incident were 3ms apart, so there was no wait at
all before the turn was destroyed (#75083).
The wait is deliberately short (_COMPRESSION_BUSY_WAIT_S, 5s) rather than
the 60s transcript write patience. The lease is a correctness boundary, not
just a busy signal: test_compression_lease_blocks_non_owner_but_allows_owner_flush
pins that a late stale turn must not land in a session being compressed.
Reusing the full write patience made that append succeed once the lease
aged out, which is exactly what the guard exists to prevent. A short budget
saves the common case, where compression publishes in a couple of seconds,
and still refuses a writer locked out by a long-running or wedged
compression.
CompressionSessionBusyError could not simply be retried either: it covers
two conditions with opposite handling. A compressor discovering its own
lease is gone is permanent, and retrying that would spend the whole budget
before failing anyway. Split the transient case into a
SessionCompressionInProgressError subclass, raised only by append_message,
and wait on just that. Existing except CompressionSessionBusyError handlers
catch both unchanged.
The retry jitter is extracted into _sleep_before_write_retry so the lock
path and the compression path share one implementation.
Disk-full / ENOSPC / SQLITE_FULL on first-message session persist used to be
swallowed as a debug log while prompt.submit still returned streaming, so the
send vanished with no error. Re-raise those failures, return a real RPC error,
and stamp session_persistence_failed turns with error so clients get a terminal
error frame.
The retag gate was global, so once one board reclaimed its legacy rows a
second board on the same state.db never got swept. Key the state_meta gate
on the workspaces root and skip reopening state.db on every spawn via an
in-process set. Align the dispatcher-spawn test with the worker's own
`kanban` source tag and cover the per-board gate.
CI caught ACP session restore seeing an unexpected _row_id in restored
history — get_messages_as_conversation feeds more than the desktop, and
changing the default shape broke the strictest consumer. Row ids are now
include_row_ids=True, requested only by the gateway's resume/display
projections; ACP restore, export, and inspection get the transcript in its
historical shape.
Reactions live in the existing messages.display_metadata JSON column (no new
table), with iOS Tapback semantics enforced DB-side: one reaction per author
per message, re-tap retracts, different emoji replaces. The desktop catches up
to the reaction contract five platform adapters already ship.
- SessionDB: set/get_message_reaction, latest_message_row_id (role + offset +
require_text so invisible tool-call-only rows are never targeted),
take_unseen_reactions (announce-exactly-once), get_message_role
- message.react RPC: accepts row_id or newest_role for live messages that
haven't learned their durable id yet
- react_to_message tool: desktop-gated (check_fn), defaults to the user's
latest visible message, messages_back for retroactive reactions
- Model context rides run_message only (beside the speech-interrupted note):
the persisted prompt stays clean, so no [The user reacted …] scaffolding in
transcripts, and no cached prefix ever changes
- Resume projection forwards row_id + reactions; _row_id is stripped from
outgoing API copies next to display_metadata
DEFAULT_DB_PATH in hermes_state.py is computed at import time, freezing
the developer's real ~/.hermes even when a test fixture (or runtime
profile switch) later redirects HERMES_HOME. Any default SessionDB() —
e.g. gateway SessionStore — then opened the real state.db.
Add _default_db_path(): resolves get_hermes_home() fresh at call time,
while a deliberately re-pointed DEFAULT_DB_PATH (the established
monkeypatch escape hatch) still wins via an import-time snapshot
comparison, preserving existing test behavior. SessionDB.__init__ and
session_search's requirement check now use the resolver; explicit
db_path arguments are untouched.
Reimplemented from PR #11875 by @JorkeyLiu (original diff predates the
hermes_state rewrite); regression test ported and modernized.
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
Salvages #55322 (ZFS 'disk i/o error' marker) without regressing the
Bug D transient-EIO protection from 5c49cd0ed0. 'disk i/o error' is
ambiguous: deterministic on ZFS/APFS-CoW SHM corruption (#55305,
#71498) but often a one-shot transient (page-cache pressure, lock
contention). A blind marker match re-introduced the mixed-journal-mode
corruption pattern; a blind re-raise wedged state.db on ZFS.
Disambiguate: retry the WAL pragma twice with a short backoff. A
transient EIO clears and WAL proceeds; a deterministic failure keeps
raising and falls through to the guarded DELETE fallback (still
refusing to downgrade a DB whose on-disk header reports WAL).
Tests: transient-EIO recovery, persistent-EIO fallback, and the
never-downgrade-WAL-on-disk guard.
The WAL→DELETE fallback on WAL-incompatible filesystems (NFS / SMB / FUSE /
the AgentFS NFS overlay) was logged at WARNING, treating a real loss of
concurrency — under the kanban dispatcher + workers a write blocks readers,
surfacing as SQLITE_BUSY — as if it were cosmetic. Escalate the deduplicated
fallback log to ERROR so the degradation is observable, not silent.
Add an opt-in require_wal=True to apply_wal_with_fallback that raises a typed
WalUnsupportedError (subclass of sqlite3.OperationalError, so existing DB-init
handlers still catch it) instead of degrading to DELETE, for callers that
mandate WAL concurrency. All four current callers keep the default
require_wal=False so NFS-homed installs keep working unchanged.
Tests: 4 new require_wal cases; WARNING→ERROR assertion updates in both
test_hermes_state_wal_fallback.py and test_kanban_db.py.
apply_wal_with_fallback() only detected WAL-incompatible filesystems via a
RAISED OperationalError matched against _WAL_INCOMPAT_MARKERS. But macOS NFS,
SMB/CIFS, and overlay filesystems (e.g. AgentFS's NFS-backed mount) refuse the
WAL switch WITHOUT raising: `PRAGMA journal_mode=WAL` returns the still-effective
mode ('delete') and no exception. The code then ran `return "wal"`
unconditionally, so it:
1. returned a false "wal" while the DB was actually in DELETE mode, and
2. never called _log_wal_fallback_once, so the operator got ZERO signal that
concurrency had silently degraded (reader-blocks-writer).
state.db and kanban.db share this path, so a session/kanban board DB on a
network or overlay filesystem ran in DELETE with no diagnostic.
Fix: read the row `PRAGMA journal_mode=WAL` returns and verify it is actually
'wal' instead of assuming success; on a silent no-op, emit the existing
fallback WARNING and return the true mode. The raise-based path is unchanged.
Reproduced on a real AgentFS NFS overlay (PRAGMA journal_mode=WAL returned
('delete',) with no OperationalError). Adds a regression test for the
silent-no-op shape; the 16 existing WAL-fallback tests are unchanged.
Use database.journal_mode as the sole non-secret operator setting, preserve the vulnerable-SQLite safety gate and existing WAL databases, validate explicit DELETE results, document the active config path, and cover real SQLite openers with behavioral tests.
Add HERMES_JOURNAL_MODE env / database.journal_mode config for
virtiofs/NFS/SMB where WAL is not crash-safe. Route 5 bypass openers
through apply_wal_with_fallback so a single setting covers every .db
(#68545).
A shared state.db is legitimately held for multi-second stretches by
sibling Hermes processes: VACUUM after auto-prune, the TRUNCATE WAL
checkpoint at close on a large WAL, offline recovery, or an older
still-running process whose FTS maintenance predates the bounded-merge
protocol (every `hermes update` leaves mixed-version processes sharing
the DB until the old ones exit).
The old retry budget was attempt-counted: 15 attempts x 20-150ms jitter
gives up after ~1.3s of waiting. Any hold longer than that surfaced as:
- append_message failing -> the conversation loop aborts the turn as
session_persistence_failed ('No reply: the turn was stopped because
session storage could not be written') on a perfectly healthy store;
- SessionDB() open failing -> the CLI disables persistence for the
entire run ('Failed to initialize SessionDB ... database is locked').
Both observed in production logs on 2026-07-29 (10.8 GB state.db, 9
concurrent hermes processes, three of them pre-dating the bounded-merge
fix pull).
Changes:
- _execute_write patience is now TIME-based with two budgets: routine
writes wait up to 20s; transcript-critical writes (append_message,
session-row creation — the ones whose failure aborts a user turn)
wait up to 60s. Jitter stays 20-150ms for the first 2s, then backs
off to 250ms-1s so a long hold isn't hammered with BEGIN IMMEDIATE.
- Exhausted patience raises an error that names the actual cause
(another process held the write lock; the database is healthy)
instead of a bare 'database is locked' that reads like disk damage —
and the turn-abort explainer inherits that clarity.
- SessionDB open now applies the same jittered patience to the
locked/busy class around connect+schema-init, instead of failing the
whole open (and disabling persistence for the run) on the first 1s
timeout. Non-lock errors, including the malformed-schema repair
class, propagate immediately as before.
Fixes#74478
`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>
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.
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).