Commit Graph

403 Commits

Author SHA1 Message Date
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
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
kshitij
5777e68b3d fix(state): include the rollback journal in the forensic backup
The pre-repair copy took only -wal/-shm. In rollback-journal (DELETE) mode --
Hermes's fallback on NFS/SMB/FUSE/ZFS and on WAL-reset-vulnerable SQLite builds
-- a hot <db>-journal exists on disk whenever a transaction was open, and that
file is what rolls the damaged bytes back to a consistent state. A forensic copy
without it cannot be recovered by hand, which is the entire purpose of taking
the copy before destructive surgery.

Verified the journal is really there:

  files while a txn is open: ['state.db', 'state.db-journal']
  files after commit:        ['state.db']

Add _DB_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal") and use it at the four
sites that must agree: the disk-guard sizing, the staging copy, the
backup-count exclusion in _existing_malformed_backups (so a copied journal is
not itself counted as a forensic backup), and _prune_malformed_backups (which
otherwise leaks one journal per pruned backup, quietly defeating the retention
cap this PR is partly about).

Matches the spelling hermes_cli/session_recovery.py:61 already uses for the
same concept.
2026-08-22 14:33:20 +05:30
kshitij
8779b782b3 fix(state): exclude SQLite's commit counters from the repair fingerprint
Third self-review pass found the content fingerprint was still defeated on
rollback-journal deployments, by the same mechanism as the original mtime bug.

The head sample starts at byte 0, so it covers the database header's file
change counter (bytes 24-27) and version-valid-for (92-95). In DELETE mode a
commit writes the main file directly and bumps both. A malformed-SCHEMA DB
still accepts writes -- that is the whole premise of this PR -- so any ordinary
session write between passes re-keyed the ledger:

  DELETE, 18MB db, one peer UPDATE between passes (before this commit)
    pass 1..6: attempts=1 every pass, exhausted=False -> unbounded loop

  after
    pass 1..3: attempts=1,2,3   pass 4: BLOCKED

WAL is unaffected (commits land in -wal; the main header only moves on
checkpoint), so this was invisible on a WAL host and reproducible on every
NFS/SMB/FUSE/ZFS or WAL-reset-vulnerable host -- exactly the deployments the
earlier lock-safety commit was written for.

Mask the two volatile ranges out of the sample. Page 1's sqlite_master b-tree
sits after byte 100 and stays in, so genuine recovery still resets the budget:
verified schema rewrite, index rebuild, VACUUM and truncation all change the
key, while a bare utime and an ordinary commit do not.

Test-cost cleanup in the same file, since the new tests needed a
larger-than-sample fixture and the file was already slow:
  - the two guard tests that allocated 450MB of os.urandom now use sparse
    truncate (both only ever read st_size), and the new fixtures use 600 rows
    rather than 40k;
  - file runtime 127s -> 35s.
2026-08-22 14:33:20 +05:30
kshitij
602c45e45e fix(state): never let a peer connection reset the repair budget
Self-review of the previous commit found it reintroduced the bug this PR
exists to fix, by a different route.

`_db_fingerprint` fell back to `size:mtime_ns` when a live connection made the
content read unsafe. The ledger compares keys for EQUALITY, and the two keys
have different SHAPES, so a gateway peer connecting between passes flipped the
shape and the counter reset to 1 every time:

  pass 1 [offline] attempts=1  fp=8192:58c7924f0fba...
  pass 2 [LIVE   ] attempts=1  fp=8192:1786972039271402096
  pass 3 [offline] attempts=1  fp=8192:58c7924f0fba...
  ... never reaches _MAX_PERSISTENT_REPAIR_ATTEMPTS

Return None instead, and teach the two ledger helpers to cope:

- `_persistent_repair_attempts_exhausted` falls back to the recorded key's
  SIZE prefix (the one component both shapes share and that needs no raw
  read) rather than reading as "not exhausted" — otherwise a peer connection
  hides an exhausted budget on every pass, same loop.
- `_record_repair_outcome` keeps the key already on record and still
  increments, rather than dropping the pass.

  pass 1 [offline] attempts=1  pass 2 [LIVE] attempts=2
  pass 3 [offline] attempts=3  pass 4 [LIVE] BLOCKED

Intra-pass flips were already safe (the probe and the record are both reached
with the same liveness within one `repair_state_db_schema` call); it is the
cross-pass change that desynced.

Also drops two `type: ignore` directives `ty` flagged as unused, and replaces
the `LiveConnectionError = ()` / `nullcontext()` shim with a real no-op
contextmanager + exception class so the scaffold-install path is honest.
2026-08-22 14:33:20 +05:30
kshitij
8de64b1634 fix(state): stop backup staging from posing as a forensic copy
The staging name was derived from the backup name
(`<db>.malformed-backup-<stamp>.incomplete`), which still matches the prefix
`_existing_malformed_backups` selects on -- it excludes only `-wal`/`-shm`.
Three consequences, all reproduced:

  - it is COUNTED as a forensic backup;
  - it sorts NEWEST (`.incomplete` > the bare stamp), so prune's
    keep-3-newest slice retained partials and deleted intact copies -- the
    exact inversion the staging change was meant to prevent;
  - worst, the dedupe ran BEFORE the sweep, and a staging file orphaned by a
    kill mid-copy is a byte-identical copy of the damaged DB, so its
    fingerprint MATCHES and it was handed back as the official `backup_path`.
    Repair then passed the #69603 hard-stop gate and ran destructive surgery
    believing a forensic copy existed, and the next pass's sweep deleted that
    very file.

Move staging outside the prefix (`<db>.backup-staging-<stamp>`) and sweep
before the dedupe. The sweep also matches the pre-merge `.incomplete`
spelling so a host that ran the earlier build does not keep prefix-matching
debris that sorts newest and survives prune forever.

Before / after on the same fixture (orphaned staging + a later pass):

  before  backup_path = ...malformed-backup-<stamp>.incomplete   (staging!)
          pass-1 forensic copy deleted by the next sweep
  after   backup_path = ...malformed-backup-<stamp>              (real copy)
          debris swept, pass-1 forensic copy preserved
2026-08-22 14:33:20 +05:30
kshitij
b3f14c8534 fix(state): keep the repair fingerprint from cancelling POSIX advisory locks
The content fingerprint takes a raw descriptor, and close() on ANY descriptor
cancels every POSIX advisory lock the process holds on that file. The
exhaustion probe runs before _backup_db_file's has_live_connection guard, so
the read happened even when a peer SessionDB held a write lock.

Verified end-to-end (journal_mode=DELETE, gateway mid-turn write, peer in a
subprocess):

  before   peer BLOCKED -> repair -> peer BLOCKED, holder COMMIT ok
  unfixed  peer BLOCKED -> repair -> peer STOLE the lock,
                                    holder COMMIT: disk I/O error

WAL is immune (it coordinates through -shm), but DELETE is what Hermes falls
back to on NFS/SMB/FUSE/ZFS and on SQLite builds vulnerable to the WAL-reset
bug, so this is a real deployment shape.

Run the read under offline_file_access and fall back to size:mtime_ns when a
connection is live. That keeps the ledger counting instead of returning None
(which reads as "not exhausted" and would restore the unbounded loop), and the
content key stays load-bearing on the offline repair path -- the only path
where surgery actually runs.

Also fail the free-space guard CLOSED: a nearly-full volume is exactly where
statvfs is likeliest to fail, and proceeding is the multi-GB copy that finishes
off the disk.
2026-08-22 14:33:20 +05:30
jirathip-k
c914a9ac4b fix(state): make backup atomic and the disk guard proportional
Follow-up to adversarial review of the first commit. Three findings, two
confirmed by test and fixed here, one disproven and left alone.

CONFIRMED — the free-space guard was a threshold, not cleanup. Prune runs
only on the success path, so any copy that failed partway (ENOSPC, sidecar
copy failure, kill mid-copy) left a file matching the `malformed-backup-`
prefix that nothing ever removed. Measured on the unpatched tree: backups
capped at 3 while copies succeed, but 13+ and climbing once copy2 raises —
self-reinforcing, since each partial consumes the space that guarantees the
next failure. Worse, partials sort newest-by-name, so a later successful
prune KEPT the garbage and deleted the intact forensic copies.
Fix: copy to a `.incomplete` staging name that does not match the backup
prefix, os.replace into place only after every copy succeeds, unlink staging
on failure, and sweep stale staging debris on entry.

CONFIRMED — the 2GiB floor was a small-volume regression. A 50MB DB on a
10GB volume with 1.5GB free (30x headroom) was refused, and since a refused
backup is a HARD STOP (#69603) that silently converts "repair loops" into
"repair never runs". Fix: require the copy itself (now including its
-wal/-shm sidecars, which the old check ignored) plus proportional headroom
— max(256MiB, 2% of volume).

DISPROVEN — the review claimed a refused backup skips _record_repair_outcome
so the loop never terminates. It does not: repair_state_db_schema records the
outcome on the result returned by _repair_state_db_schema_locked, which is
where the hard stop returns. Verified on a simulated low-disk host: terminal
at pass 4 with zero backups written. No change made.

Tests: 5 new (small-volume allow, proportional headroom, sidecar accounting,
failed-copy leaves no countable debris + staging swept). 23 pass with the
#86747 suite; test_hermes_state.py 252 passed. Pre-existing unrelated
failures unchanged.
2026-08-22 14:33:20 +05:30
jirathip-k
27d661e171 fix(state): stop unbounded state.db repair loop from filling the disk
A malformed-schema state.db sent Hermes into a repair loop that wrote a
fresh full-size forensic backup every ~10s: 31 copies / 2.3GB in 20
minutes, free space heading to zero on a host running an agent fleet.

The #86747 guards for exactly this were already present and did not hold.
Both keyed on `size:mtime_ns`:

  * `_db_fingerprint` -> the ledger's attempt counter reset to 1 on every
    pass, so `_MAX_PERSISTENT_REPAIR_ATTEMPTS` was never reached and the
    loop never terminated;
  * `_backup_db_file`'s dedupe compared mtime, so it never matched and
    each pass wrote another full-size copy.

The assumption behind that key -- "nothing can successfully write to a
damaged file" -- holds for the b-tree damage of #86747 but not for the
malformed-SCHEMA class: the DB still opens and accepts writes (only
sqlite_master is unreadable), so live writers, WAL checkpoints and the
in-place repair strategies themselves all move mtime between passes.

Fixes:

  * fingerprint on size + a bounded head/tail content sample instead of
    mtime. Stable across passes that merely touch the file, still changes
    on genuine repair/truncation/restore (so recovery resets the budget),
    and stays O(1) on a multi-GB DB.
  * dedupe the forensic backup on that same fingerprint.
  * add the missing free-space guard: refuse the pre-repair copy when it
    would leave under 2GiB free, with an actionable error. The backup is a
    full raw copy of the damaged DB, so a repair loop is a disk amplifier
    that can take down every process on the host -- and the refusal path
    already hard-stops the repair (#69603) rather than mutating the only
    remaining copy.

Tests fail on the unfixed tree and pass here; the pre-existing failures in
test_state_db_malformed_repair.py and TestFTS5Search are unrelated and
reproduce on the base commit.
2026-08-22 14:33:20 +05:30
kshitijk4poor
3bdc2165c3 fix(state): scope salvage to repair-connection durability + live-writer guard
Follow-up to the salvaged repair-durability commit. Scope corrections so this
PR ships only the reachable, non-competing, WAL-mode-correct half:

- Drop verify_state_db_integrity() + its 4 tests. Zero production callers here
  (dead code); the caller lives in the follow-up that wires it into
  SessionStore._open_session_db_for_active_scope() (PR #91754). The function
  moves with its wiring.
- Drop the _db_fingerprint change (size:mtime_ns -> dev:ino:size) + its 3
  ledger tests. This is competing work: PR #88425 (salvage of @jirathip-k's
  #88224) already fixes the same size:mtime_ns budget-reset bug with a
  content-sample + volatile-header-mask that also handles the DELETE-mode
  commit-counter case, and carries @jirathip-k's diagnosis/credit. Landing a
  second, divergent fingerprint contract would stomp that lineage. Fingerprint
  stays with #88425; this PR reverts _db_fingerprint to main's form.
- Mark test_repair_refuses_while_another_connection_holds_the_db requires_wal.
  _live_writer_holds_db detects an out-of-process holder via the WAL-index
  exclusive lock, absent in journal_mode=DELETE (used on WAL-reset-vulnerable
  SQLite <3.51.3 incl. CI's 3.50.4, and on NFS/SMB). The test failed there;
  the conftest requires_wal gate auto-skips it. DELETE-mode limitation is now
  documented on the guard docstring: repair is serialised only by the
  cross-process repairer lock there. The reported incident was in WAL mode.
- Map dhanesh@users.noreply.github.com -> dhanesh (contributors/emails) so the
  attribution CI gate passes.

Net: this PR is repair-connection durability barriers + the live-writer guard.
addresses @andrexibiza's #90747 review (dead-code verifier + fingerprint
interlock with #88425).
2026-08-22 04:02:29 +05:30
Dhanesh Purohit
ca28a69ada fix(state): apply macOS write barriers on every state.db repair connection
state.db corrupted twice in two days with the torn-b-tree signature —
repeated "2nd reference to page", "Rowid out of order", and long runs of
"never used" pages in messages (rootpage 5) and idx_messages_session.

macOS fsync() guarantees neither data-on-platter nor write ordering, which
_enforce_macos_synchronous_full already documents: a rewrite interrupted by
process or OS termination leaves half-written b-tree pages. The mitigation
is per-connection (synchronous=FULL + checkpoint_fullfsync=1) and was
applied only through apply_wal_with_fallback(). The repair path opened
state.db with a bare sqlite3.connect() six times and then ran REINDEX,
VACUUM and writable_schema surgery through it — the operations that rewrite
nearly every page of the file — with no barrier at all.

- _connect_repair_durable() routes every repair/probe connection through the
  barriers. Applying them is best-effort by necessity: SQLite loads the
  schema before any statement, so on a malformed schema even
  PRAGMA synchronous=FULL raises DatabaseError, and a malformed database is
  precisely this helper's input. _reapply_durability_barriers() retakes them
  before REINDEX and VACUUM, once the schema parses and they can stick.
- verify_state_db_integrity() adds the proactive check that was missing.
  Repair only ever ran reactively, after a caller already hit a malformed
  error, so a database torn in pages no query happened to touch stayed live
  and kept accepting writes. On 2026-08-19 that gap was 11 hours across two
  restarts that both reported a clean start. Size-aware: degrades to an O(1)
  probe above 2 GiB rather than pegging a CPU at startup.

Also restores two fixes lost when `hermes update` reset the tree to
origin/main before they were committed:

- _db_fingerprint keys the repair ledger on dev+inode+size instead of
  size+mtime_ns. The old form was justified as "stable for a file nothing
  can successfully write to"; that premise is false, because on FTS
  corruption this module deliberately keeps canonical writes enabled with
  FTS detached. mtime churned on every write, so each pass re-keyed the
  ledger and reset the counter to 1 — the cap could never be reached and the
  damaging surgery could retry forever.
- _live_writer_holds_db() refuses surgery while another connection holds the
  database. The cross-process lock only serialises repairers against each
  other; it says nothing about the gateway, Desktop or a CLI. Rewriting
  b-tree pages under a concurrent writer is what spread the 2026-08-18/19
  damage out of the FTS shadow tables and into the canonical ones. Fails
  open, so it cannot strand the self-heal path it protects.

The guard's own tests built a two-table toy schema, so every repair aborted
on "no such table: sessions" before reaching the guards under test — the
assertions were passing over a code path that never ran. They now build
through a real SessionDB.

Targeted state/repair suites: 330 passed, 1 pre-existing unrelated failure.
Broader sweep: 50 failed/1221 passed -> 46 failed/1225 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Dhanesh Purohit <dhanesh@users.noreply.github.com>
2026-08-22 04:02:29 +05:30
kshitijk4poor
2ea5287da0 fix(state): only flag uninspectable Hermes processes as holders
The cmdline fallback was matching every system daemon with an
unreadable fd table (init, systemd-journald, dockerd, etc.), causing
FTS rebuilds to be skipped on every Linux system. Add _looks_like_hermes
filter so only processes whose cmdline contains Hermes markers are
flagged — matching @jackulau's suggestion of 'uninspectable AND
identifiable as another Hermes process.'
2026-08-22 03:56:13 +05:30
kshitijk4poor
1c59daaace fix(state): use /proc readlinks + cmdline fallback for holder detection
Address review feedback from @jackulau on PR #90871:

1. psutil.open_files() silently drops '(deleted)' WAL sidecar entries
   on Linux because isfile_strict() stats the literal path including
   the suffix and fails. Switch to direct /proc/<pid>/fd readlinks
   which preserve the '(deleted)' suffix so _canonical can match.

2. psutil.process_iter() converts AccessDenied to None, which
   or-() skips silently — the fail-closed branch never runs. For the
   root-gateway vs user-desktop topology in the issue, the fd table is
   unreadable but /proc/<pid>/cmdline is world-readable. Add a cmdline
   fallback that flags uninspectable processes.

Also keep the psutil path for macOS/BSD (no '(deleted)' convention).
2026-08-22 03:56:13 +05:30
kshitijk4poor
c45e2b19c3 fix(state): guard gateway FTS rebuild + comment early flag-set
Add the foreign-holder guard to gateway/session.py::_rebuild_fts_once(),
the third FTS rebuild path that was not covered by the original fix.
Also add a comment explaining why _fts_runtime_rebuild_attempted is set
before the foreign-holder check: the fail-open path that follows
persists FTS_STALE_KEY so the next startup retries via _recover_stale_fts.
2026-08-22 03:56:13 +05:30
fangliquanflq
fc72d6c716 fix(state): defer FTS rebuild under foreign WAL holders 2026-08-22 03:56:13 +05:30
abundantbeing
a2a23a8f7e fix(clients): hide compaction carriers across surfaces 2026-08-21 21:53:11 +05:30
Teknium
76653a8eba fix(sessions): prune/archive spare pinned sessions by default (data loss) 2026-08-20 01:46:20 -07:00
Teknium
97b5f66030 docs(state): soften stale SessionDB self-pin wording after #88063
#88048 documented the token-writer self-pin (bound-method thread target +
strong atexit hook) as a permanent contract: "__del__ never runs for
exactly the instances that leak". #88063 then removed both pins (idle
writer retirement + weakref atexit hook), making abandoned handles
eventually collectible.

Reword the __enter__ docstring and the context-manager test module
docstring to describe the pin as historical motivation, note the #88063
behavior, and keep the guidance that owners close deterministically.
No code changes.
2026-08-16 20:43:13 -07:00
fangliquan
59c7a99084 fix(state): avoid overlapping context manager change 2026-08-16 19:53:07 -07:00
fangliquan
b454e4da76 fix(state): release abandoned session database handles 2026-08-16 19:53:07 -07:00
Jack Lau
ab7f48d4b0 feat(state): support the context-manager protocol on SessionDB
A SessionDB handle cannot be released by dropping the last reference.
Once its background token writer starts, the instance pins ITSELF two
ways: the writer thread's target is a bound method, and
queue_token_counts registers atexit.register(_drain_token_queue_at_exit),
which only close() unregisters. A dropped-but-pinned handle keeps its
state.db/-wal/-shm descriptors for the life of the process, and __del__
never runs for it, so the existing safety net is dead code for exactly
the instances that leak.

That is why owning call sites are expected to close explicitly, in those
words, in the ownership comments in run_agent.py and
tui_gateway/methods_session.py. This adds the ergonomic half of that
contract so an owner can scope a handle and be exception-safe by
construction:

    with SessionDB(path) as db:
        db.append_message(...)

Purely additive. __enter__ returns self, __exit__ closes and returns
False so a caller's exception always propagates, and close() is already
idempotent, so a scope that closes early still exits cleanly. Nothing
changes for callers that already close directly.

Four regressions cover the scope closing the handle, __enter__ returning
the instance itself, the failure path closing while still propagating,
and an early close leaving the exit clean. They assert on the
sqlite_safe_read tracking registry rather than raw descriptor counts,
matching test_session_db_read_conn_pool.py, because SQLite's unix VFS
parks a closed descriptor on a per-inode reuse list and makes raw counts
lag the real connection count.

Refs #88033
2026-08-16 19:52:21 -07:00
Teknium
06b9141109 fix(state): classify structural DB corruption as its own persistence cause
'database disk image is malformed' contains the word 'disk', so
classify_persistence_error bucketed SQLITE_CORRUPT / SQLITE_NOTADB
failures as 'disk' and the turn-completion explainer told users to
free disk space for a structurally damaged state.db (the #77386-family
misdiagnosis, reproduced in the v0.20.0 malformed-DB incident report).

- hermes_state: new 'corrupt' bucket in PERSISTENCE_ERROR_CAUSES,
  matched via _DB_CORRUPTION_MARKERS BEFORE the locked/disk buckets
- run_agent: explainer text for 'corrupt' points at hermes doctor and
  explicitly says freeing space will not help
- cron explainer-variant suppression picks the new variant up
  automatically (it iterates PERSISTENCE_ERROR_CAUSES)
2026-08-16 10:34:23 -07:00
Ojas Sharma
7095e23eb2 fix(agent): attribute background-review usage and add cost controls
Persist fork token usage under session_model_usage task=background_review,
emit a per-fork completion log line, and expose enabled/max_iterations/
prompt_file so operators can see and bound the automatic review cost.

Address review feedback: load auxiliary.background_review once per spawn,
classify completion logs by summarize action prefixes, treat explicit
api_call_count=None as the documented default of 1, and WARNING on the
fail-open enabled-gate path.
2026-08-16 06:38:38 -07:00
fangliquanflq
095d25c612 fix(gateway): keep persisted model routes consistent 2026-08-16 02:03:31 -07:00
fangliquanflq
39d2d858fd fix(gateway): persist hygiene failure cooldown rung 2026-08-16 02:02:06 -07:00
fangliquanflq
0a42bc7113 fix(sessions): align prune filter derivation 2026-08-16 01:55:25 -07:00
fangliquanflq
0b8a09759c fix(sessions): address prune skip review notes 2026-08-16 01:55:25 -07:00
fangliquanflq
29dfbf2d6a fix(sessions): surface open sessions skipped by prune 2026-08-16 01:55:25 -07:00
Teknium
406c5daf04 fix(compression): bound the rotation tail clone below the rotator's own flush
CI caught two rotation-path regressions from the unbounded clone: the #47202
pre-publish flush writes the rotator's OWN input transcript to the parent
(above the start-watermark), and the clone was duplicating it into the child
alongside the handoff. publish_compression_child gains watermark_ceiling —
the MAX(id) captured immediately BEFORE that flush — so only rows in
(watermark, ceiling] (genuinely foreign concurrent appends) clone across.
Ceiling capture failure falls back to no tail preservation (historical
behavior) rather than risking duplication. Ceiling-exclusion test added.
2026-08-15 23:37:22 -07:00
Teknium
652f5c2ebb fix(compression): rotation path clones the concurrent tail into the child
CI caught the sibling site the in-place fix missed: legacy (non-in-place)
compression rotates via publish_compression_child, where a mid-summary
append previously stranded in the closed parent. Same watermark + pure-SQL
column clone as archive_and_compact, with session_id rewritten to the child.
Lineage-guard test flipped to pin the appends-flow-freely contract; rotation
watermark tests added (tail follows the child; None = historical behavior).
2026-08-15 23:37:22 -07:00
Teknium
21d3e63702 fix(compression): watermark commit — appends flow freely, concurrent tail survives compaction
Redesign of the #75316 class (supersedes the approach in PR #87307).

Root cause family: the compression lock fenced ORDINARY transcript appends
for the whole slow provider-summary call. Turns died as
session_persistence_failed whenever a message overlapped a compression
(#74568, #77386, #75083), stale dead-PID locks blocked writes for the full
TTL, and the busy-wait mitigation (#75264) was an order of magnitude shorter
than real summaries. Separately, the commit archived from a pre-call
snapshot, so rows appended mid-compression were swept into the archive.

Design: the commit transaction is already exclusive — no lock phases needed.

1. Appends never check compression_locks. The lock's only job is stopping
   two compressions colliding; it keeps that job. The whole stale-lock /
   busy-wait symptom family dies as a class.
2. Watermark captured in the DB at compression start
   (get_active_message_watermark = MAX(id) of active rows) — not from
   in-memory message dicts, which carry no row ids in production.
3. archive_and_compact(watermark=, lock_holder=): one transaction verifies
   the holder still owns an unexpired lease (a reclaimed lease cannot
   publish a stale compaction), archives the snapshot, inserts the compacted
   set, and re-sequences the concurrent tail (id > watermark) via a
   pure-SQL column clone — every column except id survives byte-exact
   (api_content, platform_message_id, reasoning sidecars, token counts),
   FTS triggers index the clones naturally, originals stay archived and
   recoverable. watermark=None preserves the historical behavior.

Removed: the append-side compression fence in _check_transcript_write_guards
(with rationale note), making the _COMPRESSION_BUSY_WAIT_S retry lane
unreachable from append paths (kept for other callers).

Tests: 12 new (watermark contract, column-exact clone, commit fence incl.
lease-lost/expired/rollback failure injection, append-vs-commit race);
busy-retry suite flipped to pin the new contract; sabotage-verified (5 fail
with the watermark disabled, 12 pass restored); E2E through the real
compress_context seam with a mid-summary append landing and surviving.
2026-08-15 23:37:22 -07:00
Teknium
933ef69470 feat: session picker lifecycle status + delete 2026-08-15 18:15:22 -07:00
Teknium
0d00ebef79 fix(state): bound the state.db repair loop and stop dead-backup accumulation (#86747)
A corruption class the repair strategies cannot heal (b-tree page
damage) failed repair_state_db_schema on every process start, forever:
_claim_repair_attempt's in-memory set only bounds one process, so each
restart re-ran the full surgery AND took a fresh ~900MB forensic backup
of the same damaged bytes — 105 attempts / 89GB of dead
state.db.malformed-backup-* files over 11 days in the reporting install.

Three bounded behaviors, all sidecar-file based (no schema changes):

1. Persistent attempt ledger (<db>.repair-attempts.json): after 3 failed
   repair passes against the same file fingerprint (size + mtime_ns),
   repair_state_db_schema refuses with a terminal, actionable error
   (restore a backup / `sqlite3 state.db ".recover"` / delete the ledger
   to force a retry) instead of re-running surgery. Success clears the
   ledger; a replaced or restored file re-keys it and gets fresh
   attempts. Missing/corrupt ledger fails open (never blocks a first
   repair).

2. Backup dedupe: _backup_db_file reuses the newest existing forensic
   backup when it is byte-identical to the damaged file (size+mtime
   match, preserved by copy2) instead of copying another ~900MB.

3. Retention cap: only the 3 newest malformed-backup copies (plus
   sidecars) are kept; older ones are pruned after each new backup.
   Also fixes a same-second timestamp collision that silently
   overwrote an earlier forensic copy.

Tests cover ledger accumulation, terminal refusal (surgery not called,
no new backup), budget reset on file change, success-clears-ledger,
corrupt-ledger tolerance, dedupe, distinct-state backups, retention
prune incl. sidecars, and the end-to-end one-backup invariant.

Fixes #86747
2026-08-15 02:22:58 -07:00
Teknium
62d3ef683c fix(tests): subprocess-surviving isolation marker closes the #82770 fixture escape
The hermetic conftest now exports HERMES_TEST_ISOLATION (value = the tmp
isolation root) before any test module imports, and re-pins it per test in
_hermetic_environment. hermes_state._running_under_pytest() honors the
marker as a test-context signal alongside PYTEST_CURRENT_TEST /
PYTEST_VERSION.

Why a third layer: PYTEST_* belongs to pytest, and tests that spawn
children routinely rebuild the child env and strip it ("the subprocess
must look like a real CLI" — tests/cli/test_exit_watchdog_signal_arm.py,
tests/hermes_cli/test_config_loader_e2e.py do exactly this on purpose).
Such a child loses the HERMES_HOME redirect and the guard's arming signal
in one step, which is how 700+ zero-message fixture rows (dm:123, chat-1,
wx-chat, ...) landed in a developer's production state.db. The marker is
OURS: stripping it is never required to make a child "look real" (no
production code branches on it except the guard), it inherits by default,
and children that genuinely need a real DB use the sanctioned
HERMES_STATE_DB_GUARD_BYPASS=1 hatch instead.

The ancestry-walk layer (previous commits) stays: it covers children whose
env was rebuilt from a completely empty dict. The marker layer covers the
common **os.environ-derived rebuilds cheaply (one dict lookup, no psutil),
and — unlike ancestry — also covers detached/daemonized children that
escape the process tree.

tests/hermes_state/test_isolation_marker_env.py pins: the conftest export,
the marker-alone arming, the rebuilt-env child refusing the production
path, and the bypass hatch. Sabotage-verified: 3/6 fail without the fix.
test_live_db_guard_ancestry._scrubbed_env now strips the marker too, so
the ancestry tests keep proving ancestry rather than riding the marker.
2026-08-15 02:20:13 -07:00
joaomarcos
c0684e4223 fix(state): make the pytest-launcher match platform-independent
`_process_looks_like_pytest` used `os.path.basename`, which under Linux is
POSIX-only: a Windows-style argv token such as
`C:\venv\Scripts\pytest.exe` came back unchanged and never matched, so the
matcher's verdict depended on the host it ran on. A guard should answer the
same question the same way everywhere.

Split on both separators explicitly instead. This keeps the deliberate
false-positive protection — `/tmp/pytest-of-dev/...` still reduces to its
last segment, not to `pytest` — while removing the platform dependency.

Caught by CI: test_recognises_pytest_invocations[cmdline2] passed on Windows
and failed on Linux.
2026-08-15 02:20:13 -07:00
joaomarcos
f5163bf83b fix(state): import Set for the routing-cleanup annotation
`_delete_routing_entries_for_sessions(session_ids: Set[str])` referenced a
name `hermes_state` never imported, so importing the module raised
`NameError: name 'Set' is not defined` and took down 11 of 12 CI test slices.

It passed locally because Python 3.14 evaluates annotations lazily (PEP 649)
and never touches the expression; CI runs an older interpreter, where the
annotation is evaluated at class-body execution.
2026-08-15 02:20:13 -07:00
joaomarcos
174ce8770d fix(state): arm the live-DB guard by process ancestry, not env alone
Production `state.db` files accumulate zero-message "open" gateway session
rows carrying test-fixture identities (`chat-1` / `user-1` / `wx-chat`), with
matching `gateway_routing` scopes pointing at `pytest-of-*` temp directories.

The escape is structural. Hermetic isolation rides entirely on the process
environment: `HERMES_HOME` says *where* to write, `PYTEST_CURRENT_TEST` /
`PYTEST_VERSION` say *whether the guard is armed*. Both travel in the same
carrier, so a child spawned with a rebuilt environment loses them together —
it resolves the developer's real `state.db` *and* silences the only check
that would have stopped it, in one step. The guard is a no-op in precisely
the situation it was written for.

Back the env probe with process ancestry, which survives an env rebuild:

* `_process_looks_like_pytest()` matches a pytest launcher by argv token
  basename, so `/tmp/pytest-of-dev/...` paths in real argv cannot
  false-positive, and an unreadable process is never assumed to be a test.
* `_has_pytest_ancestor()` walks parents via psutil, memoised, and fails
  open when psutil is unavailable — a real `hermes` run pays for at most
  one walk and keeps the previous behaviour if the walk errors.
* `_in_test_context()` checks env first (two dict lookups, covers the
  in-process case) and only then ancestry.

`_STATE_DB_GUARD_BYPASS` is a module global and cannot cross a process
boundary, so ancestry-armed children would have had no way to opt out at
all; `HERMES_STATE_DB_GUARD_BYPASS=1` is the env-carried twin.

Also sweeps the rows already written. Bulk prune/archive cannot reach them:
their shared selector is pinned to `ended_at IS NOT NULL` so a live session
is never picked, which permanently excludes every never-closed row. Adds a
narrower selector — keyed, still open, and with no messages, tokens, tool
calls, API calls, activity or title — behind
`hermes sessions prune --never-active` (default floor 30 days, honours
--dry-run/--yes). Routing entries naming a deleted row go with it, so the
gateway is never left resuming a session id that no longer exists; `pinned`
and `archived` rows are excluded as explicit user intent.

Closes #82770
2026-08-15 02:20:13 -07:00
DannyFengTianYu
b066f2b373 fix(history): isolate branch transcripts from parent updates
(cherry picked from commit 57c51cc401e867e0b315e051ba138d0b8f7a5f27)
2026-08-15 00:35:40 -07:00
embwl0x
e89532d97e fix(desktop): order async session git metadata 2026-08-15 00:33:11 -07:00
Teknium
fbaea9bddc feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable) (#86797)
* feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable)

Adds a source-orthogonal, archive-orthogonal 'hidden' session flag meaning
'don't show in the global Sessions sidebar, but stay fully resumable by the
surface that owns it'. Mirrors the existing archived/pinned capability end to
end, so it's a generic widening (any plugin that owns its own session lifecycle
- kanban, Bot Mode, future plugins - can keep its sessions out of the shared
recents list) rather than a per-plugin special-case.

- Schema: hidden INTEGER NOT NULL DEFAULT 0 on sessions (additive; lands on
  existing DBs via the declarative _reconcile_columns ADD COLUMN path, same as
  archived/pinned - no version-gated migration).
- DB: SessionDB.set_session_hidden(session_id, hidden) (clones set_session_pinned
  incl. the compression-lineage recursive CTE); list_sessions_rich gains
  include_hidden=False, appending 's.hidden = 0' by default so hidden rows drop
  from every listing path (and the REST sidebar endpoints inherit it with no
  change).
- Gateway: session.set_hidden RPC (mirrors session.title); session.create accepts
  hidden=true, deferred via pending_hidden and applied in _ensure_session_db_row
  when the row is lazily created (mirrors pending_title).
- REST parity: PATCH /api/sessions/{id} accepts+bool-validates 'hidden' ->
  set_session_hidden; _session_response exposes it.

Enables Hermes-Bot-Mode to hide canonical 'Bot Chat' sessions from the sidebar
(NousResearch/Hermes-Bot-Mode#46) WITHOUT retagging source (which would mis-set
the agent platform). Bot Chats keep source=desktop. Gateway RPC needs a
SERVE-backend restart to take effect live. 1 focused test (default-exclude /
include_hidden / unhide round-trip).

* fix: teach lost-and-found recovery about the 55-column sessions layout

Adding the 'hidden' column makes the current sessions table 55 columns. The
SQLite lost-and-found recovery classifier keys off the physical field count
(SESSIONS_LAYOUT_NFIELDS) to identify a salvaged sessions row, so a recovered
current-layout row (nfield=55) would otherwise be unrecognized and dropped.
Add 55 to the frozenset (54/52 stay as historical prefixes) and update the
column-count assertions + synthetic current-layout insert in the recovery test.

---------

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-15 00:31:37 -07:00
kshitij
7a390f7c43 fix: __del__ delegates to close() for full cleanup
The original __del__ only closed _conn (the writer connection),
skipping the read-only connection pool, token writer thread, and
atexit unregister. Delegates to self.close() instead so all
cleanup paths run. Uses __dict__.get('_conn') guard to stay
safe on partially-constructed instances and during interpreter
shutdown.
2026-08-15 10:26:24 +05:30
RelaxJonh
1db4801063 fix: close leaked SessionDB connections on exception paths (#83226)
Two call sites create SessionDB instances without closing them on error:

1. gateway/slash_commands.py: /insights command — db.close() was on the
   success path but not in a finally block, so exceptions between
   SessionDB() and db.close() leak the connection.

2. hermes_cli/sessions_cmd.py: sessions repair — SessionDB() created
   inline with no .close() at all, leaking the FD on every call.

Additionally, add a __del__ safety net to SessionDB itself so that
instances orphaned by callers who forget .close() are cleaned up when
garbage collected, rather than pinning FDs alive until process exit
via the atexit hook.

Fixes #83226
2026-08-15 10:26:24 +05:30
joaomarcos
39e480c051 fix(state): close leaked SessionDB connections on exception paths (#83226)
SessionDB could leave native SQLite handles open when construction failed
partway through schema/pragma/FTS/repair/lock/interrupt handling. Other
short-lived callers (MCP reads/polling, session search, reactions, trace
upload, insights, shutdown recovery) opened temporary SessionDB handles
without a complete ownership boundary. API-server profile caches and
RetainDB shutdown had similar late-close races. Under sustained load this
exhausted file descriptors (EMFILE).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:41:26 -07:00
Teknium
8dc5608a78 fix(compression): adopt live continuation tip at flush across multi-hop chains
A turn writing against a session already closed by compression died with
session_persistence_failed and a misleading "this is often a full disk"
dialog, even though the store was healthy and a live continuation existed
(#82001). Depth-1 recovery (find_live_compression_child) could not resolve
lineages with >=2 compression hops (root -> mid -> tip), reproduced
independently on two- and three-hop chains.

- run_agent.py flush chokepoint: on CompressionSessionClosedError, resolve
  tip = db.get_compression_tip(old_id) (canonical bounded transitive walk),
  adopt only when tip != old_id AND the tip row is live, retry the flush
  exactly once (adoption budget); otherwise fail closed.
- gateway/session.py append_to_transcript: replace the depth-1 live-child
  lookup with the same tip + liveness contract, so gateway transcript
  reroutes follow full chains.
- agent/conversation_compression.py _adopt_live_compression_child: turn-start
  recovery preflight now resolves via get_compression_tip with the same
  liveness check, closing the last depth-1 consumer in this family.
- classify_persistence_error: new "compression_closed" bucket; the turn-end
  explanation names compression rotation and tells the client to refresh the
  session id instead of blaming a full disk.

Tests: depth-1 adoption, multi-hop chain adoption (agent + gateway), fail
closed with no continuation / stale-closed (ws_orphan_reap) tip, exactly-once
adoption budget, and error-wording guards (compression-closed never mentions
disk; real disk failures keep disk guidance).

Closes #82001

Co-authored-by: Al3xand3r1987 <125030427+Al3xand3r1987@users.noreply.github.com>
Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
2026-08-14 21:39:44 -07:00
zuowen7
bbb6cc7e99 test: cover tool-message dedupe and latest paging for include_compacted (#80680)
Dedupe key now includes tool_call_id/tool_calls/tool_name: compaction
copies carry those fields verbatim, so identical tool messages across
generations still collapse, while distinct tool calls sharing
role/content/timestamp are never merged. Add endpoint-level coverage
for the desktop's real read path (limit + order=latest +
include_compacted=true).
2026-08-14 21:08:14 -07:00
zuowen7
f71f91a39b fix(desktop): surface compaction-archived messages in transcript reads (#80680) 2026-08-14 21:08:14 -07:00