Commit Graph

501 Commits

Author SHA1 Message Date
joaomarcos 0e9b57d310 fix(state): cap read connections per PROCESS and yield when the fd table is tight
Follow-up to the per-file budget in the previous commit, which closed the
scaling axis it measured and left three others open.

* A per-file ceiling still lets the cost grow with the PROFILE count: a
  multiplexed gateway serves N profiles from one process and each has its own
  state.db, so `_READ_POOL_MAX` bounded each file while the process total went
  unbounded — the per-instance bug one level out. `_READ_POOL_PROCESS_MAX`
  (three files' worth) now bounds the process, and a miss reclaims an idle
  connection from ANY path before degrading: a profile quiet for an hour must
  not hold descriptors the profile being served right now needs.

* Hermes's SQLite descriptors are only ever a share of the fd table. In #98573
  the ~20 state.db handles were not the whole 256 — they were the share that
  pushed httpx sockets and terminal subprocess pipes over, and the EMFILE
  surfaced in tools/terminal_tool.py rather than here. New read connections are
  now refused when the process is within `_FD_HEADROOM_RESERVE` of its soft
  RLIMIT_NOFILE, measured from /proc/self/fd or /dev/fd and cached briefly. The
  guard fails OPEN where it cannot measure (Windows has neither the fd
  directory nor RLIMIT_NOFILE, and a CRT limit in the thousands) and CLOSED on
  evidence — including a probe that could not get a descriptor of its own.
  `_read_open_denied_fd_headroom` makes it diagnosable from a running process.

* Writer connections cannot be rationed the way read connections can: a
  SessionDB without one cannot write. Their only real bound is not opening
  redundant handles, so a process that accumulates more than
  `_HANDLES_PER_PATH_WARN` handles on one file now says so once, and the next
  duplicate is visible before it is an incident instead of inferred from an
  lsof after one.

`_READ_POOL_MAX` itself is deliberately unchanged at 8. Retuning that constant
is #98585's subject; with a process ceiling above it and the headroom guard
in front of it, the value is no longer the binding constraint.

Refs #98573

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:23:48 -07:00
joaomarcos e8c41568ca fix(state): bound state.db read connections per FILE, and stop opening two gateway handles
Issue #98573 reports a long-lived gateway holding ~20 `state.db` descriptors
that never shrink, walking into the 256 soft RLIMIT_NOFILE a launchd/systemd
service manager hands the process. The cause named there — a per-thread
`threading.local()` read connection — is already gone (87aedbe7b6 pooled the
read connections, 0472c31aa1 added the peak permit). Measured on main: one
SessionDB with 40 concurrent reader threads peaks at 9 live connections, not 40.

The symptom survives one layer up. `_READ_POOL_MAX` was enforced by a
BoundedSemaphore owned by each SessionDB, which bounds the wrong noun: the
descriptors are spent on a FILE, so every additional handle on one state.db got
its own allowance and peak scaled as `instances x (1 + _READ_POOL_MAX)`.

Two changes, both needed:

* The permits move to a per-path `_PathReadBudget`, shared by every SessionDB
  in the process that points at that file. A permit miss first reclaims an IDLE
  pooled connection from a peer handle before degrading to the writer lock —
  without that, whichever handle warmed up first would pin the whole budget and
  permanently demote every later one (a cron job's transient handle, a second
  profile's store) to the locked writer connection.

* `GatewayRunner` borrows `SessionStore`'s handle instead of opening its own.
  Both caches resolve the same `_default_db_path()`, so the process was holding
  two writer connections and two read pools against one file for no reason, and
  doubling again per profile on a multiplexed gateway. The store owns the
  connection and sweeps it at shutdown; the runner's cache now holds only the
  async wrapper and its sweep skips borrowed handles.

Measured peak live connections against one file, 40 reader threads, by handle
count 1/2/4/8:

  before: 9 / 18 / 36 / 51   (51 not 72 only because the sample window ended
                              before every pool filled)
  after:  9 / 10 / 12 / 16   (read connections capped at 8 in total; the
                              remainder is one writer per handle, and the
                              gateway's per-profile pair is now one)

Fixes #98573

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:23:48 -07:00
teknium1 9db48053e8 fix(state): self-heal SessionDB writes after close() races an in-flight worker
Subagent/cron sessions died mid-run with "Session DB append_message
failed: 'NoneType' object has no attribute 'execute'": a teardown owner
(cron run_job finally, delegate timeout owner, agent close()) called
SessionDB.close() — nulling _conn — while a still-unwinding worker had
one more transcript flush to land. The flush then hit None.execute, the
turn force-ended as session_persistence_failed, and the session tail was
silently dropped while cron delivery reported last_status: ok.

Fix at the shared persistence boundary: _execute_write and the _read_ctx
writer-lock fallback detect the closed handle under self._lock and
reopen a connection to the same database file with a loud WARNING naming
the race. Read-only handles never reopen — they raise an explicit
'was closed' error. A failed reopen raises an OperationalError naming
the teardown race so classify_persistence_error gets a real cause.

Closes #94736
2026-08-31 10:51:19 -07:00
Teknium f57802c7ad fix(state): bound the rewind-tail walk at the watermark and rewind concurrent-tail originals too 2026-08-31 10:02:22 -07:00
BrunoBza 9d9d9194d4 fix(state): archive carried-forward compaction tail as rewind rows (#86366)
archive_and_compact() soft-archives every active row with compacted=1 and
then re-inserts compacted_messages as fresh live rows. When the
compressor's protected tail rides inside that list verbatim - which is
the normal batch-compaction shape ([summary] + tail) - the tail's
ORIGINALS end up stored twice per compaction: (active=0, compacted=1)
next to their live clones. search_messages() recalls both flags without
DISTINCT, so every carried-forward message came back once per compaction
(measured up to 4 identical hits) and was mislabeled to users and the
agent as archived "summarized away" content.

Add an optional tail_count parameter: the last tail_count archived rows
are superseded byte-identical duplicates, stamped rewind-style
(active=0, compacted=0, hidden from recall) instead of compacted=1.

Callers:
- batch in-place compaction counts the compressor-tagged tail dicts
  (_COMPACTION_TAIL_MARKER set by compress() on every carried-forward
  message);
- micro-compaction splices [prefix, marker, suffix] - everything except
  the single marker row is carried forward, so tail_count=len-1;
- proactive tool-result pruning rewrites content in place (not verbatim),
  keeping the historical archive-everything behavior.

Fixes #86366
2026-08-31 10:02:22 -07:00
Teknium c0667439ec fix(compression): rotation heals stale automatic ended_at stamps instead of wedging (#88197)
TUI server shutdown stamps ended_at/end_reason='tui_shutdown' on sessions
whose agent keeps running; every rotation then aborts at
publish_compression_child's liveness check forever (the #88197 wedge; the
amplification half was fixed by #88411).

Class fix: is_automatic_end_reason() in hermes_state_common owns the
"accidental infrastructure cleanup vs deliberate boundary" taxonomy.
publish_compression_child clears automatic stamps in its own transaction
and proceeds (parent re-closes with its TRUE boundary,
end_reason='compression'); the #88411 pre-flush guard no longer aborts on
stamps the publish can heal. Deliberate boundaries (compression,
session_reset, explicit close) still fail closed at both sites.

TEST REPIN (deliberate contract change):
test_ended_parent_aborts_before_the_prepublish_flush pinned
"tui_shutdown stamp => rotation aborts and parent must not grow" — the
abort it required IS the #88197 wedge. Repinned as two tests:
- test_automatic_stamp_no_longer_wedges_rotation: automatic stamp =>
  rotation COMMITS (no abort loop, so no growth-by-abort is possible);
- test_deliberately_ended_parent_aborts_before_the_prepublish_flush:
  session_reset (deliberate boundary) => still aborts BEFORE the #47202
  flush, preserving #88411's no-growth contract where an abort remains
  correct.
The class invariant "no aborted rotation grows the parent" holds
everywhere: automatic stamps no longer produce aborts, deliberate
boundaries still abort pre-flush.
2026-08-31 09:58:11 -07:00
Teknium cdd3b84c13 fix(state): reject special files in zeroed probe; real schema-bytes decode fixture
Follow-ups on the salvage: regular-file guard before the zeroed byte-probe (a FIFO at the state.db path would block startup forever — #98017 review P2), plus an on-main-reproducing UnicodeDecodeError fixture for #98924 (raw bytes in sqlite_master, not messages.content, are what reach pysqlite error-message decode).
2026-08-31 09:56:43 -07:00
chelsealong 47b0e6561e fix(state): honor _ensure_fts_cjk_schema's never-raises contract
The tokenizer-not-loaded branch ran its sqlite_master presence check
and self-heal statements (drop stale cjk triggers) with no guard, so a
transient sqlite3.OperationalError there (e.g. a locked database)
escaped the method despite its documented "Never raises" contract.

That exception then hit _migrate_broad_fts_update_triggers's
quarantine-then-reraise handler, aborting _init_schema and the whole
SessionDB open. Wrap the presence-check/self-heal block in the same
kind of OperationalError guard the tokenizer-loaded branch already
has, degrading to "no cjk index" instead of propagating.
2026-08-31 09:56:43 -07:00
loulanyue 69245e65bd fix(state): serialize startup across zero-byte check, quarantine, connect, and schema commit (#97568)
- Guard against concurrent-opener race where newly created 0-byte state.db was falsely quarantined before first schema write
- Wrap startup in quarantine_cross_process_lock when database is uninitialized or zeroed
- Guard is_zeroed_sqlite_file and is_zeroed_state_db against active live connections in current process
- Add concurrent-opener and live-connection regression tests
2026-08-31 09:56:43 -07:00
Teknium 22dcbdece6 fix(state): contain post-commit FTS maintenance errors + lock-audit the writer conn (salvage #90734)
Salvaged from PR #90734 by @Kyzcreig onto current main:
- hermes_state_search.py: post-commit FTS incremental merge failures
  (including the bare SystemError CPython's sqlite3 layer raises under
  cross-thread errmsg scrambling) are contained and logged instead of
  escaping and making the caller replay an ambiguous, possibly-durable
  write (exactly-once refinement by @yuzilongleif-collab).
- tests/state/test_writer_conn_thread_safety.py: live reader/writer race
  hammer + AST sweep freezing the no-unlocked-writer-conn invariant.

On top: the sweep now also flags self._conn PASSED to helpers, which
caught one more live site on main — get_session_delete_targets handed
the shared writer connection to _collect_delegate_child_ids inside a
_read_ctx block, executing on it without self._lock. Routed to the
borrowed read connection.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
2026-08-31 09:56:04 -07:00
milnerrad 94869e5a0d fix(state): isolate background reads from writer connection 2026-08-31 09:56:04 -07:00
loulanyue a071fc80da fix(state): quarantine 0-byte truncated state.db and record store provenance (#97568) 2026-08-31 20:44:26 +05:30
Teknium 5cc3da6827 fix(state): SessionDB derives its own store's profile for unstamped session rows
The tui_gateway/run_agent writers now stamp profile_name explicitly, but
every OTHER creation path that passes no profile_name (cli.py /new,
hermes_cli/main.py --create-if-missing, foreign-session import, ACP
adapter, gateway branch/title paths, the #82616 peer self-heal INSERT,
and compression children of legacy NULL parents) still minted
profile_name = NULL rows. Rows minted NULL after the one-shot #94724
legacy-owner backfill ran stayed NULL forever: profile-keyed consumers
(desktop sidebar scope matching, @session:<profile>/<id> deep links, the
fail-closed owner ladder) treat NULL as unowned, so the sessions vanished
from the sidebar with their transcripts intact (#99222).

Fix the class at the choke point instead of chasing call sites: every
profile-tree state.db belongs to exactly one profile, so
SessionDB._insert_session_row (and the peer self-heal INSERT and the
compression-child publisher) derive the store's own profile from db_path
when the caller names none — <root>/state.db -> 'default',
<root>/profiles/<name>/state.db -> <name>. The same single-match contract
backfill_null_session_profiles and the web listing's row_profile stamp
already rely on. Explicit profile_name arguments always win; stores
outside the profile tree (tests, ad-hoc copies) keep NULL — never guess.

E2E-verified with real imports against a temp HERMES_HOME:
before (origin/main) a bare create_session on the default store persisted
NULL; after, 'default' / '<profile>' land in state.db for the default
store, a named-profile store, the peer self-heal insert, and a
compression child of a NULL parent, while explicit args and
outside-tree stores are unchanged.

Refs #99222
2026-08-31 05:56:18 -07:00
kshitijk4poor 3aee290899 refactor(compression): name the split-failure cooldown; drop duplicate tests
Review folds from the formal gate battery:
- _SPLIT_FAILURE_COOLDOWN_SECONDS = 60 replaces the bare literal, with a
  comment pinning WHY it is the timeout ladder's first rung (transient
  lease/DB condition) rather than the 600s summary-provider cooldown.
- publish_compression_child docstring now states the compression_lock_holder
  condition on the refresh guard.
- Dropped 2 of 3 extracted unit tests as duplicates of existing coverage in
  test_compression_rotation_state.py / test_context_compressor.py; kept the
  force-bypass test (only site pinning that behavior for split failures) and
  the E2E test (now asserting the named constant).
2026-08-31 14:09:42 +05:30
VVV 087cc49a26 fix(compression): refresh lease in-transaction before publish; arm cooldown on split failure
Two narrow repairs for #97948 symptom B (large-session rotation aborts with
'Compression lease lost before publication' / session_split_failed, then the
next turn re-runs the identical doomed compression):

1. publish_compression_child gains require_lease_refresh: the lease is
   extended inside the same transaction as the expiry check (same conn, no
   TOCTOU), giving a worker whose refresher thread died from transient DB
   failures one final chance to keep its completed work.

2. A failed compression split now records a 60s failure cooldown, so the
   next turn cannot immediately re-trigger the same compression.

Salvaged from #98137 (author: vsd2807). The timeout-reconciliation half of
that PR is NOT carried: it has a blocking review (runtime sid vs persisted
session_key, one-shot check cannot observe a 6-minute commit, no identity
projection) and needs a redesign.
2026-08-31 14:09:42 +05:30
HexLab98 027b339e79 fix(compression): persist stall-interrupted backoff on pre-commit cancel (#96775)
An explicit /stop after the summary stream has already gone idle restored the original transcript but left no durable cooldown, so the next automatic turn re-entered the same stalled strategy. Record a stall-specific failure on that path only, merge with any longer live deadline, and keep an ordinary early /stop cooldown-neutral.
2026-08-30 19:46:21 -07:00
Teknium e05c91ac71 fix(cli): slow /handoff transfers no longer misreported as "gateway not running"
Live-reproduced on main: /handoff poll-waited a flat 60s for a TERMINAL
state, but the gateway's dispatch is a full synthetic agent turn (whole
transcript replay + delivery) that routinely exceeds 60s on long sessions.
The CLI then printed "Timed out waiting for the gateway. Is `hermes
gateway` running?" (false diagnosis), called fail_handoff() on the RUNNING
row (stomping the gateway's claim), and promised "Your CLI session is
intact" after switch_session had already re-pointed the session. The
watcher later overwrote failed -> completed: split-brain.

- hermes_state.fail_handoff gains only_states CAS; waiters can only fail
  rows still pending. Owner (gateway watcher) keeps the unconditional form.
- CLI wait loop is two-phase: 60s for the CLAIM (pending) — a timeout
  there really does mean no gateway — then up to 15 min for the claimed
  dispatch with 30s heartbeats; a running row is never failed by the CLI.
- Desktop handoff.fail RPC now CAS-fails pending rows only; a running row
  returns {failed: false, state: running} instead of stomping the claim.

Repro (real _handoff_watcher, real state.db, CLI as separate process,
75s dispatch): before — CLI timeout @60s + false message + row stomped;
after — pending->running@5s->completed@80s, clean CLI exit.
2026-08-29 18:08:55 -07:00
StanleyStetson 24e54b55f5 fix(desktop): preserve streamed assistant text and unify atomic persistence (#95514)
- Preserve streamed assistant text in Desktop UI when message.complete delivers empty text.

- Prevent destructive hydration in Desktop useMessageStream over rendered text on empty completion.

- Recover stream buffer in finalize_turn when final_response is empty on healthy turns.

- Unify in-place blank assistant repair, watermark clone resolution, non-blank concurrent winner adoption, and batch row appends into a single atomic guarded SessionDB transaction.

- Synchronize canonical committed content to live in-memory messages dicts and preserve all-or-nothing rollback semantics on persistence failure.
2026-08-29 11:33:05 +05:30
kshitijk4poor 112baae665 fix(state): close gate blind spots — alias + variable-SQL readers (simplify findings)
Reviewer findings from the formal /simplify-code pass, all verified:

1. Scanner blind spots (HIGH): five more locked pure readers were
   invisible to the v1 gate — get_compression_fallback_streak and
   get_compression_ineffective_count hid behind `conn = self._conn`
   aliasing; list_gateway_sessions, find_session_by_origin and
   search_sessions hid behind SQL held in variables/f-strings (the
   scanner required unknown == 0 to flag). All five converted to
   _read_ctx(); the scanner now (a) tracks self._conn aliases and
   (b) flags lock blocks with NO proven write instead of silently
   skipping unprovable SQL. Sabotage self-check extended to pin all
   three detection classes (literal, alias, variable-SQL) plus a
   mixed variable-SQL writer that must NOT fire.

2. get_meta reverted to the writer lock: its inline comment (present
   on main) documents a real read-your-writes dependency —
   fts_rebuild_step reads rebuild progress that a pooled WAL reader
   cannot see mid-transaction. The blanket conversion had overridden
   a documented design decision; it is now the single justified
   _ALLOWED_LOCKED_READERS entry, replacing the dead
   _enter_fts_fail_open entry (whose lock block counts 3 writes and
   never needed allowlisting).

Strengthened scanner on pre-conversion main: 43 violations
(39 pure-read + 4 no-proven-write). This branch: zero.

Suites: gate 2/2; tests/test_hermes_state.py + tests/state/ 331
passed (same 3 pre-existing FTS-rebuild reds as clean main); 433
passed across the 12 consumer suites of the five newly-converted
methods (compression anti-thrash, session search, status, scheduler).
2026-08-29 10:38:53 +05:30
kshitijk4poor 0534f1033b perf(state): route 39 pure-read SessionDB methods off the writer lock + gate
Pattern-C architectural fix (write-lock contention), completing what
#90734 started: that PR fixed the four UNLOCKED readers racing the
writer connection; this one fixes the 39 LOCKED pure readers convoying
every concurrent turn's persistence behind the global writer lock, and
adds the gate that stops the class from re-entering.

The gateway shares ONE SessionDB across every agent. A read-only query
under `with self._lock:` blocks all concurrent writers for its
duration; under WAL, _read_ctx() serves the same read from a pooled
read-only connection with no lock at all (non-WAL falls back to the
locked writer byte-for-byte, so DELETE-journal installs are unchanged).

Converted (SELECT-only bodies, mechanical `with self._lock:` →
`with self._read_ctx() as conn:`): gateway routing loaders, session/
message counters, titles, compression tip/lineage/cooldown readers,
telegram topic bindings, prune candidate scans, meta readers, resume
resolution — 39 methods, verified per-method that every statement is a
SELECT and every conn use stays inside the with-block. Read-modify-
write methods and checkpoint/maintenance PRAGMAs stay on the writer
lock (their read is ordered against their own write).

Gate: tests/state/test_no_locked_readers_gate.py — an AST scanner over
SessionDB that fails CI when a pure-read method body takes the writer
lock, with a sabotage self-check proving the scanner fires. On
pre-conversion main it reports 38 violations; on this branch, zero.

Measured (2 writer threads + 3 reader threads, 3s, WAL):
  before: 78.5k reads, p99 2.49ms, max 160.7ms  (readers convoy)
  after: 200.4k reads, p99 0.53ms, max  25.5ms  (2.6x throughput,
         4.7x better p99, 6x better tail; writes unchanged)
Honest caveat: on runtimes where WAL is refused (the currently-bundled
SQLite 3.46 trips the WAL-reset-vulnerability gate → journal=DELETE),
_read_ctx() falls back to the identical locked-writer path and this
change is behavior-neutral by construction; the win applies to WAL
installs (legacy WAL DBs, fixed runtimes, wal-configured operators).

Suites: tests/test_hermes_state.py 243 passed; combined state sweep
540 passed — the only 3 reds are the pre-existing
test_fts_runtime_rebuild failures, verified failing on clean
origin/main before this change.
2026-08-29 10:26:21 +05:30
Teknium 7d1c9aeab7 fix: log swallowed reclaim failures + pin ContextVar dispatch invariant (review follow-up for #91217) 2026-08-28 11:45:19 -07:00
69k4xmdfm2-blip fc5fdb8c2a fix(gateway): handoff is broken on multi-profile installs (wrong DB, wrong key, wrong bot)
`/handoff <platform>` never completes on a multiplexed gateway, and when it
does complete it can deliver through the wrong profile's bot. Three distinct
faults, all the same family: multi-profile code paths that assume a single
store / a single adapter map.

1. The watcher polls only the ROOT store.
   `_handoff_watcher` resolves `self._session_db` with no profile scope, which
   always yields the root `state.db`. But `/handoff` run under
   `hermes -p <profile>` writes `handoff_state='pending'` into THAT profile's
   store. Nothing ever reads it, so the CLI times out after 60s while the
   gateway is alive and connected. The watcher now iterates
   `[(None, None), *secondary_profiles]` and polls each inside
   `_profile_runtime_scope`.

2. The destination session key is built without the profile namespace.
   `_process_handoff` called `build_session_key()` with no `profile=`,
   producing `agent:main:...` while that profile's own adapter routes organic
   inbound messages on `agent:<profile>:...`. The handoff bound a key nobody
   reads.

3. Delivery uses the PRIMARY profile's adapter and config.
   `self.adapters` holds only the default profile's adapters (secondaries live
   in `self._profile_adapters[name]`) and `self.config` only the default's home
   channel. A secondary profile's handoff was therefore sent by the wrong bot,
   to the wrong chat, while persisting the right session key and reporting
   `handoff_state='completed'` — a false positive that looks correct in the
   database and is wrong on the wire.

Two robustness fixes in the same path:

4. Head-of-line blocking between profiles. `_process_handoff` runs a full agent
   turn plus delivery; awaiting it inline meant one slow handoff stopped the
   watcher from even polling the other profiles. With the CLI's 60s deadline, a
   valid handoff could time out purely because another profile's was ahead of
   it. Dispatch is now fire-and-forget, with an in-flight guard so a row is
   never claimed twice, and a bounded drain on shutdown.

5. Rows stranded in `running`. Only the watcher sets `running`, for the span of
   one in-process dispatch, so a row still in that state at startup belongs to
   a gateway that died mid-dispatch. It can never reach a terminal state, and
   `request_handoff` refuses new requests unless the state is
   NULL/completed/failed — that session could never hand off again, silently.
   `reclaim_stale_running_handoffs()` now fails those rows once per store at
   watcher startup. Failing (not re-queueing) is deliberate: the dead gateway
   may already have switched the session key and dispatched, so a blind retry
   risks double delivery.

Behaviour on single-profile installs is unchanged: the scope list degrades to
the unscoped root poll, `_resolve_profile_for_key` returns None when
multiplexing is off (byte-identical keys), and config/adapters fall back to
`self.config`/`self.adapters`.

Tests: 13 new across three files. Each was verified to FAIL against the
unpatched code (the fix was reverted and the suite re-run) so they are real
guards rather than decorative assertions. Verified end-to-end on a live
4-profile gateway: `handoff_state` goes failed -> completed, and a planted
stranded `running` row is reclaimed at startup with the reason recorded.
2026-08-28 11:45:19 -07:00
Teknium db63afb9d7 fix(state): journal-mode probe/restore go through _connect_repair_durable
The write-durability contract test (test_repair_path_has_no_bare_connects)
enforces that every repair-path connection carries the macOS write
barriers; the restore rewrites the file header, so it belongs under the
same rule.
2026-08-27 13:48:51 -07:00
Teknium 4882184e95 fix(state): guest durability barriers also apply configured database.synchronous
apply_durability_barriers() is the guest-connection entry point for
secondary state.db users (async delegation ledger) that must not run
journal-mode setup. database.synchronous (#90892) rides on the
journal-mode/pragma paths guests now skip, so apply it here directly —
otherwise guest connections silently run at the compile-time default.

Follow-up to the #93012 salvage.
2026-08-27 13:48:51 -07:00
fangliquanflq 7e6eda7bbb fix(delegation): expose state durability barriers 2026-08-27 13:48:51 -07:00
liuhao1024 786e65bf4f fix(state): re-apply the configured journal mode after corruption repair
Corruption can drop the WAL bit from the database header, and every
repair strategy rebuilds or rewrites the file in place — so a repaired
store comes back in the default journal mode (delete). The WAL-reset
gate at open time never sees the flip because it happens inside the
repair path, not at open (the open-time flip #89393 warns about is a
different door), leaving the operator no signal that a WAL store moved
to DELETE (#89674).

After a successful repair, re-apply the canonical database.journal_mode
through _set_journal_mode_no_wait (concurrent openers abort the flip
instead of sneaking it between transactions) and log a WARNING naming
the old and restored modes. Best-effort: a refused restore is logged,
never raised — the repair itself already succeeded. Probing the damaged
file for a pre-repair mode is deliberately not trusted: the corruption
itself is what drops the WAL bit, so the configured setting is the only
reliable target.
2026-08-27 13:48:51 -07:00
Finn763 cae58be1f5 fix(state.db): cross-backend heartbeat gates orphan sweep
A live sibling serve sharing state.db is no longer treated as a dead process by the startup orphan sweep.

Covers the sweep half of #94895. The launchd Errno 48 KeepAlive loop is not addressed here.

Credit: @Finn763
2026-08-27 11:31:59 -05:00
Jack Lau ef29fc63d7 fix(state): make state.db synchronous configurable on every platform
`apply_database_pragmas()` reads five sizing pragmas from `database:` and no
durability one, and `_enforce_macos_synchronous_full()` returns early when
`sys.platform != "darwin"`. Between them, nothing in the process ever executes
`PRAGMA synchronous` against state.db on Linux or Windows.

The effective level there is therefore `SQLITE_DEFAULT_WAL_SYNCHRONOUS`, a
compile-time constant of whichever SQLite the interpreter links. Debian and
Ubuntu builds commonly ship it as NORMAL; the bundled build and a plain
source build use FULL. So the durability of state.db is decided by which
python3 the installer found, is invisible from config, and cannot be pinned.

#90837 is three weeks of corruption forensics conducted on Ubuntu under the
stated premise `synchronous=FULL`, with every other cause eliminated live.
That premise is not something the reporter could have verified from config,
because there was no config key to set and no log line to read back.

- `resolve_synchronous_level()` maps the spellings operators actually write
  (OFF/NORMAL/FULL/EXTRA, any case, or 0-3) to the PRAGMA integer, and
  returns None for anything else. Kept out of the sizing loop on purpose: an
  unrecognised `cache_size` harmlessly falls back to a default, an
  unrecognised durability level must not.
- `_apply_synchronous_pragma()` applies it, and on Darwin refuses to lower
  below FULL. `_enforce_macos_synchronous_full()` runs during
  `apply_wal_with_fallback()`, which is earlier than `apply_database_pragmas()`,
  so without an explicit floor a configured NORMAL would silently undo #64355
  by the accident of running last. Raising to EXTRA on macOS is allowed.
- Unset changes nothing, so no existing install moves.

Tests: 34, covering the parser, application, the unset path, the typo path,
a guardrail that #77630's five keys still apply, and the Darwin floor in both
directions. Removing the wiring fails 6; removing the floor alone fails 1.

Related to #90837
2026-08-27 07:52:26 -07:00
Jack Lau d5d42b96ef fix(state): warn when an existing database's journal_mode is flipped to WAL
apply_wal_with_fallback treats an on-disk WAL database as authoritative and
says so twice: it never live-downgrades one. The mirror case had no
protection at all. When the on-disk mode is DELETE and the configured mode
is wal, the function flips the database and logs nothing.

journal_mode is a property of the FILE, so that rewrites the header and
persists after the process exits. Setting the mode directly on the file is
something operators do; it was the documented mitigation for the SQLite
3.50.4 WAL-reset bug. A config key that makes the choice durable already
exists (database.journal_mode, #68545), but nothing named it at the moment
the PRAGMA was being undone.

#89293 reports the cost: after upgrading past the vulnerable SQLite,
is_sqlite_wal_reset_vulnerable() stopped short-circuiting into
_apply_delete_for_wal_reset_bug, the flip path went live, and 4 of 5
databases silently returned to WAL with no log line anywhere.

Add a deduped WARNING at both points where the switch succeeds, decided
before the pragma runs since both inputs are only readable while the file is
still in its original state. Log-only: the flip still happens, the return
value is unchanged, and the never-live-downgrade rule is untouched.

WARNING rather than ERROR is deliberate. The reverse direction is ERROR
because dropping to DELETE costs concurrency; this direction is normally the
desirable one (managed_uv treats a database stuck on DELETE as a bug worth
repairing on update). The problem was never the change, it was that the
change was invisible.

The page_count guard is the load-bearing half. A brand-new database also
reports journal_mode=delete and is also about to be switched to WAL, and
every opener applies WAL before creating schema, so without it the warning
would fire on the first run of every install.

Refs #89293
2026-08-27 07:52:26 -07:00
Paolo Antinori 9aeb582e38 fix(state): warn when configured journal_mode=delete is overridden by on-disk WAL
When database.journal_mode=delete is configured but the on-disk DB is already
WAL, apply_wal_with_fallback honors the never-live-downgrade rule and keeps WAL.
That is correct (a live downgrade under open connections causes mixed-mode
corruption), but the operator's configured mode silently has no effect, and on
a WAL-incompatible filesystem (virtiofs/NFS/SMB) the DB then corrupts on the
next crash/sleep exactly what they configured to prevent.

Two code paths return WAL in this situation; both now emit a once-per-process-
per-db_label ERROR telling the operator the config did not apply and they must
convert the DB header offline (stop connections, PRAGMA journal_mode=DELETE):

1. The WAL-reset-vulnerable path (_apply_delete_for_wal_reset_bug): previously
   warned only about the vulnerability with an "upgrade SQLite" remedy, which
   does not help when the real cause is the filesystem. Emitted after that
   warning so the actionable message is last.
2. The read-only probe path (non-vulnerable runtime): previously returned WAL
   with no signal at all.

The never-live-downgrade behavior is unchanged (existing test now also asserts
the warning). New tests cover both paths, the per-db_label dedup, and the
require_wal=True edge case.

Real-world impact: a Hermes deployment with state.db on a Podman virtiofs
bind-mount (or any NFS/SMB home) that upgrades across a version where WAL was
the default, then sets journal_mode=delete, sees no corruption protection
until the DB header is converted. This makes the gap visible. See #68545.
2026-08-27 07:52:26 -07:00
kshitijk4poor 4a6e523bdc refactor: trim verbose comment on _EMPTY_SESSION_WHERE
Deduplicate the explanation across the constant comment, count_empty_sessions
docstring, and delete_empty_sessions docstring. Keep the incident refs
(#70516/#80763/#82756/#95868) and the core WHY on the constant; cross-reference
from the methods.
2026-08-27 19:54:32 +05:30
joaomarcos 59993be6e9 docs(sessions): name the reachable ordinal-0 rewind path in the #95868 guards
Precision pass on the comments added by the previous commit: prompt.submit
reaches replace_messages(archive_dropped=True) with an empty prefix on a
confirmed ordinal-0 rewind, which is the production shape that lands a
populated session on message_count = 0. archive_and_compact normally
publishes at least a summary row, so it is pinned as defense in depth
rather than claimed as an equally reachable trigger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zTDHnWcBvQsJSX1fBLhuv
2026-08-27 19:54:32 +05:30
joaomarcos 0bfe715e4d fix(sessions): don't let the empty-session sweep delete an archived transcript
`count_empty_sessions` / `delete_empty_sessions` — the dashboard's
"Delete empty (N)" affordance — defined "empty" as `sessions.message_count
= 0`. That column is a denormalized counter over the LIVE (`active = 1`)
rows only, and two production transcript-rewrite paths reset it on purpose
while keeping every dropped turn on disk as `active = 0`:

  * `replace_messages(..., archive_dropped=True)` — the rewind / edit /
    regenerate mode added in #82756 so a taken-back turn stays recoverable.
  * `archive_and_compact` — in-place compaction, which archives the
    pre-compaction transcript under the same session id (#38763).

A chat rewound to its first turn, or compacted with an empty live set,
therefore reports `message_count = 0` while still holding its entire
history — and those soft-archived rows are the only copy. A gateway reload
is what makes the row eligible: it stamps `ended_at` on every detached
session (`end_reason='ws_orphan_reap'`), satisfying the sweep's
`ended_at IS NOT NULL` gate. The next sweep then hard-deleted the session
row AND `DELETE FROM messages`, destroying the transcript silently.

Every other emptiness test in `hermes_state` already defends the counter
with a real `EXISTS (SELECT 1 FROM messages ...)` probe
(`delete_session_if_empty`, `prune_empty_ghost_sessions`,
`list_never_active_keyed_sessions`, `find_recoverable_session`). This
sweep was the only destructive path that trusted the counter alone. It now
uses the same probe, via one `_EMPTY_SESSION_WHERE` selector shared by the
count and the delete so the button's N and the sweep it triggers can never
disagree again. The counter stays as a cheap prefilter; `EXISTS` is the
authority.

Genuinely message-less rows are still swept — the feature is unchanged for
the case it was built for.

Fixes #95868

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zTDHnWcBvQsJSX1fBLhuv
2026-08-27 19:54:32 +05:30
Teknium 01f7ce5b76 feat(sessions): one-shot single-match owner backfill for legacy NULL-profile rows (#94724)
POST /api/sessions/owner-backfill stamps a store's own serving-profile
identity onto its pre-#95407 'profile_name = NULL' session rows. Single
match by construction (each profile's state.db belongs to exactly one
profile), idempotent, one-shot-per-row, never overwrites a non-NULL
owner, and reports the stamped count for logging.

Refs #94724
2026-08-27 02:17:56 -07:00
Teknium ad7b7255ab fix(state): renaming a bot's canonical Bot Chat is refused — the title IS the identity (#92473)
Bot Mode resolves the forever-chat by exact-title lookup on
(profile, 'Bot Chat'); no session-id pointer exists. A user rename
therefore orphaned the whole conversation: resolution missed, the next
click minted an empty replacement, and UNIQUE(title) then blocked ever
renaming back. Refuse the rename at SessionDB._set_session_title — the
single write path every surface funnels through (gateway session.title,
/title, CLI rename, REST). Hidden discriminates the registry row, so a
normal visible session a user happens to call 'Bot Chat' stays freely
renameable; re-asserting the same canonical title stays a no-op.
2026-08-26 03:21:42 -07:00
Jan-Stefan Janetzky 1104ffe0b9 feat(memory): opt-in fail-closed pre-compress checkpoint contract (API v1)
Context compression is intentionally lossy. Deployments that archive
transcript evidence to an external durable store before compaction had no
way to guarantee the archive actually happened: MemoryManager.on_pre_compress
swallows provider failures by design, so a failed archive silently degraded
into data loss.

This adds an opt-in, provider-agnostic checkpoint contract:

- memory_provider: PRE_COMPRESS_CHECKPOINT_API_VERSION = 1; providers opt in
  by advertising pre_compress_checkpoint_api_version. Version 0 keeps the
  historical best-effort hook semantics.
- memory_manager: supports_pre_compress_checkpoint() capability probe;
  on_pre_compress(require_checkpoint=True) propagates checkpoint-provider
  failures and raises when no capable provider completed the checkpoint.
- conversation_compression: new compression.checkpoint_required config key
  (default false, documented in cli-config.yaml.example). When enabled,
  compaction fails closed with BLOCKED_MISSING_PREREQUISITE (the
  uncompressed transcript is preserved) unless a checkpoint-capable provider
  confirms the durable checkpoint. Providers receive normalized direct
  user/assistant evidence: tool rows, system messages, tool-call wrappers,
  and prior compaction summaries are filtered host-side into one stable
  contract. codex_app_server compaction is rejected under the gate because
  it exposes no truthful pre-compaction transcript boundary.
- hermes_state: persistent _compressed_summary column (declarative schema
  migration via _reconcile_columns) so summary provenance survives process
  restarts; only the resume model history carries the marker, keeping
  get_messages_as_conversation on its existing contract.
- gateway: the lossy hygiene/auto-compact paths load the memory provider
  (skip_memory=False) so a required checkpoint also guards those rewrites.

The gate arms only on an explicit boolean True (bare-MagicMock agents in
existing tests have truthy auto-attributes). Default behavior is unchanged:
checkpoint_required=false preserves best-effort semantics for all existing
providers. Contract tests, including a restart round-trip of the summary
marker, in tests/agent/test_pre_compress_checkpoint_contract.py.

Refs #93986
2026-08-25 03:55:55 -07:00
Teknium 1a95d0d58e Merge branch 'pr-81234' into salv/81234-retry-carrier 2026-08-24 03:15:07 -07:00
fangliquanflq fe24525605 fix(state): reap only proven database holders 2026-08-23 20:01:41 -07:00
fangliquanflq 8f3a82f96a fix(state): recover FTS after orphan holder deferrals 2026-08-23 20:01:41 -07:00
halaprix d3e4b50e68 fix(tui): sweep orphaned tui/desktop/subagent session rows at gateway startup
Close session rows left ended_at IS NULL when the in-process websocket
orphan timer dies with the process (#65194). Dual-clock staleness
(started_at AND newest message), desktop included, live in-memory
sessions excluded, scheduled once from both entry.main and the WS
sidecar so desktop/dashboard boots also run the sweep.
2026-08-23 18:58:40 -07:00
Andrex Ibiza, MBA 31a01f373b fix(state): make automatic repair non-destructive
Reproduce the schema-btree failure where the in-place writable_schema/VACUUM ladder can reduce a 3,048-page canonical state.db to 113 pages and still return repaired=False.

Move all mutating strategies behind a complete SQLite online-backup snapshot, retain one exclusive SQLite guard from staging through transactional promotion, preserve committed WAL frames and the live inode, fail closed on environmental hazards, and add adversarial regression coverage for failed-repair preservation, post-stage writer races, interrupted copies, stale scratch, disk admission, attempt-ledger semantics, and durability routing.

Fixes #93064
Supersedes the delivery mechanics of #87409 while preserving its implementation provenance.

Co-authored-by: cervantesh <11169707+cervantesh@users.noreply.github.com>
2026-08-23 18:25:12 -07:00
kshitij 4865194772 fix(bot-mode): review follow-ups for recoverable-archive resurrection
- Clear the accidental end stamp on resurrection (at the lineage tip):
  a surviving ws_orphan_reap/agent_close reason made a LATER deliberate
  archive auto-resurrect on the next lookup — the user could never retire
  the canonical chat. Test pins the resurrect -> deliberate-archive ->
  stays-archived cycle.
- Judge recoverability at the compression TIP: the registry row of a
  compressed lineage carries end_reason='compression', so tip-stamped
  accidents were unrecoverable through the registry row. Lineage test.
- Heal the third lookup: the api_server exact-title listing (hermes peer
  dm resolution) filtered archived rows out via list_sessions_rich and
  still failed for reap-archived canonical chats.
- Single source of truth for the recoverable set: tuple moved to
  hermes_state_common (mirroring _RESET_END_REASONS_SQL) and interpolated
  into all three recovery SQL sites — literals cannot drift.
- methods_session gate uses BOT_CHAT_TITLE (not a literal) and re-fetches
  by id after resurrection (title has no DB-level UNIQUE).
- Idempotence pinned: two consecutive profiles.list calls both resolve.
2026-08-24 03:27:11 +05:30
kshitijk4poor bef31fb06b fix(bot-mode): resurrect canonical Bot Chat archived by recoverable reasons on reopen (#92687) 2026-08-24 03:06:11 +05:30
Kshitij Kapoor 72ae855da5 refactor: consolidate _db_persisted literal + marker-insensitive no-op check (#92231 follow-up)
Review-pass follow-up on the load-time durability stamp:

- hermes_state.py: import the marker from agent.context_compressor instead
  of a third synced literal (hermes_state already imports agent.* at module
  level; only run_agent is circular). Old comment claimed otherwise.
- agent/turn_finalizer.py: replace the raw "_db_persisted" string at the
  fill-empty-tail pop site with the shared constant (was outside the drift
  guard).
- agent/conversation_compression.py: the no-op progress check now falls back
  to a marker-insensitive comparison (_strip_marker_for_comparison). Loaded
  rows are stamped at materialization time while compress() output is
  marker-swept, so a semantically-identical no-op copy on a cold-resumed
  session would previously compare unequal and take the progress branch.
  Raw == still runs first so engine-returned list subclasses keep their
  __eq__ semantics.
- test_marker_constant_in_sync extended to turn_finalizer + identity
  assertions; new test_noop_progress_check_is_marker_insensitive
  (mutation-checked: fails when the helper is neutered).
2026-08-23 13:34:32 +05:30
kshitijk4poor 016ba66176 fix: stamp _db_persisted at row load time so resumed transcripts never re-append (#92231)
Resumed sessions loaded message dicts from state.db WITHOUT the
_DB_PERSISTED_MARKER, so any flush that lost the identity boundary
(compression durable-snapshot adoption, incremental tool-call persists,
rotation preflight on cold resume) re-appended the ENTIRE loaded
transcript as new rows. Compression cycles then doubled the copies:
the incident session grew 998 -> 1995 -> 3990 -> 7981 rows across
three aborted rotations (15,962 active rows, only 472 distinct).

Fix at the architectural chokepoint: SessionDB._rows_to_conversation
(shared by get_messages_as_conversation and get_resume_conversations)
now stamps the marker at row materialization time - a dict built FROM
a durable row is persisted by construction, regardless of which caller
loads it or how the list is later handed to a flush.

Safety:
- Wire-safe: every transport strips underscore-prefixed keys before
  the API request (chat_completion_helpers, anthropic_adapter), same
  contract as the existing _row_id stamp in the same function.
- Rotation handoffs still write: compression's assembly copies strip
  the marker (_fresh_compaction_message_copy + the terminal
  _strip_persistence_markers sweep), so compacted transcripts still
  flush to the child session (#57491 invariant preserved).
- Branch/seed copies unaffected: /branch and _persist_branch_seed
  build fresh field-projected dicts and write via append_messages_batch
  directly, not through the marker-gated flush.

Tests: new regression suite (marker sync, load stamping, 3-cycle
amplification repro, new-tail write guard, compaction-copy handoff);
updated the #68454 control test that asserted the old double-write
behavior and the ACP restore shape test.
2026-08-23 13:34:32 +05:30
kshitij 987064caa4 fix: restore generic corruption match in FTS self-heal
The PR narrowed _is_fts_write_corruption_error to only match FTS5-specific
'fts5: corrupt structure record' errors, dropping the generic 'database disk
image is malformed' match. But FTS shadow table corruption (the common case)
raises the generic error on SQLite < 3.53, not the FTS5-specific one. This
broke FTS self-heal for 10 existing tests and for users on older SQLite.

Restore the generic match via is_malformed_db_error. Safety is preserved
because the FTS rebuild only touches derived indexes — if the damage is
actually in a canonical B-tree, the rebuild itself fails and the write
propagates.

Also restore the original test assertion and remove the
test_generic_malformed_write_fails_closed test whose premise (generic
corruption should not trigger FTS rebuild) was wrong for the FTS self-heal
path.
2026-08-23 02:55:03 +05:30
Bruce Xu 50bbcbf2b4 fix(state): fail closed on unscoped SQLite corruption 2026-08-23 02:55:03 +05:30
poisdahl fd41164861 fix(history): keep carrier rewinds race-safe after refresh 2026-08-22 17:30:35 +02:00
poisdahl a5b326a471 Merge remote-tracking branch 'origin/main' into agent/81234-merge-20260821
# Conflicts:
#	tests/agent/test_reference_handoff_active_turn.py
2026-08-22 16:47:39 +02:00
kshitijk4poor 1fe8683e58 fix(state): split forensic-backup identity from repair-epoch fingerprint; publish backup bundle atomically
Addresses two data-integrity gaps @andrexibiza flagged reviewing #88425.

1. Forensic dedupe no longer reuses the repair-epoch fingerprint.
   _db_fingerprint masks SQLite's commit counters and samples only head/tail
   so an ordinary write does not re-key the repair budget — the right
   predicate for 'same damage epoch', the WRONG one for 'same recovery
   image'. A live writer committing rows into an interior page (size
   preserved, head/tail untouched) collided under it, so _backup_db_file
   handed back a STALE backup that predates real user data. New
   _backup_content_identity() digests the whole file + every sidecar; the
   dedupe uses it. The O(n) read is cheaper than the O(n) copy it avoids on a
   hit.

2. Backup bundle is now published atomically. The promotion loop replaced
   files one at a time (main first) and cleanup unlinked only staging srcs,
   so a sidecar os.replace failure after the main promotion left the
   final-prefix main backup on disk — a countable-but-incomplete bundle that
   passed the #69603 hard stop and deduped as legitimate next pass. Now
   sidecars publish first and the main DB last (its name is the commit
   marker _existing_malformed_backups counts), and cleanup rolls back every
   already-published destination.

Two regressions added (both mutation-checked — each fails on pre-fix code):
- test_backup_not_deduped_after_interior_page_write
- test_publication_failure_leaves_no_countable_partial_bundle

tests/test_state_db_repair_loop_mtime.py: 28 passed.
2026-08-22 14:33:20 +05:30