fix(state): surface corrupt state.db as one degraded session-storage state (#120274)

* fix(state): publish structural state.db corruption as one profile-level state

A structurally corrupt state.db showed up differently on every surface: the
sidebar endpoint returned 200 with empty slices plus an errors row, /api/sessions
returned 500, /api/status said components.storage ok and readiness was green.
None of them said the store was damaged, so Desktop rendered it as deleted
history (#72046).

hermes_state_health is now the single latch, keyed by resolved state.db path:

- SessionDB._halt_db_corrupt, the SessionDB read helpers, the web profile
  reader and the readiness probe publish into it, only for structural
  corruption (not FTS-scoped damage, not the malformed-schema case the web
  open path heals).
- gateway.readiness reports it (state_db degraded/corrupt, and session_store
  unavailable/corrupt even when the handle cache says ok), which also feeds
  /api/status components.storage (now with reason: corrupt).
- /api/sessions, /api/profiles/sessions and /api/profiles/sessions/sidebar
  carry storage: {profile: "corrupt"}; /api/sessions returns 503
  state_db_corrupt instead of 500.
- A peer SessionDB handle in the same process refuses writes on a latched
  path with the existing StateDbCorruptError, so gateway/agent transcript
  diversion and classify_persistence_error keep working unchanged.

The latch never clears on its own and resets on restart, the recovery boundary
StateDbCorruptError already documents.

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>

* fix(desktop): say the session store is damaged instead of an empty sidebar

The sidebar reads the list endpoints' new storage map into
$corruptSessionStores and renders a persistent destructive Alert above the
session list naming the affected profile(s). The copy says missing chats were
not deleted and points at the non-destructive path (quit Hermes, then
`hermes sessions recover --source <state.db> --inspect-only` or restore a
snapshot) plus the recovery guide; it does not recommend `sessions repair`
for structural damage.

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>

---------

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
This commit is contained in:
Austin Pickett
2026-09-23 10:17:46 -04:00
committed by GitHub
parent 5c6b56a866
commit bd945ec384
17 changed files with 582 additions and 7 deletions

View File

@@ -218,3 +218,26 @@ describe('listSidebarSessions remote ownership', () => {
expect(result.recents.sessions[0]).toMatchObject({ connection_id: 'prometheus', id: 'remote-session' })
})
})
describe('listSidebarSessions storage health', () => {
it('passes the backend corrupt-store map through so the sidebar can say why it is empty', async () => {
hermesApi.mockResolvedValue({
cron: { sessions: [] },
errors: [{ error: 'database disk image is malformed', profile: 'default' }],
messaging: { sessions: [] },
recents: { sessions: [] },
storage: { default: 'corrupt' }
} as never)
const result = await listSidebarSessions({
recentsProfile: 'all',
recentsLimit: 40,
recentsExclude: [],
cronLimit: 20,
messagingLimit: 40,
messagingExclude: []
})
expect(result.storage).toEqual({ default: 'corrupt' })
})
})

View File

@@ -204,6 +204,9 @@ export interface SidebarSessionsResponse {
cron: SidebarSessionSlice
messaging: SidebarSessionSlice
errors?: Array<{ profile: string; error: string }>
/** `{profile: 'corrupt'}` for each profile whose state.db the backend has found
* structurally damaged. Absent from older backends. */
storage?: Record<string, 'corrupt'>
}
export interface SidebarSessionsRequest {
@@ -252,7 +255,10 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<S
const cronErrors = cron.errors ?? []
const messagingErrors = messaging.errors ?? []
const storage = { ...recents.storage, ...cron.storage, ...messaging.storage }
return {
...(Object.keys(storage).length ? { storage } : {}),
recents: {
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
sessions: recents.sessions,
@@ -343,7 +349,8 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
sessions: stampActiveConnectionOwner(result.messaging?.sessions ?? []),
...(result.errors?.length ? { errors: result.errors } : {})
},
errors: result.errors
errors: result.errors,
storage: result.storage
}
}

View File

@@ -181,7 +181,8 @@ import {
SidebarBlankState,
SidebarLoadErrorState,
SidebarPinnedEmptyState,
SidebarSessionSkeletons
SidebarSessionSkeletons,
SidebarStorageCorruptNotice
} from './section-states'
import { buildSessionByAnyId, resolvePinnedSessions } from './session-index'
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
@@ -1687,6 +1688,8 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
<SidebarStorageCorruptNotice />
{showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField

View File

@@ -1,8 +1,14 @@
import { useStore } from '@nanostores/react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Skeleton } from '@/components/ui/skeleton'
import { useI18n } from '@/i18n'
import { openExternalLink } from '@/lib/external-link'
import { AlertTriangle, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $corruptSessionStores } from '@/store/session'
import { SidebarRowCluster, SidebarRowShell, SidebarRowStack } from './chrome'
@@ -73,3 +79,44 @@ export function SidebarLoadErrorState({ onRetry }: { onRetry: () => void }) {
</div>
)
}
const SESSION_STORAGE_RECOVERY_URL =
'https://hermes-agent.nousresearch.com/docs/user-guide/session-storage-recovery#when-the-three-steps-do-not-work'
// A structurally corrupt state.db empties (or thins out) the list below it,
// which reads as deleted history (#72046). Persistent while the backend
// reports the store corrupt; there is nothing to dismiss until it is recovered.
export function SidebarStorageCorruptNotice() {
const profiles = useStore($corruptSessionStores)
const { t } = useI18n()
const copy = t.sidebar.storageCorrupt
if (profiles.length === 0) {
return null
}
return (
<div className="shrink-0 px-2 pb-1 pt-1">
<Alert className="gap-x-2 px-3 py-2 text-xs" data-testid="storage-corrupt-notice" variant="destructive">
<AlertTriangle />
<AlertTitle className="line-clamp-none">{copy.title}</AlertTitle>
<AlertDescription>
<p>{copy.body(profiles.join(', '))}</p>
<p>{copy.action}</p>
<code className="break-all text-[0.7rem]">
hermes sessions recover --source &lt;state.db&gt; --inspect-only
</code>
<Button
className="-ml-1 mt-0.5 text-(--ui-text-secondary)"
onClick={() => openExternalLink(SESSION_STORAGE_RECOVERY_URL)}
size="sm"
variant="ghost"
>
<ExternalLink />
{copy.guide}
</Button>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $corruptSessionStores, setCorruptSessionStores } from '@/store/session'
import { SidebarStorageCorruptNotice } from './section-states'
const openExternalLink = vi.fn()
vi.mock('@/lib/external-link', () => ({ openExternalLink: (href: string) => openExternalLink(href) }))
beforeEach(() => {
$corruptSessionStores.set([])
openExternalLink.mockClear()
})
afterEach(cleanup)
// A structurally corrupt state.db used to render as "No sessions yet" (#72046):
// the list went empty and nothing said the store was damaged.
describe('SidebarStorageCorruptNotice', () => {
it('renders nothing while every store is healthy', () => {
setCorruptSessionStores({})
render(<SidebarStorageCorruptNotice />)
expect(screen.queryByTestId('storage-corrupt-notice')).toBeNull()
})
it('names the damaged profile and gives the non-destructive recovery path', () => {
setCorruptSessionStores({ default: 'corrupt' })
render(<SidebarStorageCorruptNotice />)
const notice = screen.getByTestId('storage-corrupt-notice')
expect(notice.getAttribute('role')).toBe('alert')
expect(notice.textContent).toContain('Session database is damaged')
expect(notice.textContent).toContain('for default')
expect(notice.textContent).toContain('were not deleted')
expect(notice.textContent).toContain('hermes sessions recover --source <state.db> --inspect-only')
// No blanket "run repair" advice: structural damage goes to inspect/restore first.
expect(notice.textContent).not.toContain('sessions repair')
screen.getByRole('button', { name: /recovery guide/i }).click()
expect(openExternalLink).toHaveBeenCalledWith(expect.stringContaining('/user-guide/session-storage-recovery'))
})
it('keeps atom identity when a refresh reports the same stores', () => {
setCorruptSessionStores({ work: 'corrupt', default: 'corrupt' })
const first = $corruptSessionStores.get()
setCorruptSessionStores({ default: 'corrupt', work: 'corrupt' })
expect($corruptSessionStores.get()).toBe(first)
expect(first).toEqual(['default', 'work'])
setCorruptSessionStores(undefined)
expect($corruptSessionStores.get()).toEqual([])
})
})

View File

@@ -28,6 +28,7 @@ import {
keepFailedProfileMeta,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
setCorruptSessionStores,
setCronSessions,
setMessagingPlatformTotals,
setMessagingSessions,
@@ -294,6 +295,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
) {
const recents = result.recents
setCorruptSessionStores(result.storage)
// Drop rows the user just deleted/archived: a refresh can race an
// in-flight mutation and the backend page still carries the doomed row.
// Honoring the optimistic tombstone keeps the removal from flashing back

View File

@@ -3088,6 +3088,13 @@ export const en: Translations = {
projectEmpty: 'No sessions yet',
projectLoadFailed: 'Could not load sessions',
noSessions: 'No sessions yet',
storageCorrupt: {
title: 'Session database is damaged',
body: (profiles: string) =>
`Hermes can't read all of the session history for ${profiles}. Chats missing from this list were not deleted; the file they are stored in is damaged.`,
action: 'Quit Hermes on this profile, then inspect the file without changing it, or restore a snapshot:',
guide: 'Recovery guide'
},
noFilterMatches: 'No sessions match these filters',
projects: {
showAllSessions: 'Show all sessions',

View File

@@ -2609,6 +2609,12 @@ export interface Translations {
projectEmpty: string
projectLoadFailed: string
noSessions: string
storageCorrupt: {
title: string
body: (profiles: string) => string
action: string
guide: string
}
noFilterMatches: string
projects: {
showAllSessions: string

View File

@@ -929,6 +929,11 @@ export interface ProfileUsage {
}
export const $sessionProfilesUsage = atom<Record<string, ProfileUsage>>({})
/** Profiles whose state.db the backend reports as structurally corrupt (the list
* endpoints' `storage` map, #72046). An empty or partial list for one of these
* is a damaged store, not deleted history, and the sidebar says so. */
export const $corruptSessionStores = atom<string[]>([])
export const $sessionsLoading = atom(true)
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
@@ -1296,6 +1301,20 @@ export const setSessionProfilesTruncated = (next: Updater<Record<string, boolean
export const setSessionProfilesUsage = (next: Updater<Record<string, ProfileUsage>>) =>
updateAtom($sessionProfilesUsage, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
/** Publish the corrupt-store profiles from one sidebar refresh; identity-stable when unchanged. */
export function setCorruptSessionStores(storage: Record<string, string> | undefined) {
const next = Object.keys(storage ?? {})
.filter(profile => storage?.[profile] === 'corrupt')
.sort()
const prev = $corruptSessionStores.get()
if (prev.length !== next.length || prev.some((profile, i) => profile !== next[i])) {
$corruptSessionStores.set(next)
}
}
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredIdRotation = (next: Updater<ActiveSessionStoredIdRotation | null>) =>
updateAtom($activeSessionStoredIdRotation, next)

View File

@@ -496,6 +496,8 @@ export interface PaginatedSessions {
/** Per-profile read failures from the cross-profile aggregator (e.g. a locked
* or corrupt state.db). Present only on `/api/profiles/sessions`. */
errors?: Array<{ profile: string; error: string }>
/** `{profile: 'corrupt'}` for each listed profile whose state.db is structurally damaged. */
storage?: Record<string, 'corrupt'>
}
export interface SessionCreateResponse {

View File

@@ -22,9 +22,19 @@ def _check(status: str, detail: str | None = None, **extra: Any) -> dict[str, An
def _probe_state_db(home: Path) -> dict[str, Any]:
"""Read-only schema probe plus the process-wide corruption latch (``hermes_state_health``).
The schema read only catches an unreadable header or schema; damage deeper in the file
surfaces when a reader or writer touches it, and those publish into the latch. Reporting
the latch here is what makes readiness and ``/api/status`` agree with the session list
(#72046). ``detail="corrupt"`` is the one reason string consumers key off."""
from hermes_state_health import STORAGE_CORRUPT, note_storage_error, storage_state
path = home / "state.db"
if not path.exists():
return _check("ok", "not initialized")
if storage_state(path) == STORAGE_CORRUPT:
return _check("degraded", STORAGE_CORRUPT)
try:
# Read-only schema query: catches unreadable/corrupt DBs without competing with
# writers. ``closing`` is required — sqlite3's context manager only commits/rolls
@@ -35,6 +45,8 @@ def _probe_state_db(home: Path) -> dict[str, Any]:
conn.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone()
return _check("ok")
except Exception as exc:
if note_storage_error(path, exc):
return _check("degraded", STORAGE_CORRUPT)
return _check("degraded", type(exc).__name__)
@@ -71,7 +83,10 @@ def _probe_gateway(runtime_status: dict[str, Any]) -> dict[str, Any]:
def _probe_session_store(runtime_status: dict[str, Any], state_db_probe: dict[str, Any]) -> dict[str, Any]:
"""Report the running gateway cache state, not an independent reopen."""
"""Report the running gateway cache state, not an independent reopen. A corrupt store is
unavailable whatever the cache says: an open handle on a damaged file is not a working one."""
if state_db_probe.get("detail") == "corrupt":
return _check("unavailable", "corrupt")
runtime_store = runtime_status.get("session_store")
state = str(runtime_store.get("status") or "unknown") if isinstance(runtime_store, dict) else ""
if state in {"ok", "unavailable", "retrying"}:

View File

@@ -39,6 +39,7 @@ from hermes_cli.web_server_profiles import (
_fallback_profile_dicts, _hub_action_name, _write_profile_mcp_servers,
)
from hermes_cli.web_server_sessions import _open_session_db_at_path
from hermes_state_health import STORAGE_CORRUPT, note_storage_error, storage_state
from starlette.concurrency import run_in_threadpool
from hermes_cli.web_models import (
ProfileCreate, ProfileActiveUpdate, ProfileExport, ProfileImport, ProfileRename,
@@ -268,6 +269,8 @@ def _read_profile_db(name: str, home, errors: Optional[List[Dict[str, str]]],
db = _open_session_db_at_path(db_path, read_only=True)
return fn(db)
except Exception as exc:
# An open that dies on a damaged file never reaches SessionDB's read helpers.
note_storage_error(db_path, exc)
_warn_profile_read_error(name, exc)
if errors is not None:
errors.append({"profile": name, "error": str(exc)})
@@ -277,6 +280,14 @@ def _read_profile_db(name: str, home, errors: Optional[List[Dict[str, str]]],
db.close()
def _corrupt_profile_stores(targets) -> Dict[str, str]:
"""``{profile: "corrupt"}`` for every scanned profile whose state.db this process has latched
as structurally corrupt (``hermes_state_health``). Lets Desktop tell an empty or partial list
from a damaged store, including when some reads still succeed (#72046)."""
return {name: STORAGE_CORRUPT for name, home in targets
if storage_state(Path(home) / "state.db") == STORAGE_CORRUPT}
# Sidebar scan cache TTL: short enough that the UI never shows meaningfully stale data, long
# enough to coalesce the desktop's reconnect/focus/change poll bursts into one scan.
_SIDEBAR_CACHE_TTL_SECONDS = 5.0
@@ -447,7 +458,8 @@ def get_profiles_sessions(
if not full:
_strip_session_list_rows(window)
return {"sessions": window, "total": sum(totals.values()), "profile_totals": totals,
"limit": limit, "offset": offset, "errors": errors}
"limit": limit, "offset": offset, "errors": errors,
"storage": _corrupt_profile_stores(targets)}
@sessions_router.get("/api/profiles/sessions/sidebar")
@@ -499,9 +511,11 @@ def get_profiles_sessions_sidebar(
_sidebar_profile_cache_put(cache_key, slices)
return slices
scanned = []
for name, home in targets:
if recents_scope != "all" and name != recents_scope:
continue
scanned.append((name, home))
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
@@ -534,7 +548,7 @@ def get_profiles_sessions_sidebar(
"profiles_usage": profile_totals},
"cron": {"sessions": _window("cron")},
"messaging": {"sessions": _window("messaging"), "total": len(rows["messaging"])},
"errors": errors}
"errors": errors, "storage": _corrupt_profile_stores(scanned)}
def _merge_by_id(into: Dict[str, Dict[str, Any]], entries: List[Dict[str, Any]], child_key: str) -> None:

View File

@@ -22,9 +22,10 @@ from hermes_cli.web_server_gateway import _strip_session_list_rows
from hermes_cli.web_server_sessions import _maybe_auto_archive_for_profile, _session_latest_descendant
from hermes_cli.web_models import (
BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename)
from hermes_cli.web_routers._common import log as _log, destructive_profile, http_failure
from hermes_cli.web_routers._common import CORRUPT_STORE_DETAIL, log as _log, destructive_profile, http_failure
from hermes_state import is_malformed_db_error
from hermes_state_errors import is_transient_sqlite_error
from hermes_state_health import STORAGE_CORRUPT, note_storage_error, storage_state
list_router = APIRouter()
search_router = APIRouter()
@@ -33,6 +34,7 @@ manage_router = APIRouter()
_cron_default_profile = late("_cron_default_profile", "hermes_cli.web_server_cron")
_cron_profile_home = late("_cron_profile_home", "hermes_cli.web_server_cron")
_open_session_db_for_profile = late("_open_session_db_for_profile", "hermes_cli.web_server_sessions")
_session_db_path_for_profile = late("_session_db_path_for_profile", "hermes_cli.web_server_sessions")
_NOT_FOUND = "Session not found"
@@ -219,7 +221,11 @@ def get_sessions(
s["pinned"] = bool(s.get("pinned"))
if not full:
_strip_session_list_rows(sessions)
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
# ``storage`` tells an empty page apart from an unreadable store (#72046); same
# ``{profile: "corrupt"}`` shape as the /api/profiles/sessions* lists.
storage = {row_profile: STORAGE_CORRUPT} if storage_state(db.db_path) == STORAGE_CORRUPT else {}
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset,
"storage": storage}
finally:
db.close()
except HTTPException:
@@ -236,6 +242,14 @@ def get_sessions(
if transient
else "Internal server error"),
) from exc
except sqlite3.DatabaseError as exc:
# A damaged store is unavailable, not empty and not an internal error (#72046).
db_path = _session_db_path_for_profile(profile)
if not (note_storage_error(db_path, exc) or is_malformed_db_error(exc)):
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error") from exc
_log.error("GET /api/sessions: state.db at %s is corrupt: %s", db_path, exc)
raise HTTPException(status_code=503, detail=dict(CORRUPT_STORE_DETAIL)) from exc
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -407,6 +407,9 @@ async def _component_health(gateway: Dict[str, Any]) -> Dict[str, Any]:
from gateway.readiness import _probe_state_db
storage_check = await run_in_threadpool(_probe_state_db, get_hermes_home())
components["storage"] = {"status": storage_check.get("status", "degraded")}
# The one reason enum consumers key off; same latch as readiness and the session lists.
if storage_check.get("detail") == "corrupt":
components["storage"]["reason"] = "corrupt"
except Exception:
components["storage"] = {"status": "degraded"}
# ``disabled`` entries are platforms the multiplexer deliberately does not run for a served profile

View File

@@ -33,6 +33,9 @@ from hermes_state_common import (
escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity,
)
from hermes_state_holders import read_only_db_uri
from hermes_state_health import (
STORAGE_CORRUPT, mark_storage_corrupt, note_storage_error, storage_corrupt_reason, storage_state,
)
from hermes_state_errors import (
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
@@ -958,6 +961,13 @@ class SessionDB(
ioerr_begin_retried = False
while True:
self._raise_if_db_corrupt()
if storage_state(self.db_path) == STORAGE_CORRUPT:
# Another handle in this process already saw structural damage on this file.
# Quarantine this one before it touches SQLite; the error type is the same
# StateDbCorruptError, so every transcript-diversion owner handles it unchanged.
self._halt_db_corrupt(sqlite3.DatabaseError(
"database disk image is malformed (reported earlier in this process: "
f"{storage_corrupt_reason(self.db_path)})"))
# NOTE: the replaced/generation live probe runs INSIDE the lock below,
# not here. close() mutates _conn and _db_sidecar_identity under that
# same lock, ending the WAL generation (SQLite unlinks the -wal/-shm
@@ -1098,8 +1108,14 @@ class SessionDB(
return fn(conn)
except sqlite3.OperationalError as exc:
if attempt >= _READ_ONLY_IOERR_RETRY_ATTEMPTS or _DISK_IO_ERROR_MARKER not in str(exc).lower():
note_storage_error(self.db_path, exc)
raise
time.sleep(_READ_ONLY_IOERR_RETRY_BACKOFF_S)
except sqlite3.DatabaseError as exc:
# A reader is often the only observer (the sidebar poll on a store nobody is
# writing to): publish structural damage so the list is not read as empty.
note_storage_error(self.db_path, exc)
raise
def _ensure_db_file_generation(self) -> None:
"""Mint a once-per-file generation stamp (state_meta + application_id). First opener wins (INSERT
@@ -1272,6 +1288,9 @@ class SessionDB(
"""Quarantine this handle and raise; never run in-file repair here."""
self._db_corrupt = True
self._db_corrupt_reason = str(exc)
# Publish the profile-level state first: readiness, /api/status and the session list
# endpoints read it, and every other handle in this process refuses writes on it.
mark_storage_corrupt(self.db_path, exc)
self._disable_close_time_checkpoint()
logger.error(
"state.db %s reported structural corruption outside the FTS "

114
hermes_state_health.py Normal file
View File

@@ -0,0 +1,114 @@
"""Profile-level session-storage health: one process-wide latch per state.db path.
A structurally corrupt state.db used to show up as an empty or partial Desktop sidebar,
green readiness and a 500 from ``/api/sessions``: every surface guessed on its own and
none said "the store is damaged" (#72046). This module is the single place that fact is
recorded. Writers (``SessionDB._halt_db_corrupt``), readers (``SessionDB`` read helpers)
and the readiness probe publish into it; ``gateway.readiness`` (``state_db`` /
``session_store`` checks, hence ``/api/status`` ``components.storage``) and the session
list endpoints read from it, so Desktop and readiness cannot disagree.
Only structural corruption latches: bare ``SQLITE_CORRUPT`` / ``SQLITE_NOTADB`` with no
FTS provenance. FTS-scoped damage has its own fail-open path with canonical rows intact,
and a malformed-schema row is healed by the web open path, so neither is reported here.
The latch never clears on its own. A corrupt image does not heal, and a store that
flickers between "ok" and "corrupt" is the silent failure this replaces. It resets when
the process restarts, which is the recovery boundary ``StateDbCorruptError`` already
documents (stop Hermes, recover or restore, start again). No marker is written to disk:
the file it would describe is the one that is damaged.
"""
from __future__ import annotations
import logging
import sqlite3
import threading
from pathlib import Path
from typing import Dict, Optional
from hermes_state_errors import (
classify_persistence_error,
is_fts_scoped_corruption_error,
is_malformed_schema_error,
)
logger = logging.getLogger(__name__)
STORAGE_OK = "ok"
STORAGE_CORRUPT = "corrupt"
_lock = threading.Lock()
_corrupt: Dict[str, str] = {} # resolved db path -> first error text (log only, never served)
def _key(db_path) -> str:
return str(Path(db_path).expanduser().resolve(strict=False))
def is_structural_corruption_error(exc: BaseException) -> bool:
"""Canonical B-tree/schema/freelist damage: a corrupt/NOTADB error SQLite does not scope to
the FTS index, and not the malformed-schema case the web open path repairs."""
return (
isinstance(exc, sqlite3.DatabaseError)
and not is_fts_scoped_corruption_error(exc)
and not is_malformed_schema_error(exc)
and classify_persistence_error(exc) == "corrupt"
)
def mark_storage_corrupt(db_path, reason: object) -> None:
"""Latch *db_path* as corrupt for the life of this process (idempotent)."""
key = _key(db_path)
with _lock:
if key in _corrupt:
return
_corrupt[key] = str(reason)
logger.error(
"state.db at %s is structurally corrupt (%s); session storage is reported as corrupt "
"until Hermes restarts on a recovered or restored file. Stop Hermes, then run "
"`hermes sessions recover --source %s --inspect-only` or restore a snapshot.",
db_path, reason, db_path,
)
def note_storage_error(db_path, exc: BaseException) -> bool:
"""Latch *db_path* when *exc* is structural corruption; True when it was."""
if not is_structural_corruption_error(exc):
return False
mark_storage_corrupt(db_path, exc)
return True
def storage_state(db_path) -> str:
"""``"corrupt"`` once this process has seen structural corruption on *db_path*, else ``"ok"``."""
with _lock:
return STORAGE_CORRUPT if _key(db_path) in _corrupt else STORAGE_OK
def storage_corrupt_reason(db_path) -> Optional[str]:
"""The first error text latched for *db_path* (logs/diagnostics only), or None."""
with _lock:
return _corrupt.get(_key(db_path))
def reset_storage_state(db_path=None) -> None:
"""Forget the latch for *db_path* (all paths when None). For tests and a verified in-process
recovery; nothing in the runtime clears it on its own."""
with _lock:
if db_path is None:
_corrupt.clear()
else:
_corrupt.pop(_key(db_path), None)
__all__ = [
"STORAGE_CORRUPT",
"STORAGE_OK",
"is_structural_corruption_error",
"mark_storage_corrupt",
"note_storage_error",
"reset_storage_state",
"storage_corrupt_reason",
"storage_state",
]

View File

@@ -0,0 +1,221 @@
"""A structurally corrupt state.db is published once and read the same way everywhere (#72046).
Before this, a damaged store showed up as an empty Desktop sidebar (200 + an ``errors`` row),
a 500 from ``/api/sessions``, ``components.storage: ok`` on ``/api/status`` and green
readiness. Each surface guessed; none said the store was damaged, so it read as deleted
history. ``hermes_state_health`` is now the one latch: readers, writers and the readiness
probe publish into it, and every surface below reads it.
"""
import sqlite3
import pytest
from hermes_state import SessionDB, StateDbCorruptError
from hermes_state_errors import classify_persistence_error
from hermes_state_health import (
is_structural_corruption_error,
note_storage_error,
reset_storage_state,
storage_state,
)
@pytest.fixture(autouse=True)
def _fresh_latch():
reset_storage_state()
yield
reset_storage_state()
def _seed(db_path, count=40):
db = SessionDB(db_path=db_path)
try:
for i in range(count):
sid = f"s{i:03d}"
db.create_session(sid, source="cli")
db.append_message(session_id=sid, role="user", content="hello " * 50)
finally:
db.close()
def _corrupt_sessions_btree(db_path):
"""Overwrite the root page of the ``sessions`` table: real B-tree damage, not a mock."""
conn = sqlite3.connect(db_path)
try:
if conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal":
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
root = conn.execute("SELECT rootpage FROM sqlite_master WHERE name = 'sessions'").fetchone()[0]
finally:
conn.close()
with open(db_path, "r+b") as fh:
fh.seek(page_size * (root - 1))
fh.write(b"\xde\xad\xbe\xef" * (page_size // 4))
# ── The latch itself ───────────────────────────────────────────────────────
class TestLatch:
def test_only_structural_corruption_latches(self, tmp_path):
path = tmp_path / "state.db"
assert note_storage_error(path, sqlite3.OperationalError("database is locked")) is False
assert note_storage_error(path, sqlite3.DatabaseError("malformed database schema (messages_fts)")) is False
fts = sqlite3.DatabaseError("database disk image is malformed")
fts.sqlite_errorcode = 267 # SQLITE_CORRUPT_VTAB: FTS-scoped, has its own fail-open path
assert note_storage_error(path, fts) is False
assert storage_state(path) == "ok"
assert note_storage_error(path, sqlite3.DatabaseError("database disk image is malformed")) is True
assert storage_state(path) == "corrupt"
def test_not_a_database_is_structural(self):
assert is_structural_corruption_error(sqlite3.DatabaseError("file is not a database"))
def test_a_real_read_on_a_damaged_btree_latches(self, tmp_path):
path = tmp_path / "state.db"
_seed(path)
_corrupt_sessions_btree(path)
db = SessionDB(db_path=path, read_only=True)
try:
with pytest.raises(sqlite3.DatabaseError):
db.list_sessions_rich(limit=50, offset=0)
finally:
db.close()
assert storage_state(path) == "corrupt"
class TestPeerWritesAfterLatch:
"""A second handle in the same process must not keep writing to a file another handle
already found damaged, and its refusal must be the SAME terminal error the gateway and
agent flush already divert on (not a new class they would retry)."""
def test_fresh_handle_refuses_writes_with_state_db_corrupt_error(self, tmp_path):
path = tmp_path / "state.db"
_seed(path, count=1)
note_storage_error(path, sqlite3.DatabaseError("database disk image is malformed"))
peer = SessionDB(db_path=path)
try:
with pytest.raises(StateDbCorruptError) as excinfo:
peer.append_message(session_id="s000", role="user", content="after the latch")
assert classify_persistence_error(excinfo.value) == "corrupt"
assert peer._db_corrupt is True
finally:
peer.close()
conn = sqlite3.connect(path)
try:
contents = [row[0] for row in conn.execute("SELECT content FROM messages")]
finally:
conn.close()
assert "after the latch" not in contents
def test_quarantine_on_one_handle_publishes_the_latch(self, tmp_path):
path = tmp_path / "state.db"
db = SessionDB(db_path=path)
real = db._conn
class _Malformed:
def execute(self, *a, **k):
raise sqlite3.DatabaseError("database disk image is malformed")
def __getattr__(self, name):
return getattr(real, name)
try:
db.create_session("s1", source="cli")
db._conn = _Malformed()
with pytest.raises(StateDbCorruptError):
db.create_session("s2", source="cli")
finally:
db._conn = real
db.close()
assert storage_state(path) == "corrupt"
# ── Readiness ──────────────────────────────────────────────────────────────
def test_readiness_state_db_and_session_store_agree_on_corrupt(_isolate_hermes_home):
from gateway.readiness import collect_runtime_readiness
from hermes_constants import get_hermes_home
path = get_hermes_home() / "state.db"
_seed(path, count=1)
healthy = collect_runtime_readiness(configured_model="m", runtime_status={"session_store": {"status": "ok"}})
assert healthy["checks"]["state_db"]["status"] == "ok"
note_storage_error(path, sqlite3.DatabaseError("database disk image is malformed"))
ready = collect_runtime_readiness(configured_model="m", runtime_status={"session_store": {"status": "ok"}})
assert ready["status"] == "degraded"
assert ready["checks"]["state_db"] == {"status": "degraded", "detail": "corrupt"}
# The gateway's handle cache says "ok"; an open handle on a damaged file is not a working store.
assert ready["checks"]["session_store"] == {"status": "unavailable", "detail": "corrupt"}
def test_readiness_probe_latches_a_file_that_is_not_a_database(_isolate_hermes_home):
from gateway.readiness import _probe_state_db
from hermes_constants import get_hermes_home
home = get_hermes_home()
(home / "state.db").write_bytes(b"this is not sqlite" * 512)
assert _probe_state_db(home) == {"status": "degraded", "detail": "corrupt"}
assert storage_state(home / "state.db") == "corrupt"
# ── Desktop-facing HTTP surfaces, end to end on a real damaged file ─────────
@pytest.fixture
def client(monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_cli.web_routers import profiles as profiles_routes
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
from hermes_constants import get_hermes_home
monkeypatch.setattr(profiles_routes, "_SIDEBAR_CACHE_TTL_SECONDS", 0.0)
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
(get_hermes_home() / "config.yaml").write_text("{}\n", encoding="utf-8")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def test_corrupt_state_db_is_published_to_every_desktop_surface(client):
from hermes_constants import get_hermes_home
path = get_hermes_home() / "state.db"
_seed(path)
healthy = client.get("/api/profiles/sessions/sidebar?recents_profile=all").json()
assert len(healthy["recents"]["sessions"]) > 0
assert healthy["storage"] == {}
assert client.get("/api/sessions").json()["storage"] == {}
assert client.get("/api/status").json()["components"]["storage"] == {"status": "ok"}
_corrupt_sessions_btree(path)
# The sidebar used to be the only surface that noticed, as an empty 200 plus an errors row.
sidebar = client.get("/api/profiles/sessions/sidebar?recents_profile=all")
assert sidebar.status_code == 200
body = sidebar.json()
assert body["recents"]["sessions"] == []
assert body["storage"] == {"default": "corrupt"}
# The flat list says "unavailable", not "internal server error".
flat = client.get("/api/sessions")
assert flat.status_code == 503
assert flat.json()["detail"]["error"] == "state_db_corrupt"
# Status (what Desktop polls) and readiness now say the same thing.
status = client.get("/api/status").json()
assert status["components"]["storage"] == {"status": "degraded", "reason": "corrupt"}
assert status["overall"] == "degraded"
assert client.get("/api/profiles/sessions?profile=all").json()["storage"] == {"default": "corrupt"}