Skip to content

hermes_state: opt-in in-place self-heal for a lost WAL generation (fixes #109687) - #109766

Open
Sahilvishnaliya wants to merge 1 commit into
NousResearch:mainfrom
Sahilvishnaliya:fix/109687-wal-generation-self-heal
Open

Sahilvishnaliya wants to merge 1 commit into
NousResearch:mainfrom
Sahilvishnaliya:fix/109687-wal-generation-self-heal

Conversation

@Sahilvishnaliya

Copy link
Copy Markdown
Contributor

Fixes #109687

The problem

A short-lived reader (one sqlite3.connect() + close, e.g. hermes sessions stats) unlinks the live gateway writer's state.db-wal/-shm on Linux. The gateway keeps serving while committing into the unlinked inode — session writes are silently discarded — and the shipped deleted-WAL guard then fails every other process closed. The halt is sticky for the life of the writer process: the only remediation is an operator killing all holders.

What this adds

The issue's second explicit ask — "a writer that does lose its generation should be able to recover without operator intervention" — as an opt-in self-heal, database.wal_self_heal: true (default OFF; the fail-closed posture is unchanged for unflagged installs):

  1. Capture the retired frames durably via the existing capture_retired_wal_generation machinery (WAL generation split-brain: DeletedWalGenerationError guarded writes, but the graceful-shutdown checkpoint then corrupted state.db (field report) #105670) — nothing that was committed to the orphaned WAL is lost; the artifact stays for inspection.
  2. Close this handle's orphaned descriptors with SQLite's close-time checkpoint disabled (SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE); if the checkpoint cannot be suppressed (Python < 3.12), the heal fails closed and the shipped pin path stays.
  3. Reopen through refuse_deleted_wal_generation exactly like a fresh process — if any other process still holds an orphaned sidecar, the guard refuses and the canonical DeletedWalGenerationError stands. The heal never mints a second WAL while the box is not clear.
  4. Adopt the current generation (identity re-recorded, sticky halt flags cleared) and resume writes.

Read-pool hygiene: pooled read connections minted before the heal hold fds into the orphaned generation; an epoch allowlist set at heal time evicts them at checkout (_fresh_read_conns), so no stale descriptor ever serves reads afterwards.

Design notes / honesty

Tests

tests/hermes_state/test_wal_generation_self_heal.py — 7 tests:

test platform covers
test_flag_defaults_off all flag OFF unless configured
test_heal_off_keeps_fail_closed_halt all unflagged halt never attempts the heal
test_heal_attempted_then_sticky_when_it_cannot_heal all capture failure → sticky refusal stands
test_heal_refuses_when_guard_detects_foreign_holders all guard refusal, descriptor closed, sticky halt
test_heal_adopts_current_generation_and_resumes all full heal: reopen, adopt, writes resume, read-pool epoch
test_pool_evicts_pre_heal_read_conns all pre-heal pooled reader is evicted at checkout
test_heal_recovers_writer_without_operator Linux (skipif) real unlink → second generation → heal end-to-end

…usResearch#109687)

A short-lived reader's clean close unlinks the live gateway writer's
-wal/-shm sidecars on Linux; the writer keeps serving while committing
into the unlinked inode, and the shipped fail-closed halt is sticky for
the life of the process. The capture machinery (NousResearch#105670) already
preserves the retired frames; this adds the recovery the issue asks for:
under database.wal_self_heal (default OFF, fail-closed posture
unchanged), a halted writer captures the retired generation, drops its
orphaned descriptors with the close-time checkpoint disabled, reopens
through refuse_deleted_wal_generation exactly like a fresh process, and
adopts the current generation — writes resume without operator
intervention. Read-pool connections minted before the heal are evicted
at checkout so no orphaned fd serves reads.

Developed on Windows: cross-platform logic tests cover the gating,
sticky-halt fallback, guard refusal, full adopt-and-resume flow and
pool eviction; the real unlink/adopt end-to-end test is Linux-only
(skipif), same gate as the existing deleted-WAL guard tests.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

2 participants