Commit graph

12 commits

Author SHA1 Message Date
Gille
cff60b205d fix(sessions): verify fully reconstructed recovery 2026-07-26 23:34:29 -07:00
Gille
914059fad6 fix(sessions): retain all reconstructed sessions 2026-07-26 08:06:46 -07:00
teknium1
d2e733e636 fix(sessions): reconstruct missing sessions instead of deleting salvaged messages
Reported in Discord by @spherohero: `sessions recover --allow-partial` copied
20,817 of 20,824 message rows, then orphan cleanup deleted every one of them
because no session row was salvageable. Final output: 0 sessions, 0 messages.
The salvage worked and then threw the result away -- the exact opposite of
what --allow-partial exists to do.

_cleanup_partial_orphans() removed dependent rows whose session_id had no
matching sessions row. That is correct when a few sessions are lost; it is
catastrophic when the sessions b-tree is damaged worse than messages, which
is the common shape (sessions is small and hot, messages is large).

Reproduced exactly: 500 readable messages, unreadable sessions -> 500
copied, 500 removed, empty output.

Now _reconstruct_missing_sessions() runs FIRST, inside the same transaction,
synthesizing a minimal row per orphaned session_id (only id/source/started_at
are NOT NULL). started_at comes from the earliest surviving message.
Placeholders carry source='recovered' and an explicit title so a fabricated
session can never be mistaken for an original. Same repro now retains all
500 messages under 1 reconstructed session.

Reconstruction is reported as LOSS, not a clean recovery: session metadata
(title, model, timestamps, cost) is genuinely gone even though the
conversation text survived. loss_detected=True, partial=True,
complete=False, with a warning naming the counts.

Also fixes total_removed_or_relinked, which summed every dict value and would
now have counted retained messages as removed.

Sabotage-verified: removing the reconstruction call restores the wipe and
fails the test. 942 targeted tests green.
2026-07-25 23:45:18 -07:00
teknium1
36926af266 test(sessions): prove the connector reached the lock before asserting blocked
Closes the last false-pass window @helix4u flagged on #71779. He explicitly
said not to hold the PR for it; it is two lines, so worth doing rather than
leaving a known-soft assertion in a concurrency test.

connection_opened.wait(1.0) proved the connection had not opened, but not
that the connector thread had actually reached connect_tracked() -- an
unscheduled thread produces the same observation. The connector now sets
connect_attempted immediately before the blocking call, and the test waits
on that first, so "still blocked" means blocked at the lock rather than
not yet started.

15/15 stable at ~1.1s. Removed-lock sabotage still fails.
2026-07-25 23:05:30 -07:00
teknium1
c8aa0c7a34 fix(sessions): report damaged state_meta as loss, not absence
Second round of @helix4u review on #71779. Both findings reproduced before
fixing.

1. My previous fix turned a crash into SILENT DATA LOSS. Returning
   status="missing" for a present-but-unusable state_meta looked like a safe
   degrade, but _verify_recovered_database only escalates "failed"/"partial"
   into a warning + loss_detected. Measured on the branch: a run that dropped
   a real metadata table reported warnings=[], loss_detected=False,
   partial=False, complete=True. Strictly worse than the ValueError it
   replaced -- that at least failed loudly.

   Now "failed" when the table exists but lacks key/value, "missing" only
   when genuinely absent. The damaged case yields
   warnings=['state_meta copy status is failed'], loss_detected=True,
   partial=True, complete=False, while staying verified=True so the output
   is still installable-with-review.

2. The race test I wrote had its own scheduling race: after the guard
   released the lock, the racer could win before the main thread set the
   release event, failing on a correct implementation. Rewritten per
   helix4u's design -- copy runs in a worker parked inside the patched
   copy, a second worker attempts connect_tracked(), assert it stays blocked,
   release, assert it then opens. Deterministic and ~1.1s instead of 10s;
   12/12 stable.

Sabotage-verified. Note the third scenario only failed after adding a
unit-level test: recover_session_database short-circuits on the inspection
result when state_meta is entirely absent, so the helper's absent-branch is
unreachable end-to-end and a regression there was invisible. Both statuses
are now pinned directly.

939 targeted tests green.
2026-07-25 23:05:30 -07:00
teknium1
9657f6e343 fix(sessions): close the snapshot check/use race and guard damaged state_meta
Post-merge follow-up to #71770. Both defects were found by @helix4u in review
and reproduced against merged main before fixing.

1. Check/use race in _copy_source_bundle (my bug, from the #71770 follow-up
   commit). It called has_live_connection(), released the registry lock, and
   only then ran shutil.copy2() over the bundle. A tracked connection could
   open in that window; the copy's close() then cancels its POSIX advisory
   locks -- the exact class #71724 closed. Measured on main: a racer thread
   opened a connection mid-copy after blocking 0.000s.

   Adds sqlite_safe_read.offline_file_access(), a context manager that holds
   the connection-lifecycle lock across an entire multi-step raw access, and
   routes the bundle copy through it. Same racer now blocks 10.0s until every
   raw descriptor is closed. Any future raw read of a database file (hashing,
   moving a bundle aside) should use this rather than a bare pre-check.

2. _copy_state_meta_salvage assumed a 'key' column. A damaged state_meta can
   keep 'value' and lose 'key'; columns.index("key") then raised ValueError
   and aborted the whole partial recovery. The mirror case (key without
   value) would have copied key-only rows and reported the table complete.
   Now requires both, matching the non-partial _copy_state_meta, so an
   unusable optional table is recorded as missing/failed and --allow-partial
   still recovers sessions and messages.

Both regression tests verified by sabotage: reinstating the bare pre-check
fails the race test, removing the key/value requirement fails the other.
937 targeted tests green.
2026-07-25 23:05:30 -07:00
teknium1
a1c4d99953 fix(sessions): refuse to snapshot a live database during recovery
Follow-up to the partial-recovery salvage. _copy_source_bundle() raw-copied
the source state.db and its -wal/-shm sidecars with no live-connection check.

Copying a database file is an open()/close() on it, and close() cancels every
POSIX advisory lock the process holds on that file -- including a running
VACUUM's EXCLUSIVE lock (fe431651c5). hermes_state._backup_db_file already
refuses that situation; this path did not, leaving two policies for one
hazard. Verified against the merged guard: with a tracked connection open,
_backup_db_file returned None (refused) while _copy_source_bundle copied
anyway.

Recovery normally runs as its own short-lived CLI process against an
offline/quarantined file, so this should never fire in practice. It is a
consistency fix, not a live corruption path -- but it is exactly the drift
that reintroduces the bug later.

Regression test verified by sabotage: removing the guard fails it.
2026-07-25 22:14:57 -07:00
Gille
508764d384 fix(sessions): add opt-in partial database recovery 2026-07-25 22:14:57 -07:00
teknium1
fe431651c5 fix(state): make the byte-probe guard atomic, path-correct, and fail-closed
Addresses review findings on the previous commits. Three of them were real
defects I reproduced against my own head before fixing.

1. Check/use race (BLOCKING). read_header_bytes_preopen() checked
   has_live_connection() under _live_lock, released it, then did the raw
   open/read/close outside the lock; connect_tracked() opened before
   registering. A thread could pass the "nothing is live" check, another
   could open a connection and BEGIN IMMEDIATE, and the first thread's
   close() then cancelled its POSIX locks -- the exact bug this guard
   exists to prevent. Reproduced deterministically (BLOCKED -> ACQUIRED).
   _live_lock now spans all three lifecycle transitions: open+register,
   unregister+close, and check+open+read+close.

2. Read-only connections keyed by URI spelling (BLOCKING). SessionDB's
   read-only path opens file:/…/state.db?mode=ro; that string was fed to
   Path.resolve(), producing <cwd>/file:/…/state.db?mode=ro. No probe of
   the real Path could match, so read-only connections were invisible to
   the guard and their locks cancellable. Reproduced with no forced
   scheduling. Keys now come from PRAGMA database_list (canonical path),
   with an explicit tracking_path override.

3. Fail-open wrapper (HIGH). _connect_tracked_db() caught every exception
   and retried an untracked plain connect, so any error silently disabled
   the guard. Now only ImportError (scaffold installs without hermes_cli)
   falls back; real failures propagate.

4. Backup paths that warned and proceeded (MEDIUM). _backup_corrupt_db()
   and _backup_db_file() raw-read live databases; they now REFUSE when a
   connection is live rather than warning. Losing a forensic copy beats
   corrupting the database being rescued.

Custom factories are no longer rejected (that broke legitimate callers) nor
silently untracked -- the tracking close() is mixed into whatever factory is
in play, including when an opener substitutes its own after the fact.

WAL POLICY: #70055 is RESTORED, not reverted. My earlier justification was
confounded -- the clean WAL result came from 3.53.1, which carries both the
WAL-reset fix AND 3.51.0's broken-lock defenses, so it said nothing about the
bundled 3.50.4. Re-measured on 3.50.4 with the lock fix in place: WAL 0/3 and
DELETE 0/3, i.e. no evidence WAL is safer. Upstream still documents the
WAL-reset bug through 3.51.2 as serious. Keeping new databases out of WAL
until a fixed runtime ships is the conservative call, and the WAL policy does
not belong in this root-cause fix.

Six sabotage runs confirm each new test fails when its defect is reinstated
(including two that initially did NOT -- the race test was rewritten to pause
inside the byte read, and a separate test added for the opener-substituted
factory path). 1124 targeted tests green.
2026-07-25 21:44:43 -07:00
teknium1
95fb477856 fix(state): close the tracking leak and finish the audit of raw DB reads
Completeness pass over the previous commit.

Registry leak (would have silently disabled the guard): the first version
incremented on open but decremented only in SessionDB.close(). kanban's
connect() hands raw connections to callers who close them directly (4 sites),
so its counter only ever went up — after enough kanban operations every
byte-probe on that path would be refused forever, disabling zeroed-file and
header detection. Replaced manual track/untrack with a TrackedConnection
subclass that untracks in close(), the one method every close path goes
through. Verified across plain close, contextlib.closing, double close,
nested lifetimes, and 100-cycle churn; `with conn:` (a transaction scope, not
a close) correctly stays tracked.

Also: a caller-supplied factory now wins instead of raising TypeError on a
duplicate kwarg, since tracking is an optimisation for the probe guard, not a
precondition for opening the database.

Removed _apply_delete_for_wal_reset_bug, dead after the force-DELETE revert.

Audit notes:
- DELETE mode is still reachable via the NFS/SMB/FUSE fallback and remains
  correct there; those users are protected by the raw-read fix, not by the
  journal mode.
- The remaining whole-file reads are on genuinely offline artifacts:
  _backup_db_file (DB won't open; bytes preserved for forensics),
  _backup_corrupt_db (quarantine path, now warns if a connection is live),
  and backup verification of snapshots. backup._safe_copy_db already uses
  the SQLite backup API rather than a byte copy.
- The mechanism is POSIX-specific (Windows byte-range locks are handle-scoped,
  not process-scoped), so this is a Linux/macOS correctness fix; the change is
  platform-neutral and safe on Windows.

Sibling tests updated: session recovery no longer expects a DELETE-mode
recovered DB, and the kanban WAL-fallback test patches the connect site that
actually runs now.
2026-07-25 21:44:43 -07:00
teknium1
ec2a0f8c1e fix(sessions): point failed in-place repair at offline recovery
A failed `hermes sessions repair` printed "keep state.db and the backup"
and stopped, so a user whose sessions had vanished had no way to discover
that a non-destructive recovery path exists — the reported dead end.

The failure branch now names the next command, read-only step first, seeded
with the backup path it just preserved. Covered by a CLI-surface regression
test that drives the real subprocess against an unrepairable database.
2026-07-25 16:42:31 -07:00
Gille
a9b8128bcb fix(sessions): add offline state database recovery 2026-07-25 16:42:31 -07:00