Skip to content

fix(kanban): stop corruption amplification + spurious quarantines in the DB health guard - #41795

Closed
jamesraddock wants to merge 11 commits into
NousResearch:mainfrom
jamesraddock:fix/kanban-db-health-check-ttl
Closed

fix(kanban): stop corruption amplification + spurious quarantines in the DB health guard#41795
jamesraddock wants to merge 11 commits into
NousResearch:mainfrom
jamesraddock:fix/kanban-db-health-check-ttl

Conversation

@jamesraddock

@jamesraddock jamesraddock commented Jun 8, 2026

Copy link
Copy Markdown

What does this PR do?

Hardens the Kanban SQLite health guard against three failure modes we hit in production (WSL2, multi-worker dispatch). The three commits build on each other and ship together because the first one alone is actively harmful (details below).

1. b4a98f74-equivalent — TTL re-probe (stops the corruption amplification loop)

_guard_existing_db_is_healthy() runs PRAGMA integrity_check on connect, but skipped it for the entire process lifetime once a path was in _INITIALIZED_PATHS. That set conflates "schema is migrated" (genuinely once-per-process) with "the DB is healthy" (can change after first connect). When the file was later torn (e.g. an interrupted WAL checkpoint), a long-lived writer that first connected while healthy never re-checked — it kept checkpointing the damaged file, compounding corruption on every cycle. Observed: one torn write snowballed into ~20 quarantine copies (~150 MB) in ~10 minutes with the board hard-down (HTTP 409) throughout.

Fix: decouple health from schema-init; re-probe on a 30s TTL so writers notice post-init corruption within one window.

2. Confirm across N probes before quarantining (stops spurious quarantines)

With the TTL re-probe in place, long-lived processes probe far more often — and a read/write integrity_check under concurrent writers on a weak-durability FS (WSL2 virtual disk, no DPO/FUA) can transiently read a mid-checkpoint page as bad while the DB is fine. Observed: ~45 spurious .corrupt.bak copies of a healthy, progressing board in 20 minutes. This is why the TTL commit must not ship alone.

Fix: require _HEALTH_CONFIRM_ATTEMPTS = 3 consecutive non-ok probes (100 ms backoff) before quarantining. Real corruption reproduces on every probe; a transient clears on retry. The healthy path still costs exactly one probe.

3. Classify all-IOERR integrity failures as transient I/O, not corruption

Under sustained load the probe⇄checkpoint race can outlive the whole confirmation loop. The tell: every failure line is unable to get the page. error code=522 (SQLITE_IOERR_SHORT_READ) — the read failed, not the content is malformed. Observed: 3 quarantines of an integrity_check = ok board in 90 seconds, with the confirm loop active.

Fix: when every confirming probe fails and the report contains only IOERR-family lines (error code & 0xFF == 10, plus the Page N: never used accounting noise from unvisited pages), raise sqlite3.OperationalError — same contract as the existing lock/busy path: no quarantine copy, no fail-closed. Any content-damage line (is malformed, missing from index, …), any non-IOERR code, or any unexpected report shape still quarantines and fails closed.

Related Issue

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_cli/kanban_db.py
    • _LAST_HEALTH_OK TTL cache (30s) replaces lifetime health caching; stamp evicted on detected damage so subsequent connects keep re-probing
    • _run_integrity_probe() extracted; OperationalError (lock/busy) propagates raw as before — never classified as corruption
    • _HEALTH_CONFIRM_ATTEMPTS = 3 confirmation loop with backoff before any quarantine
    • _integrity_failure_is_transient_io() — strict classifier for all-IOERR reports → OperationalError instead of quarantine
  • tests/hermes_cli/test_kanban_db.py — 5 regression tests covering: TTL honored then re-probed, lone transient probe failure ignored, persistent corruption still quarantined, persistent SHORT_READ raises OperationalError with no quarantine, and classifier edge cases (mixed damage+IOERR, non-IOERR codes, refused-open). Full file: 219 passed.

Testing

python -m pytest tests/hermes_cli/test_kanban_db.py
219 passed

Also soak-tested on the live multi-worker board that originally reproduced all three failure modes: zero spurious quarantines since, real-corruption path verified by injected malformed file.

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have labels Jun 8, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Code Review: Positive Verification

Reviewed: hermes_cli/kanban_db.py — health check TTL to prevent corruption amplification.

Findings: Well-designed fix for a real corruption amplification bug. The previous one-shot lifetime cache in _INITIALIZED_PATHS meant a long-lived writer that connected while healthy would never re-check, silently amplifying corruption from interrupted checkpoints.

Design is sound:

  • _LAST_HEALTH_OK dict with 30s TTL bounds the full integrity probe window
  • Cheap SQLite header check still runs on every connect (not affected by TTL)
  • Cache is invalidated on remove_board, init_db, and corruption detection
  • _backup_corrupt_db still runs on first corruption detection

Test coverage: test_health_guard_honors_ttl_cache_then_reprobes covers the full lifecycle — healthy probe → TTL cache → post-corruption re-probe within window → TTL expiry → fail-closed on re-probe. monkeypatch on time.monotonic avoids flaky timing.

No issues found. LGTM.

@jamesraddock jamesraddock changed the title fix(kanban): re-probe DB health on a TTL to stop the corruption amplification loop fix(kanban): stop corruption amplification + spurious quarantines in the DB health guard Jun 9, 2026
@jamesraddock

Copy link
Copy Markdown
Author

Pushed two follow-up commits after further production soak (multi-worker dispatch on WSL2):

  • e2d0a6d2 — require 3 consecutive non-ok probes before quarantining. The TTL re-probe alone increased probe frequency in long-lived processes, and a single r/w integrity_check under concurrent writers can transiently misread a mid-checkpoint page — we measured ~45 spurious .corrupt.bak copies of a healthy board in 20 min. Reviewers should consider the original TTL commit incomplete without this one.
  • 88cd0fcf — classify all-IOERR integrity reports (unable to get the page. error code=522 = SQLITE_IOERR_SHORT_READ) as transient I/O → raise OperationalError like the lock/busy path instead of quarantining. The probe⇄checkpoint race can outlive the confirmation loop under sustained load; a failed read is not malformed content. Genuine damage reports ("is malformed", "missing from index", non-IOERR codes) still quarantine + fail closed.

PR description updated to cover the full stack; 219/219 tests pass.

@jamesraddock

Copy link
Copy Markdown
Author

One more layer found in production soak, pushed as ceeb58a6: under checkpoint contention the r/w probe can also fail with a hard SQLITE_CORRUPT (database disk image is malformed) on open/first-read that clears on the next read — identical string to real damage, so it can't be pattern-matched as transient like the IOERR case. The r/w probe participates in WAL recovery; a read-only connection doesn't, and has never false-positived in our monitoring. So when the confirmation loop exhausts, a mode=ro PRAGMA integrity_check now gets the last word: ro-ok → OperationalError (no quarantine), ro-confirms-damage → quarantine + fail closed exactly as before. Lock/busy during the ro probe counts as undecided, never as evidence of corruption. 221/221 tests.

@jamesraddock

Copy link
Copy Markdown
Author

bb99b1f1 — production falsified my previous claim: the read-only arbiter false-positived too (the quarantined copy itself later checked out integrity_check=ok). It samples at the hottest moment by construction — right after every r/w probe failed — and even ro opens can transiently report SQLITE_CORRUPT there.

The discriminator that has held across every observed failure shape is persistence: real corruption is permanent; probe⇄checkpoint contention clears within seconds. The decision window is now multi-second instead of ~300ms: 4 r/w attempts with exponential backoff (~2s), then the arbiter's damage verdict must repeat across 3 ro attempts spaced 1s apart — one clean/undecided read anywhere means transient (OperationalError, no quarantine, warning logged for visibility). Healthy DBs still cost exactly one probe; only the suspected-corrupt path sleeps.

Heads-up for reviewers: the corrupt-file tests now pay the real decision window, so test_kanban_db.py runs ~80s instead of ~25s. 222/222 pass.

@jamesraddock

Copy link
Copy Markdown
Author

Pushed two more commits after another production soak on the WSL2 host — these invert the probe design rather than adding a fifth countermeasure:

8466a2f71 — probe read-only-first; the read/write probe is gone.
Every false-positive shape this PR has chased (single torn read, IOERR storm, hard SQLITE_CORRUPT at open, hottest-moment arbiter flicker) traced back to one cause: the scheduled probe opened the DB read/write, which participates in WAL recovery/checkpointing and therefore races every concurrent writer. Meanwhile a read-only probe at a random moment (which is exactly what a TTL expiry is) produced zero false positives across the same period — months of 5-minute cron sampling plus all manual checks.

So the guard now runs ONE read-only probe at TTL expiry with a tri-state verdict:

  • ok → stamp, return (one cheap probe on the healthy path, same as before);
  • undecided (lock/busy, a hot WAL a ro connection can't recover, an all-IOERR report) → fail open, no stamp: callers no longer see spurious OperationalError, the next connect re-probes, and a genuinely bad DB still surfaces through cell_size_check=ON + the write_txn page-count invariant;
  • damage → must repeat on every spaced probe across a multi-second window before quarantining (real corruption is permanent; contention clears in seconds).

This subsumes the confirm-N loop and the separate ro arbiter (both deleted) while keeping the TTL re-probe that stops corruption amplification and the IOERR-family classifier (now feeding the undecided verdict). Net: −226/+187 lines, and the corrupt-path tests run faster because there's one persistence window instead of two stacked ones.

984f5012cwal_autocheckpoint 100 → 1000.
Every checkpoint rewrites main-DB pages — that's both the torn-write window on a no-DPO/FUA virtual disk and the contention window the probes race. 100 (from 99c19eb) forced a checkpoint per ~400KB written, i.e. near-constant churn under a dispatcher swarm. 1000 is the SQLite default, now asserted explicitly; durability is unchanged since synchronous=FULL still fsyncs the WAL at commit.

Both deployed on the affected host (gateway + web UI restarted). Suite: 222/222.

@jamesraddock

jamesraddock commented Jun 10, 2026

Copy link
Copy Markdown
Author

This is still in testing and may cross over to webui. May be particular to users using WSL

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed production investigation and the focused regression coverage. The underlying lifetime-cache premise is still present on current main, but this needs a current-main-aware salvage.

Problems

  • Current main added an initialized-path fast return at hermes_cli/kanban_db.py:1722-1737 after this branch diverged. It returns before _guard_existing_db_is_healthy(), while this PR's TTL changes only affect the slow initialization path. Therefore post-init connects would still skip the re-probe.
  • Current main also sets wal_autocheckpoint=100 in that fast path at hermes_cli/kanban_db.py:1730. The PR changes only the initialization-path assignment, so the requested 1000-page setting would not apply to normal subsequent connections.

Suggested changes

  • Integrate the bounded health check with the current fast path and add an end-to-end connect() regression covering an initialized database after TTL expiry.
  • Set and test wal_autocheckpoint on both connection paths.

Automated hermes-sweeper review.

Comment thread hermes_cli/kanban_db.py
# checkpointing an already-corrupt file, compounding the damage on every cycle
# while new/cold connects correctly fail closed. Re-probe on a short TTL so a
# writer notices post-init corruption within one window instead of never.
_LAST_HEALTH_OK: dict[str, float] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main added an _INITIALIZED_PATHS fast return before the guard (hermes_cli/kanban_db.py:1722-1737). This cache is therefore never consulted for ordinary post-init connect() calls after a salvage. Please wire the TTL check into that branch and cover it through connect(), not only a direct guard call.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
jamesraddock and others added 8 commits July 14, 2026 12:59
…e process lifetime

The integrity probe in `_guard_existing_db_is_healthy` was skipped for the
entire process lifetime once a path entered `_INITIALIZED_PATHS`. That set
conflates two different facts: "schema is migrated" (genuinely once per
process) and "the DB is healthy" (which can change *after* first connect).

Consequence is a corruption *amplification* loop. When the main DB file is
torn — e.g. an interrupted WAL checkpoint under WSL2 / abrupt VM stop — a
long-lived writer (kanban worker, gateway) that first connected while healthy
never re-checks. It keeps opening and checkpointing the damaged file,
compounding the damage on every cycle and producing a fresh
content-addressed `.corrupt.<token>.bak` each time, while new/cold connects
correctly fail closed. Observed in the wild: ~20 distinct quarantine copies
(~150 MB) generated in ~10 minutes from a single initial torn write, with the
board unusable (every kanban API 409) until the live DB was restored by hand.

Fix: track health separately from schema-init, with a short TTL.
- `_LAST_HEALTH_OK: dict[str, float]` records the monotonic time of the last
  "ok" probe; the full probe is skipped only within `_HEALTH_CHECK_TTL_SECONDS`
  (30s). The cheap header check in `connect()` still runs on every connect.
- A successful probe records the stamp; a failed probe evicts it before
  raising, so subsequent connects keep failing closed.
- `_LAST_HEALTH_OK` is cleared alongside the existing `_INITIALIZED_PATHS`
  evictions (board delete, init_db).

Because workers open a fresh connection per operation (`connect_closing`), the
next operation after the TTL elapses fails closed instead of writing — capping
the blast radius to ~one TTL window rather than "until the process exits".

Adds a regression test covering record-on-ok, skip-within-TTL, and
re-probe-and-fail-closed-after-TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single PRAGMA integrity_check failure under concurrent writers on a
no-FUA disk (WSL2 virtual disk) can be a transient mid-checkpoint read,
not real corruption. The read/write guard probe was treating any one
non-ok result as definitive, producing spurious .corrupt.bak storms of a
healthy, progressing board (~45 copies in 20 min observed live) and
failing worker connects closed for no reason.

Require _HEALTH_CONFIRM_ATTEMPTS consecutive non-ok probes (brief backoff
between) before quarantining. Real corruption reproduces on every probe;
a transient torn read clears on retry. Healthy path still costs one probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not corruption

PRAGMA integrity_check racing a concurrent WAL checkpoint reports
'unable to get the page. error code=522' (SQLITE_IOERR_SHORT_READ) —
the read failed, the content is not malformed. Under sustained worker
load that race outlives the N-probe confirmation loop, so the guard
still quarantined a healthy, progressing board (observed live: 3
.corrupt.bak of an 'ok' DB in 90s, every failure line an IOERR).

When every confirming probe fails and the report contains only
IOERR-family unreadable-page lines (plus the 'never used' noise that
follows from unvisited pages), raise sqlite3.OperationalError like the
lock/busy case instead of quarantining + failing closed. Any content-
damage line, non-IOERR code, or unexpected shape still quarantines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under checkpoint contention the read/write integrity probe can fail
with a hard SQLITE_CORRUPT ('database disk image is malformed') on
open/first-read that clears on the very next read — the r/w probe
participates in WAL recovery, so it races concurrent checkpoints in
ways a reader does not. That string is identical to real damage, so it
cannot be pattern-matched as transient (unlike the all-IOERR case).
Observed live: quarantines of an integrity_check=ok board minutes
apart, with the N-probe confirmation loop and IOERR classifier active.

When the confirmation loop exhausts and the failure is not classified
transient-I/O, run PRAGMA integrity_check over a mode=ro connection as
the final arbiter: a read-only view takes snapshot semantics, stays
out of recovery/checkpointing, and (empirically, a day of 5-minute
read-only cron probes plus every manual check) has never
false-positived. ro says ok -> raise OperationalError like the
lock/busy path, no quarantine; ro confirms damage (which real
corruption always does) -> quarantine and fail closed as before.
Lock/busy during the ro probe counts as undecided, never as evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…milliseconds

The read-only arbiter also false-positived in production: it samples at
the hottest possible moment — immediately after every r/w probe failed —
and even a read-only open can transiently report SQLITE_CORRUPT there.
The quarantined copy itself later checked out integrity_check=ok, as did
the live board (which kept progressing throughout).

The discriminator that has held across every observed failure shape is
persistence: real corruption is permanent, while probe⇄checkpoint
contention clears within a second or two. So stretch the decision
window from ~300ms to multi-second:

- confirmation loop: 4 attempts with exponential backoff (0.15/0.45/
  1.35s, ~2s total)
- read-only arbiter: damage verdict must repeat across 3 attempts
  spaced 1s apart; a single clean or undecided read anywhere in the
  window means transient (OperationalError, no quarantine)
- transient outcomes now log a warning so contention episodes stay
  visible without minting quarantine copies

Only the suspected-corrupt path ever sleeps; a healthy DB still costs
exactly one probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…probe

The guard's scheduled integrity probe opened the DB read/write so SQLite
could replay a hot WAL before checking. Under concurrent writers on a
weak-durability FS that probe false-positived four distinct ways (single
torn read, persistent IOERR storm, hard SQLITE_CORRUPT at open that
cleared on the next read, and even the hottest-moment ro arbiter) — each
needing its own countermeasure layered on top of the last. Meanwhile a
read-only probe at a *random* moment (which is what a TTL expiry is)
never produced a false positive across the same period.

Invert the design instead of adding a fifth countermeasure:

- The TTL probe is now READ-ONLY (mode=ro): it stays out of WAL
  recovery/checkpointing, cannot tear anything, and does not contend
  with writers. The only read/write open left is the real connection.
- Tri-state verdict: ok → stamp and return. undecided (lock/busy, a hot
  WAL a ro connection cannot recover, or an all-IOERR report) → fail
  OPEN without stamping — no error, no quarantine; the next connect
  re-probes, and the connection itself surfaces real damage anyway
  (cell_size_check=ON, write_txn page-count invariant). damage → must
  persist across every spaced probe in a multi-second window before
  quarantining: real corruption is permanent, contention clears in
  seconds.
- Callers no longer see spurious OperationalError from
  suspected-transient episodes; those are logged warnings now.

This subsumes the confirm-N read/write loop and the separate read-only
arbiter while keeping the IOERR-family classifier (now used to mark a
ro report undecided) and the TTL re-probe that stops corruption
amplification. The suite gets faster too: the quarantine path pays one
~3s persistence window instead of confirm-loop + arbiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every checkpoint rewrites main-DB pages — exactly the torn-write window
on a weak-durability FS (WSL2 virtual disk: write cache enabled, no
DPO/FUA), and the contention window integrity probes race against. 100
forced a checkpoint roughly every ~400KB written, i.e. near-constant
churn under a dispatcher swarm on an event-heavy board; that churn is
what every observed spurious-corruption shape raced against. 1000 (the
SQLite default, now asserted explicitly) means ~10x fewer checkpoint
events. Durability is unchanged: synchronous=FULL still fsyncs the WAL
at commit, so a crash replays from the WAL regardless of when the last
checkpoint ran.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fast path

connect()'s _INITIALIZED_PATHS fast path (the NousResearch#36644 no-cross-process-lock
optimization) returned before _guard_existing_db_is_healthy() and set
wal_autocheckpoint=100. So the TTL health re-probe and the 1000-page
checkpoint setting only ever applied to the first-open init path — a
long-lived writer (the gateway dispatcher) took the fast path on every tick
and thus never re-probed and checkpointed ~10x more often than intended,
exactly the amplification the TTL guard and the raise to 1000 were meant to
stop.

Wire the bounded health check into the fast path: call the read-only,
TTL-gated guard before the r/w open (a dict lookup on a cache hit; it takes
no cross-process lock, so it does not reintroduce the NousResearch#36644 stall), and set
wal_autocheckpoint=1000 there too so both paths match.

Adds two end-to-end connect() regressions: the fast path re-probes after TTL
expiry (and fails closed on persistent damage), and wal_autocheckpoint is
1000 on both the init and already-initialized paths. Both fail without the
change.

Addresses maintainer review on NousResearch#41795.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jamesraddock
jamesraddock force-pushed the fix/kanban-db-health-check-ttl branch from 984f501 to baaa8fd Compare July 14, 2026 17:02
@jamesraddock

Copy link
Copy Markdown
Author

Good catch, and thanks for the current-main-aware framing. Pushed a fix and rebased the branch onto current main so the line numbers line up with your review.

Worth noting the fast-path bypass was actually latent in the branch itself, not only in the merge-with-main view: the _INITIALIZED_PATHS fast return already sat before _guard_existing_db_is_healthy() and set wal_autocheckpoint=100, so on the steady-state path the TTL re-probe and the 1000-page setting never applied — which defeats the whole point for the long-lived dispatcher this PR targets.

Changes

  • Health check wired into the fast path. connect() now calls _guard_existing_db_is_healthy(path) before the read/write open on the _INITIALIZED_PATHS branch. The guard is read-only and TTL-gated, so on a cache hit it's a dict lookup, and it takes no cross-process init lock — so it does not reintroduce the [Bug]: Kanban dispatcher hangs forever on an unbounded fcntl.flock(LOCK_EX) in kanban_db.connect() under concurrent board access #36644 stall this fast path exists to avoid. Running it before the open means a confirmed-damage verdict quarantines and raises with no connection to unwind.
  • wal_autocheckpoint=1000 on both paths. The fast path now matches the init path (was 100).

Tests (both fail without the change)

  • test_connect_fast_path_reprobes_health_after_ttl — drives it end to end through connect() on an already-initialized DB: within the TTL no re-probe, past the TTL exactly one re-probe, and a persistent-damage verdict fails the connect closed.
  • test_connect_sets_wal_autocheckpoint_1000_on_both_paths — asserts 1000 on both the init and already-initialized connects.

The 7 original commits are unchanged in content (just replayed onto current main), with the fast-path fix as the commit on top.

@jamesraddock

Copy link
Copy Markdown
Author

@teknium1 — both points are addressed and the branch is rebased onto current main (new commit baaa8fdee; details in the comment above). GitHub won't let me formally re-request review from a fork PR, so tagging you here — would you mind taking another look when you get a chance? 🙏

@jamesraddock

Copy link
Copy Markdown
Author

Author note for maintainers — please hold the wal_autocheckpoint change in this stack before merging.

Two commits here raise wal_autocheckpoint from 100 to 1000 (both connect paths) and rename the regression test to assert 1000. Field data plus a follow-up investigation on my install now argue against shipping that as-is.

What happened in the field. On 2026-07-15, ~6.5h after 1000 first went live fleet-wide — on my heaviest process-churn day on record — the shared kanban board corrupted three times in one afternoon (wrong # of entries in index idx_events_*, quarantined and auto-restored from verified snapshots each time). The board had run clean at 100 before that.

What the investigation showed (~120 controlled trials + artifact forensics). The simple causal story does not survive scrutiny in either direction:

  • No process-level condition corrupted a live DB at either value: concurrent writers, SIGKILL mid-checkpoint, and naive-copy races all left the live DB intact every trial.
  • The production damage signature is index entries ahead of the table with an empty WAL — mixed-generation pages that a completed checkpoint cannot produce and that a crash would have left a WAL to repair. The strongest remaining suspects are environmental (the Windows host drive backing the WSL2 vhdx was 99% full at the time — since fixed) or checkpoint bookkeeping, not burst size.
  • In a fleet dominated by short-lived CLI/webui connections, checkpoint cadence is driven mostly by last-connection-close checkpoints, which run a full checkpoint regardless of this pragma — so the value matters much less than either in-tree comment assumed.

What I run now, and propose for this PR: wal_autocheckpoint=100 on both call sites as an explicitly-labeled precaution (not a mechanism-backed fix), a test asserting the two paths agree so the value can only change deliberately in both places at once, and quarantine-time forensics (WAL captured before the main-file copy; incident-context JSONL) so the next occurrence is actually diagnosable.

Happy to push the updated commits to this branch — flagging it first so the current diff doesn't merge with the 1000 assertion locked in by the test.

@jamesraddock

Copy link
Copy Markdown
Author

Follow-up: the corruption is now REPRODUCED and the 100-vs-1000 question is settled experimentally.

I built a churn harness (~830 real short-lived connect() processes over 150s + a long-lived ticker, ~10% SIGKILLed, page-cache eviction between integrity checks) against a throwaway board with the real schema. Results across 20 ablation arms:

  • wal_autocheckpoint=1000: corrupts within ~1 minute, every run, with the exact production signature (wrong # of entries in index idx_events_task/idx_events_run, index/table count deltas of 1–2, rowid-out-of-order cells).
  • wal_autocheckpoint=100: clean under identical load — same churn, same kills, same eviction.
  • SIGKILL and cache eviction are NOT required; plain sqlite3 connections at 1000 under the same load are clean, and the damage is byte-identical read through the page cache vs after eviction — so this is not a WSL2/storage artifact but an interaction in our own connect path: the health guard's read-only integrity probe + a fresh rw connection + the migration pass's BEGIN IMMEDIATE write transaction, executed per new process, corrupts at 1000 and is safe at 100. Removing any one ingredient (or running at 100) prevents it.

So the earlier request stands, now with direct evidence rather than field correlation: the two commits raising this to 1000 (and the test asserting 1000) must not merge. I'll push the 100-both-paths commits to this branch. Independent of the pragma value, the durable fix is to stop running the migration write-transaction on every process start when there is nothing to migrate — that removes the trigger entirely; I'll follow up with that change once it's validated against the same harness.

jamesraddock and others added 3 commits July 16, 2026 22:48
…st path

Local stopgap, not pushed to PR NousResearch#41795. Raising the fast path to 1000
(d228f239b, deployed 2026-07-15 11:27) removed the long-lived writers'
small-checkpoint pacemaker: gateway/webui steady-state connects set the
effective WAL ceiling for the whole fleet, and at 1000 pages checkpoints
became ~4MB writebacks 10x rarer. On this WSL2 no-FUA vhdx each writeback
is the torn-write window: three real index-count corruptions on task_events
followed within six hours, on a board that ran a month clean at 100 and at
6x lighter load than its heaviest clean day (2026-07-13, 7634 events).
Keep the init path at 1000 and the fast-path health guard as-is; only the
steady-state autocheckpoint returns to 100. The read-only-first guard fails
open on contention, so small-checkpoint churn no longer mints probe FPs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on both connect paths

- _backup_corrupt_db captures WAL/SHM bytes BEFORE the main-file hash+copy
  pass (every 2026-07-15 quarantine recorded a 0-byte WAL because sidecars
  were copied last; the WAL is the primary forensic artifact)
- confirmed quarantines append incident context (full integrity report,
  MemAvailable, / and /mnt/c free space, fd holders + cmdlines) to
  <board>/quarantine-forensics.jsonl
- init path now matches the fast path at wal_autocheckpoint=100 and both
  folk-theory comments are replaced: the value is a labeled precaution,
  not a root-cause fix (analysis: ~/kanban-db-investigation/REVIEW.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing to backfill

_migrate_add_optional_columns runs on every process's first connect, and
its unconditional BEGIN IMMEDIATE was one leg of a reproducible
corruption trigger: health probe + fresh rw connection + early write
transaction, under process churn at wal_autocheckpoint=1000, corrupts a
live board in under a minute (churn harness, ~830 real connect processes
per 150s run; see the reproduction matrix posted on PR NousResearch#41795). Probe
with a plain SELECT first and only open the write transaction when a
legacy running-task row actually needs a task_runs backfill. The
re-SELECT inside the transaction is unchanged, so a row appearing
between probe and txn is still handled; legacy rows can only pre-exist
anyway (new claims always set current_run_id).

Validated: the harness arms that corrupted before (all1000, migrateonly)
run clean with this change; kanban_db suite 240/240.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jamesraddock

Copy link
Copy Markdown
Author

Pushed as promised (append-only): adaf10ec1 restores the 100-page fast-path value, 21c9f1081 reconciles BOTH call sites to 100 (replacing the earlier justification comments, which did not survive testing, with the evidence-backed rationale) + adds quarantine-time forensics, and 5fcf6d41c removes the corruption trigger itself — the migration pass no longer opens its BEGIN IMMEDIATE backfill transaction on every process start when there is nothing to backfill. The regression test now asserts both connect paths agree at 100. With the last commit, the churn harness runs clean even at wal_autocheckpoint=1000; the 100 value stays as defense-in-depth.

@teknium1

Copy link
Copy Markdown
Contributor

Merged via #68654. The backup-retention and guard-improvement ideas from your PR informed #68654 (retention cap, narrow auto-REINDEX). The TTL re-probe half didn't survive the rebase onto the current fast path; if you want to rebase that half onto current main as a focused PR, it's still welcome.

@teknium1 teknium1 closed this Jul 21, 2026
@jamesraddock

jamesraddock commented Jul 21, 2026 via email

Copy link
Copy Markdown
Author

@jamesraddock

Copy link
Copy Markdown
Author

Follow-up root-cause evidence is now tracked in #69784.

SQLite has documented a WAL-reset corruption race affecting the 3.50.4 library embedded by Hermes' current uv-managed Python. The documented trigger (multi-process WAL writers plus overlapping checkpoints/WAL reset) and failure shape match this investigation.

Using the same migwritetxnall1000 churn arm:

  • SQLite 3.50.4: two 150s runs ended corrupt, with 14 and 18 mid-run anomalies and the same idx_events_task / idx_events_run disagreement seen in production.
  • fixed SQLite 3.51.3: 837 workers, 38 SIGKILLs, zero anomalies, clean cached and post-eviction final checks.

That changes the root-cause priority: the guard/quarantine work here and the recovery work merged through #68654 remain valuable defense-in-depth, but they cannot prevent vulnerable SQLite from creating the damage. The upstream issue asks Hermes to ship/select a fixed SQLite runtime or gate WAL on vulnerable versions. No need to reopen this PR; linking it so the investigation history stays intact.

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

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants