Skip to content

fix(state): warn when an existing database's journal_mode is flipped to WAL - #89393

Open
jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/89293-warn-silent-journal-mode-upgrade
Open

fix(state): warn when an existing database's journal_mode is flipped to WAL#89393
jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/89293-warn-silent-journal-mode-upgrade

Conversation

@jackulau

Copy link
Copy Markdown
Contributor

What does this PR do?

apply_wal_with_fallback() treats an on-disk WAL database as authoritative and says so twice:

Never downgrades to DELETE if the on-disk DB header reports WAL

Existing on-disk WAL databases were returned above and are never live-downgraded.

The mirror case has no protection at all. When the on-disk mode is DELETE and the configured mode is wal, the function flips the database to WAL and logs nothing. journal_mode is a property of the file, so that rewrites the header and persists after the process exits.

That matters because setting the mode directly on the file is a thing operators actually do — it was the documented mitigation for the SQLite 3.50.4 WAL-reset bug. There is a config key that makes the choice durable (database.journal_mode, #68545), but nothing tells an operator it exists at the moment their PRAGMA is being undone.

This adds a single deduped WARNING on that flip. It is log-only: the flip still happens, the return value is unchanged, and the never-live-downgrade rule is untouched.

Related Issue

Refs #89293

Item 1 of that report ("journal_mode silently reverted to WAL after upgrade", 4 of 5 databases) is this code path, and the reporter's own suggested remedy is what this implements:

An upgrade should preserve the operator's explicit journal_mode choice (or at least warn when re-enabling WAL after the operator deliberately disabled it).

The "after upgrade" framing is exact, and the mechanism is worth stating because it explains why this went unnoticed for so long. Before the upgrade the deployment linked SQLite 3.50.4, so is_sqlite_wal_reset_vulnerable() was true and apply_wal_with_fallback short-circuited into _apply_delete_for_wal_reset_bug — which kept DELETE and never reached the flip. Upgrading to 3.53.1 turned that gate off, and the flip path went live on every database routed through this helper. response_store.db stayed DELETE because it is the one store that does not route through it.

This does not close #89293. That report is a four-part causal chain (oversized DB → cron lock storm → restart inside the lock window → WAL-reset amplifier); the other parts belong to #84277, #89088 and #88604, which the reporter already cites. This is item 1 only.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

hermes_state.py

  • _database_has_content(conn) — module-level helper, PRAGMA page_count > 0. A header read; no lock, no cost. Fail-quiet: any error answers False.
  • _log_journal_mode_upgrade_once(db_label, previous_mode) — mirrors the existing _log_wal_fallback_once / _log_wal_reset_bug_once idiom (module-level set + lock, deduped per process per db_label), with its own _journal_upgrade_warned_paths / _journal_upgrade_warned_lock pair.
  • apply_wal_with_fallback — computes _upgrading_existing_db before the pragma (both inputs are only readable while the file is still in its original state), and emits the warning at both points where the switch actually succeeds: the normal path and the disk i/o error retry path.

Two judgment calls, stated rather than buried

WARNING, not ERROR. The reverse direction is ERROR (_log_wal_fallback_once) because dropping to DELETE is a real loss of concurrency. This direction is normally the desirable one — managed_uv._default_live_venv treats a database stuck on DELETE as a bug worth repairing on update, citing ~2600x slower state.db appends. So the message reports a change and names the durable lever without claiming a degradation that is not there. That is also why this does not prevent the flip: preventing it would fight a deliberate design decision, and the reporter did not ask for that.

The page_count guard is the load-bearing half. A brand-new database reports journal_mode=delete (SQLite's default) and is about to be switched to WAL — from current_mode alone that is indistinguishable from the reported bug. Every opener applies WAL before creating schema (SessionDB._connect_and_init calls apply_wal_with_fallback, then _init_schema), so without this guard the warning would fire on the first run of every install. Four of the fourteen tests exist for this one condition.

tests/test_journal_mode_upgrade_warning.py — new, 14 tests.

How to Test

pytest tests/test_journal_mode_upgrade_warning.py -q     # 14 passed

All behavioural — real sqlite3 on tmp files and caplog, matching test_journal_mode_config.py's existing idiom (_configure_mode / _disable_vulnerable_gate). No mocking at the boundary under test.

Mutation proof — every property is independently load-bearing:

Reverted Tests that fail
the warning call removed from the flip 4 — every "warns" test
page_count guard removed (treat all as existing) 1 — test_a_brand_new_database_is_silent
dedup removed 1 — test_it_fires_once_per_process_per_database
database.journal_mode dropped from the message 1 — test_the_warning_names_the_setting_that_makes_it_stick
also warn on the configured-delete path 1 — test_configured_delete_is_silent

Baselinepytest tests/test_journal_mode_config.py tests/test_hermes_state_wal_fallback.py tests/test_sqlite_wal_reset_gate.py tests/test_wal_checkpoint_strategy.py tests/test_conftest_wal_gate.py tests/state/ tests/test_hermes_state.py -q -p no:randomly, run serially, with and without the change:

Result
without the change (stashed) 1 failed, 395 passed
with the change 1 failed, 395 passed

Byte-identical, as a log-only change should be. The one failure is pre-existing and unrelated — tests/test_hermes_state.py::TestFTS5Search::test_search_projection_skips_context_enrichment_queries (assert 0 == 1); it fails the same way on a clean 9664e386f. The 14 new tests are additional to those counts.

Overlap with open PRs

hermes_state.py is busy and the journal-mode area especially so, so I read the neighbours rather than assuming:

PR What it is Relationship
#85609 (open) warns when a configured journal_mode=delete is overridden by an existing on-disk WAL The exact mirror of this, and the closest neighbour. Both warning sites there are gated on configured == "delete"; this one only fires when the configured mode is wal. Theirs covers "your config lost to the file", this covers "the file lost to a default you never set" — which is the case where the operator never touched config at all. Disjoint conditions, no shared line.
#87044, #87612 (open) represent an indeterminate journal-mode probe as None/delete so the WAL read pool is not enabled on an unconfirmed WAL Different question (what _wal_active should be when the probe fails). Neither adds or removes a journal-mode switch.
#84277 (open) PASSIVE instead of TRUNCATE for all state.db checkpoints Cause ② / ③ of #89293's chain, and the reporter cites it as such. Different function, different failure.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate — see the Overlap table; fix(state): warn when configured journal_mode=delete is overridden by on-disk WAL #85609 in particular is the mirror case and is called out explicitly
  • My PR contains only changes related to this fix (one commit, rebased on main)
  • I've run the journal-mode and state-store slices with and without the change, serially (see How to Test). I did not run pytest tests/ -q wholesale: on Windows tests/hermes_cli/ can't be collected (test_doctor_journal_modes.py calls os.geteuid), so a full-suite number from here would be meaningless. CI runs it.
  • I've added tests for my changes — 14, with the mutation proof above
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • I've updated relevant documentation — docstrings only; both new helpers document why the warning is WARNING rather than ERROR and why the content probe fails quiet
  • N/A — no config keys added or changed. database.journal_mode already exists (state.db corruption on macOS virtiofs: checkpoint_fullfsync no-ops in Linux containers; request a configurable, centralized journal_mode #68545); this change only names it in a message
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact — PRAGMA page_count is a portable header read, and the change adds no filesystem assumptions. The macOS/NFS paths (_apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full, the silent-refusal branch) are untouched and still run in the same order
  • N/A — no tool description or schema change

…to WAL

apply_wal_with_fallback treats an on-disk WAL database as authoritative and
says so twice: it never live-downgrades one. The mirror case had no
protection at all. When the on-disk mode is DELETE and the configured mode
is wal, the function flips the database and logs nothing.

journal_mode is a property of the FILE, so that rewrites the header and
persists after the process exits. Setting the mode directly on the file is
something operators do; it was the documented mitigation for the SQLite
3.50.4 WAL-reset bug. A config key that makes the choice durable already
exists (database.journal_mode, NousResearch#68545), but nothing named it at the moment
the PRAGMA was being undone.

NousResearch#89293 reports the cost: after upgrading past the vulnerable SQLite,
is_sqlite_wal_reset_vulnerable() stopped short-circuiting into
_apply_delete_for_wal_reset_bug, the flip path went live, and 4 of 5
databases silently returned to WAL with no log line anywhere.

Add a deduped WARNING at both points where the switch succeeds, decided
before the pragma runs since both inputs are only readable while the file is
still in its original state. Log-only: the flip still happens, the return
value is unchanged, and the never-live-downgrade rule is untouched.

WARNING rather than ERROR is deliberate. The reverse direction is ERROR
because dropping to DELETE costs concurrency; this direction is normally the
desirable one (managed_uv treats a database stuck on DELETE as a bug worth
repairing on update). The problem was never the change, it was that the
change was invisible.

The page_count guard is the load-bearing half. A brand-new database also
reports journal_mode=delete and is also about to be switched to WAL, and
every opener applies WAL before creating schema, so without it the warning
would fire on the first run of every install.

Refs NousResearch#89293
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/config Config system, migrations, profiles area/sessions Session lifecycle, resume, persistence, history P2 Medium — degraded but workaround exists 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 Aug 18, 2026
Comment thread hermes_state.py
_upgrading_existing_db = (
current_mode is not None
and current_mode != "wal"
and _database_has_content(conn)

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.

This is the load-bearing half. A brand-new file also reports journal_mode=delete and is about to be switched to WAL, so current_mode alone cannot tell “operator choice” from “SQLite default”. page_count is the right discriminator given every opener I checked still applies WAL before schema.

Fail-quiet on probe error (False) is the correct bias: a false warning on every fresh install would be worse than a rare missed line.

Comment thread hermes_state.py
mode = str(row[0]).strip().lower() if row and row[0] is not None else ""
if mode == "wal":
if _upgrading_existing_db:
_log_journal_mode_upgrade_once(db_label, current_mode)

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.

Correct door: only after the pragma actually returns wal. The EIO-retry path later in this function has the same check, which is the other place the header rewrite can succeed.

Residual, not a request: this is still log-only. The flip has already happened by the time this fires, so a deployment that does not read the log still lands back on WAL — which is the intended policy, just worth not mistaking for a preserve-the-choice fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and I would rather that residual be stated in the thread than inferred from the diff, so: yes, this is log-only by choice, and it is not a preserve-the-choice fix. The flip still happens, the return value is unchanged, and a deployment that never reads the log lands back on WAL exactly as it does today.

Two reasons it stops there.

The first is policy. Auto-upgrading a stuck-on-DELETE database looks deliberate rather than accidental: hermes_cli/managed_uv._default_live_venv treats DELETE as a state worth repairing on update, citing the append cost. Preventing the flip here would quietly overturn that from the storage layer, and #89293 asked for "preserve or at least warn". Warning is the half a contributor can land without making the policy call for a maintainer.

The second is that preserving the choice is not currently expressible at this call site, which I think is the more useful thing for whoever picks that up. resolve_journal_mode() returns a resolved "wal" for four different situations: the operator wrote journal_mode: wal, they wrote something invalid, the config could not be read at all (bare except Exception), or the key is simply absent. Only the last one is "nobody chose this". So a preserve-the-choice version needs config provenance (was the key present), not just its value, and then a second decision that is squarely a maintainer's: what happens to the installs that have already been flipped, whose on-disk WAL is now indistinguishable from a WAL somebody wanted.

The warning does not depend on either of those, which is why it is separable and why I kept it separable.

Comment thread hermes_state.py
"If %s was a deliberate choice (for example the mitigation for the "
"SQLite WAL-reset bug, or a WAL-unsafe filesystem), setting it with "
"PRAGMA on the file will not survive -- every open re-applies the "
"configured mode. Set `database.journal_mode: delete` in config.yaml "

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.

Naming database.journal_mode is the part that makes the warning useful. File-level PRAGMA is what #89293 used as the 3.50.4 mitigation, and it cannot survive the next open; this is the lever that can.

@zhanglingfei112

Copy link
Copy Markdown

升级前日志证据核查(回应您的问题)

您好,我来确认升级前(3.50.4 时期)的日志情况。直接回答您抛回的问题——结论:WAL-reset notice 在升级前确实大量出现,而 unsupported on this filesystem 那句从未出现。

升级前(3.50.4)实际记录到的提示

日志里反复出现的是这条 WAL-reset 警告(不是 unsupported on this filesystem):

WARNING hermes_state: state.db (async_delegation): linked SQLite 3.50.4
is vulnerable to the WAL-reset corruption bug (...) — using journal_mode=DELETE
instead of enabling WAL. Upgrade to SQLite 3.51.3+ ... This warning fires once per process per database.

分布:

  • state.db08-10 ~ 08-15 持续出现 using journal_mode=DELETE(升级前的最后一条在 08-15 08:27)
  • cron/executions.db:08-11 ~ 08-14 出现 48 条 using journal_mode=DELETE
  • 而更早的 08-01 ~ 08-10 阶段,这两个库显示的是 is already in WAL mode — leaving WAL in place

即:在 3.50.4 的保护闸下,state.db / cron/executions.db 确实被压成了 DELETE,且该警告在升级前是"看得见地"在工作的hermes update 当时也明确记录了这条:

⚠ Hermes venv links SQLite 3.50.4, which has the WAL-reset bug.
→ Provisioning a private Python 3.11 runtime with fixed SQLite...
✓ Managed Python runtime repaired (SQLite 3.50.4 → 3.53.1)

升级后(3.53.1)

  • WAL-reset 警告彻底消失(08-17 之后无任何 journal_mode / WAL-reset / DELETE 相关行)
  • 当前实际 journal_mode(实时 PRAGMA journal_mode 读出):
    • state.db: wal
    • cron/executions.db: wal
    • verification_evidence.db: wal
    • kanban.db: wal
    • response_store.db: delete ← 恰好印证您的 control 推断,它不经过 apply_wal_with_fallback

一个您会关心的点:database.journal_mode 未在 config 设置

我们的 config.yaml没有 database.journal_mode 配置项。也就是说,翻回 WAL 是默认值在起作用,而不是我们显式配置的选择。这与 #89393 里"this covers the file lost to a default you never set"的情况完全吻合。

补充:其余因果链的日志佐证

您判断 #84277 / #89088 / #88604 是这条链的其它环。这里补充升级前的实际数据,帮维护者了解全貌(升级窗口在 08-17 20:49):

指标 08-10 08-16 08-17(升级后) 08-18
disk image is malformed 1184 400 86 1
database is locked 68 38 34 1
held the state.db write lock 66 36 32 0

升级到 3.53.1 后,malformed 从 08-10 的 1184 条骤降到 08-18 的 1 条,write lock 归零。这正好支撑原报告里"WAL-reset 放大器"是问题关键一环的推断——修复放大器后锁风暴与损坏同时大幅消退。


小结:升级前的 WAL-reset 保护闸确实在可见地工作(大量 DELETE 警告),升级后闸关掉、4 个库被默认配置静默翻回 WAL,而我们从未在 config 里设置过 database.journal_mode。这条证据链支持"保留操作者选择"那一半比 warn 更有依据——我们原始诉求正是"preserve the choice, or at least warn"。

@jackulau

Copy link
Copy Markdown
Contributor Author

This is exactly the evidence I asked for, and it settles the question I threw back - thank you for going through the pre-upgrade logs properly. Three things confirmed: the WAL-reset gate was visibly working before the upgrade, unsupported on this filesystem never fired, and response_store.db staying delete confirms the control (it does not go through apply_wal_with_fallback).

But your log timeline contains something I think you passed over, and it changes what this PR should be.

The DELETE mode was never anyone's choice

You report two different messages from the same gate on state.db:

  • 08-01 → 08-10: is already in WAL mode — leaving WAL in place
  • 08-11 → 08-15: using journal_mode=DELETE instead of enabling WAL

Those are two different branches of _apply_delete_for_wal_reset_bug, and which one you get is decided by a read-only probe of what is already on disk:

current = _on_disk_journal_mode(conn)

if current == "wal":
    _log_wal_reset_bug_once(db_label, kept_wal=True)   # "already in WAL — leaving WAL in place"
    ...
    return "wal"
...
_log_wal_reset_bug_once(db_label, kept_wal=False)      # "using journal_mode=DELETE instead of enabling WAL"

The kept_wal=False message is only reachable when the database was already not in WAL when the process opened it. The gate never downgrades a live WAL database - it refuses in three separate branches, including when it cannot even read the mode, and the docstring says why ("that exact confusion let a vulnerable-SQLite process flip a live WAL state.db to DELETE under a concurrent WAL writer, destroying its committed-but-uncheckpointed transactions").

So the gate did not put state.db into DELETE. It found it there. Something else took that database out of WAL between 08-10 and 08-11, and the gate then simply declined to put it back.

What that something almost certainly was

Your own table dates it: disk image is malformed peaks at 1184 on 08-10 - the same day. The repair strategies in _repair_state_db_schema_locked all rewrite the damaged file in place (FTS rebuild, REINDEX, writable_schema surgery, VACUUM), and a rebuilt-or-restored SQLite file comes back in the default journal mode, which is delete. A corruption-recovery event on 08-10 is the one explanation consistent with both halves of your data.

Which means the causal chain runs the other way from the one in the original report: it is not "the upgrade silently reverted our journal mode". It is corruption took these databases out of WAL, the vulnerable-SQLite gate held them there and said so once per process, and the upgrade removed the gate so the default took effect again and put them back where they started on 08-01.

What this means for the PR

It argues against the "preserve the choice" half of your original ask, and I think that is worth saying plainly even though it is the half you preferred.

There is no operator choice on these four databases to preserve - you confirmed database.journal_mode is unset in your config.yaml, and the log shows they were in WAL until a corruption event moved them. A preserve implementation would pin state.db, cron/executions.db, verification_evidence.db and kanban.db to a journal mode that a crash picked for them on 08-10, permanently, on the strength of an on-disk value nobody chose. On your own machine it would have kept you in DELETE after the upgrade rather than returning you to the WAL you had been running for the previous ten days.

What is genuinely missing is the thing you asked for second: you had no way to know the mode changed. That is what this PR adds, and your data makes the case for it better than my original justification did - the flip was invisible in both directions, going in on 08-10 and coming back out on 08-17.

I am leaving the scope as warn-only.

One question, and one thing worth its own issue

Question: can you confirm the 08-10 repair? Either a repair/backup line in the logs around 08-10-08-11, or a state.db.backup* / .bak file next to state.db with a timestamp in that window (_backup_db_file writes one before any in-place surgery). If there is no repair event, my explanation is wrong and something else moved that database out of WAL - which would be worth knowing.

Separate issue: malformed dropping 1184 -> 1 across the SQLite upgrade is a strong result and it is not really about journal-mode notices. If the 08-10 repair is confirmed, the reportable gap is that a corruption repair silently changes a database's journal mode and nothing records it - which is adjacent to but distinct from this PR, and is better tracked on its own where it can be fixed at the repair site rather than at the open site.

@zhanglingfei112

Copy link
Copy Markdown

Follow-up filed as a separate issue: #89674 — corruption-repair silently changes a DB's journal_mode (WAL→delete) with no log record.

That is the "worth its own issue" gap you flagged. I verified the 08-10 repair event from my logs: state.db hit REINDEX pass failed: database disk image is malformed at 17:05, a malformed-backup-20260810_170150 was written at 17:06, and state.db/cron/executions.db transitioned WAL→DELETE between 08-10 and 08-11. The repair path rewrites the file in place and comes back in default delete, with no mode-change record — so even after #89393 merges, this flip stays invisible.

Suggested fix is at the repair site (preserve/re-apply the on-disk mode, or at minimum log it), per your steer. Linking for traceability.

(Update to my earlier evidence comment: the causal chain in #89293 was backwards — it was the 08-10 corruption event, not the upgrade, that moved these stores out of WAL. Your analysis was right.)

@jackulau

Copy link
Copy Markdown
Contributor Author

Thank you for filing #89674, and for going back and correcting your own earlier evidence comment in public - that was more than I asked for and it makes the whole chain readable for whoever picks this up.

Your timeline is the part that makes the issue land: is already in WAL mode right up to 08-10, the REINDEX failure at 17:05:48, the backup at 17:06:12, and using journal_mode=DELETE on the 08-11 open. That is the flip happening inside the repair path, not at open, which is exactly why this PR cannot see it.

Two notes so the two threads stay in sync.

#89681 already exists for it (liuhao1024, filed ~13 minutes after your issue), so it is owned. I have reviewed it rather than opening anything competing. The approach is right, but I found one thing that needs fixing before it merges and it is directly relevant to your report: the restore re-applies WAL through a helper that skips the WAL-reset vulnerability gate, so on SQLite 3.50.4 - your version at the time of the corruption - it would push the just-repaired database back into WAL, which is precisely what the gate refuses to do. Reproduced it against both code paths and posted the comparison there. Worth watching that thread if you want the fix to be safe on the runtime you actually hit this on.

This PR's scope is unchanged by that. It stays the open-time warning, warn-only. The two are complementary rather than overlapping: yours flips inside repair, this one fires when an open-time upgrade moves an existing database into WAL. Neither subsumes the other, which is why splitting them was right.

Nothing outstanding on this PR from my side - it is rebased on current main, conflict-free, and CI is green. If a maintainer wants the repair-site behaviour folded in here instead of #89681, say so and I will adjust, but I would rather not take work that someone else has already opened a PR for.

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

Labels

area/config Config system, migrations, profiles 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

4 participants