forked from NousResearch/hermes-agent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhermes_state.py
More file actions
9308 lines (8473 loc) · 407 KB
/
Copy pathhermes_state.py
File metadata and controls
9308 lines (8473 loc) · 407 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
SQLite State Store for Hermes Agent.
Provides persistent session storage with FTS5 full-text search, replacing
the per-session JSONL file approach. Stores session metadata, full message
history, and model configuration for CLI and gateway sessions.
Key design decisions:
- WAL mode for concurrent readers + one writer (gateway multi-platform)
- FTS5 virtual table for fast text search across all session messages
- Compression-triggered session splitting via parent_session_id chains
- Batch runner and RL trajectories are NOT stored here (separate systems)
- Session source tagging ('cli', 'telegram', 'discord', etc.) for filtering
"""
import asyncio
import atexit
import errno
import hashlib
import json
import logging
import os
import random
import re
import sqlite3
import sys
import threading
import time
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from agent.memory_manager import sanitize_context
from agent.session_activity import ActivityProvenance
from agent.message_sanitization import _sanitize_surrogates
from agent.skill_commands import (
SKILL_EXCERPT_JOINT,
SKILL_SCAFFOLD_SQL_LIKE,
describe_skill_invocation,
)
from hermes_constants import get_hermes_home
from hermes_cli.sqlite_runtime import (
is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable,
)
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
from hermes_state_common import ( # noqa: F401 (re-exported for back-compat)
_BRANCH_CHILD_SQL,
_COMPRESSION_CHILD_SQL,
_FTS_CJK_TRIGGERS,
_FTS_TRIGGERS,
_LISTABLE_CHILD_SQL,
_PREVIEW_RAW_SELECT,
_ephemeral_child_sql,
_shape_preview,
_sql_session_last_active,
_sql_session_last_active_by_id,
DEFERRED_INDEX_SQL,
FTS_CJK_STALE_KEY,
FTS_SQL,
FTS_STORAGE_VERSION,
FTS_TRIGRAM_SQL,
LEGACY_FTS_SQL,
LEGACY_FTS_TRIGRAM_SQL,
MAX_FTS5_QUERY_CHARS,
SCHEMA_SQL,
SCHEMA_VERSION,
_PREVIEW_CONTENT_SQL,
_PREVIEW_HEAD_CHARS,
_PREVIEW_MAX_CHARS,
_PREVIEW_SCAFFOLD_WINDOW,
_PREVIEW_SCAFFOLDED_SQL,
)
from hermes_state_portability import SessionPortabilityMixin
from hermes_state_schema import SessionSchemaMixin
from hermes_state_search import SessionSearchMixin
try: # Hard dependency, but tolerate scaffold-phase imports before pip install.
import psutil
except ImportError: # pragma: no cover - stripped/scaffold installs only
psutil = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
_COMPRESSION_LOCK_HOLDER_PID_RE = re.compile(r"(?:^|:)pid=(\d+)(?::|$)")
def _system_prompt_hash(system_prompt: str) -> str:
return hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()
def _compression_lock_holder_process_is_dead(holder: str) -> bool:
"""Return True only when a structured lock holder's local PID is gone.
Compression locks are stored in a host-local SQLite database and holder
IDs created by ``conversation_compression`` start with ``pid=<n>``. A
process killed during gateway shutdown cannot release its lease, so waiting
for the full TTL makes every new turn repeatedly attempt compaction. Reclaim
only when the kernel proves that PID no longer exists; legacy/unstructured
holders, same-process holders, permission errors, and any probe doubt
remain protected until normal TTL expiry (conservative: PID reuse must
never steal a live lease, and a wrongly-kept lease self-heals via TTL).
"""
match = _COMPRESSION_LOCK_HOLDER_PID_RE.search(holder or "")
if match is None:
return False
try:
pid = int(match.group(1))
except (TypeError, ValueError):
return False
if pid <= 0:
return False
if pid == os.getpid():
# Same-process holder (e.g. another thread's live lease): never
# self-reclaim — the lease refresher and release path own it.
return False
if psutil is not None:
try:
# psutil is the canonical cross-platform liveness answer
# (CONTRIBUTING.md "Critical rules" #1). pid_exists() reports
# recycled PIDs as alive — conservative, the TTL still applies.
return not psutil.pid_exists(pid)
except Exception:
return False # any doubt → keep the lease until TTL expiry
# Scaffold-phase fallback only (psutil missing), and POSIX-only: stdlib
# os.kill(pid, 0) is NOT a no-op probe on Windows (bpo-14484 — sig=0 maps
# to CTRL_C_EVENT and can kill the target's console group). Without psutil
# a Windows host stays TTL-only; the lease TTL remains the recovery path.
if os.name == "nt":
return False
try:
os.kill(pid, 0) # windows-footgun: ok — nt early-returns just above
except ProcessLookupError:
return True
except (PermissionError, OSError, OverflowError):
return False
return False
def _scrub_surrogates(value: Any) -> Any:
"""Replace lone surrogates when *value* is text; pass anything else through.
sqlite3 encodes bound ``str`` parameters as UTF-8 and raises
``UnicodeEncodeError`` on lone surrogates (U+D800..U+DFFF), so a single
such code point anywhere in a message aborts the whole write. No-op for
well-formed text.
"""
return _sanitize_surrogates(value) if isinstance(value, str) else value
def workspace_key(row: Dict[str, Any]) -> Optional[str]:
"""A session's workspace grouping key: its git repo root when known, else
its cwd.
Branch is deliberately excluded so checking out a new branch doesn't
fragment a workspace's session history. Returns None for cwd-less (unbound)
sessions. Both fields are already recorded on ``sessions`` — this just picks
the coarser identity for grouping/filtering.
"""
root = (row.get("git_repo_root") or "").strip()
if root:
return root
cwd = (row.get("cwd") or "").strip()
return cwd or None
def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
"""Match sessions whose ``workspace_key(row)`` equals ``key``.
Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
when its recorded ``git_repo_root`` equals ``key``, or — for rows that
predate per-session git metadata — when its ``cwd`` is at or under
``key`` (so a session started in ``repo/src`` still groups with ``repo``).
Used by ``hermes -c``/``--resume`` to continue the most recent session in
the *current* workspace rather than the global MRU.
"""
prefix = key.rstrip("/\\") or key
cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
return (
f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
[prefix, *cwd_params],
)
def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
"""Delegate-subagent ids to cascade-delete with *parent_ids*.
Only rows carrying the ``_delegate_from`` marker (set at creation, and
backfilled by the v16 migration) — generic untagged children keep the
orphan-don't-delete contract. Walks marker chains recursively so an
orchestrator subagent's own delegate children go too (FK safety).
"""
df = _delegate_from_json()
seeds = {sid for sid in parent_ids if sid}
# Seed the visited set with the parents themselves. A delegation marker
# chain can loop back onto a parent — a cycle, or a parent that is also
# another parent's delegate child when several ids are deleted at once —
# and without this guard that parent would be collected as one of its own
# descendants and cascade-deleted along with all of its messages. Callers
# delete the parents separately, so parents must never appear in the
# returned child set. (#49148)
found: set[str] = set(seeds)
frontier = list(seeds)
while frontier:
ph = ",".join("?" * len(frontier))
cursor = conn.execute(
f"SELECT id FROM sessions WHERE {df} IN ({ph}) "
f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)",
frontier + frontier,
)
frontier = [row["id"] for row in cursor.fetchall() if row["id"] not in found]
found.update(frontier)
# Return only the discovered children — never the parents themselves.
return [sid for sid in found if sid not in seeds]
def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
ids = _collect_delegate_child_ids(conn, parent_ids)
if ids:
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", ids)
# FK safety: orphan any untagged stragglers pointing at a doomed row.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({ph})",
ids,
)
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", ids)
return ids
T = TypeVar("T")
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
# Import-time snapshot used by _default_db_path() to detect a deliberately
# re-pointed DEFAULT_DB_PATH (tests monkeypatch the constant directly).
_IMPORT_DEFAULT_DB_PATH = DEFAULT_DB_PATH
def _default_db_path() -> Path:
"""Resolve the default state DB path at call time.
``DEFAULT_DB_PATH`` is computed when this module is first imported, which
freezes the developer's real ``~/.hermes`` even when a test fixture later
redirects ``HERMES_HOME`` — importing this module during collection was
enough to point every default ``SessionDB()`` at the real state.db.
Precedence:
1. A deliberately re-pointed ``DEFAULT_DB_PATH`` (differs from the
import-time snapshot — the established test escape hatch) wins.
2. Otherwise resolve ``get_hermes_home()`` fresh so a runtime
``HERMES_HOME`` redirect takes effect regardless of import order.
"""
if DEFAULT_DB_PATH != _IMPORT_DEFAULT_DB_PATH:
return DEFAULT_DB_PATH
return get_hermes_home() / "state.db"
# ---------------------------------------------------------------------------
# WAL-compatibility fallback
# ---------------------------------------------------------------------------
# SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
# byte-range locks that don't reliably work on network filesystems (NFS,
# SMB/CIFS, some FUSE mounts, WSL1). Upstream documents this explicitly:
# https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
#
# On those filesystems ``PRAGMA journal_mode=WAL`` raises
# ``sqlite3.OperationalError: locking protocol`` (SQLITE_PROTOCOL). If we
# propagate that, every feature backed by state.db / kanban.db breaks
# silently — /resume, /title, /history, /branch, kanban dispatcher, etc.
#
# ZFS is a separate case: its COW + mmap semantics can corrupt the WAL
# shared-memory (-shm) file under concurrent connection bursts, presenting
# as ``disk I/O error`` rather than ``locking protocol``.
#
# Instead, fall back to ``journal_mode=DELETE`` (the pre-WAL default) which
# works on NFS and ZFS. Concurrency drops — concurrent readers are blocked
# during a write — but the feature works.
#
# Separately, SQLite's WAL-reset bug can corrupt multi-process WAL databases
# on unfixed library builds (issue #69784). See:
# https://sqlite.org/wal.html#walresetbug
# Fixed in 3.51.3+ with backports 3.50.7 and 3.44.6. On vulnerable builds we
# refuse to *enable* WAL for fresh / non-WAL databases (prefer DELETE). We do
# NOT live-downgrade an on-disk WAL database — other gateway/cron/worker
# connections may still hold it open, and flipping journal_mode under them is
# unsafe (same invariant as the NFS path below).
_WAL_INCOMPAT_MARKERS = (
"locking protocol", # SQLITE_PROTOCOL on NFS/SMB
"not authorized", # Some FUSE mounts block WAL pragma outright
"disk i/o error", # ZFS SHM corruption under concurrent connections
)
# Last SessionDB() init error, per-process. Surfaced in /resume and
# related slash-command error strings so users know WHY the DB is
# unavailable instead of getting a bare "Session database not available."
# Only SessionDB.__init__ writes to this; kanban_db.connect() failures
# do not update it (by design — kanban failures are reported via their
# own caller's error handling, not via /resume-style slash commands).
_last_init_error: Optional[str] = None
_last_init_error_lock = threading.Lock()
# Paths for which we've already logged a WAL-fallback WARNING. Without
# this, kanban_db.connect() (called on every kanban operation — see
# hermes_cli/kanban_db.py for ~30 call sites) would re-log the same
# filesystem-incompat warning on every connection, filling errors.log.
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
# Dedup WARNING for the WAL-reset vulnerability fallback (issue #69784).
_wal_reset_bug_warned_paths: set[str] = set()
_wal_reset_bug_warned_lock = threading.Lock()
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
Thread-safe via _last_init_error_lock. Callers pass a message to
record a failure or None to clear. SessionDB.__init__ only calls
this to SET on failure — it deliberately does NOT clear on success,
because in a multi-threaded caller (e.g. gateway / web_server per-
request SessionDB() instantiation), a concurrent successful open
racing past a different thread's failure would erase the cause
string that thread's /resume handler is about to format. Explicit
clears (e.g. test fixtures) are still supported by passing None.
"""
global _last_init_error
with _last_init_error_lock:
_last_init_error = msg
def get_last_init_error() -> Optional[str]:
"""Return the most recent state.db init failure, if any.
Slash-command handlers (``/resume``, ``/title``, ``/history``, ``/branch``)
call this to surface the underlying cause in their error messages when
``_session_db is None``. Returns ``None`` if SessionDB initialized
successfully (or hasn't been attempted).
"""
return _last_init_error
# Distinctive opening shared by both background-review harness prompts
# (_SKILL_REVIEW_PROMPT and _MEMORY_REVIEW_PROMPT in agent/background_review.py).
# Matched case-sensitively against the leading content of a user/system message.
_REVIEW_HARNESS_PREFIXES = (
"Review the conversation above and update the skill library",
"Review the conversation above and consider saving to memory",
)
def _is_background_review_harness_message(msg: Dict[str, Any]) -> bool:
"""True when ``msg`` is a persisted background-review harness prompt.
These are user/system turns the forked skill/memory review agent wrote into
a real session in older builds (before the ``_persist_disabled`` isolation
fix). They instruct the agent to act as the curator under a hard tool
restriction, so replaying them as live history hijacks the session.
"""
if not isinstance(msg, dict):
return False
if msg.get("role") not in {"user", "system"}:
return False
content = msg.get("content")
if not isinstance(content, str):
return False
head = content.lstrip()
return any(head.startswith(p) for p in _REVIEW_HARNESS_PREFIXES)
def _strip_background_review_harness(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Drop background-review harness messages and the curator-mode assistant
reply that immediately followed each one.
Walk the list once; when a harness user/system message is found, skip it and
also skip the next message if it is the assistant turn that answered it.
Everything else passes through untouched and in order.
"""
if not messages:
return messages
out: List[Dict[str, Any]] = []
skip_next_assistant = False
for msg in messages:
if _is_background_review_harness_message(msg):
skip_next_assistant = True
continue
if skip_next_assistant:
skip_next_assistant = False
if isinstance(msg, dict) and msg.get("role") == "assistant":
# The curator-mode reply to the harness prompt — drop it.
continue
out.append(msg)
return out
def format_session_db_unavailable(prefix: str = "Session database not available") -> str:
"""Format a user-facing 'session DB unavailable' message with cause.
When ``SessionDB()`` init fails, callers set ``_session_db = None`` and
several slash commands (/resume, /title, /history, /branch) previously
responded with a bare ``"Session database not available."`` — no
indication of WHY. This helper includes the captured cause (typically
``"locking protocol"`` from NFS/SMB) and points users at the known
culprit so they can fix it themselves.
Example output:
Session database not available: locking protocol (state.db may be
on NFS/SMB — see https://www.sqlite.org/wal.html).
"""
cause = get_last_init_error()
if not cause:
return f"{prefix}."
hint = ""
if any(marker in cause.lower() for marker in _WAL_INCOMPAT_MARKERS):
hint = " (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html)"
return f"{prefix}: {cause}{hint}."
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
"""Read the journal mode from the SQLite DB header on disk.
Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
if the value cannot be determined (new DB, or PRAGMA read failed).
"""
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
except sqlite3.OperationalError:
return None
if row is None:
return None
mode = row[0]
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
try:
mode = mode.decode("ascii")
except UnicodeDecodeError:
return None
return str(mode).strip().lower() if mode is not None else None
def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
"""Enable ``PRAGMA checkpoint_fullfsync`` on macOS (no-op elsewhere).
On Darwin, ``synchronous=FULL`` (the WAL default) issues a plain
``fsync()``, which Apple documents does *not* guarantee that data
has reached stable storage or that writes are not reordered — see
the ``fsync(2)`` man page. SQLite's WAL corruption-safety guarantee
assumes the OS honors the fsync write barrier; macOS does not unless
the app uses ``F_FULLFSYNC``.
During a launchd *system* shutdown/reboot the OS page cache is
dropped (effectively a power-loss event for in-flight pages), so a
WAL checkpoint whose ``fsync()`` "reported" durable may never have
hit the platter — corrupting ``state.db`` with a malformed image.
This is the trigger in issue #30636 ("SIGTERM during launchd
shutdown under high load"), distinct from a plain in-session kill
(which the page cache survives and SQLite recovers from).
``checkpoint_fullfsync=1`` forces an ``F_FULLFSYNC`` barrier only at
checkpoint boundaries — where WAL frames land in the main DB — so the
cost amortizes to roughly +0.1 ms/commit (vs ~+4 ms for the broader
``fullfsync=1`` that flushes on every commit's WAL sync). Guarded by
``sys.platform == "darwin"`` because ``F_FULLFSYNC`` is macOS-only;
on other platforms the PRAGMA is a no-op, so we skip it entirely.
Best-effort: never raises.
"""
if sys.platform != "darwin":
return
try:
conn.execute("PRAGMA checkpoint_fullfsync=1")
except sqlite3.OperationalError:
pass
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
"""Enforce ``PRAGMA synchronous=FULL`` on macOS to prevent btree corruption.
On Darwin, the default ``synchronous=NORMAL`` only calls ``fsync()``,
which Apple's fsync(2) man page explicitly states does *not* guarantee
data-on-platter or write-ordering. During a WAL checkpoint race with
process termination (e.g., launchd shutdown), this can leave the main
DB with half-written btree pages → ``btreeInitPage error 11``.
WAL mode's durability guarantee assumes the OS honors fsync barriers;
macOS does not unless we explicitly set ``synchronous=FULL``, which issues
a real ``fsync()`` on every transaction commit. The ``F_FULLFSYNC``
barrier at checkpoint boundaries is handled separately by
:func:`_apply_macos_checkpoint_barrier`.
This function is called after any successful WAL activation (either
from ``apply_wal_with_fallback()`` setting a fresh WAL or when probing
an existing WAL mode). It ensures macOS connections always use FULL
synchronous mode, even if a prior connection set ``synchronous=NORMAL``.
Best-effort: never raises.
"""
if sys.platform != "darwin":
return
try:
conn.execute("PRAGMA synchronous=FULL")
except sqlite3.OperationalError:
pass
def is_sqlite_wal_reset_vulnerable(
version_info: Optional[tuple] = None,
) -> bool:
"""Return True when the linked SQLite library has the WAL-reset bug.
Upstream documents the bug in versions 3.7.0 through 3.51.2, fixed in
3.51.3+, with backports 3.50.7 and 3.44.6:
https://sqlite.org/wal.html#walresetbug
Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe.
"""
info = version_info if version_info is not None else sqlite3.sqlite_version_info
return _is_sqlite_wal_reset_vulnerable(info)
def sqlite_source_id() -> str:
"""Return ``sqlite_source_id()``, or an empty string when unavailable."""
try:
conn = sqlite3.connect(":memory:")
try:
row = conn.execute("SELECT sqlite_source_id()").fetchone()
finally:
conn.close()
except sqlite3.Error:
return ""
if not row or row[0] is None:
return ""
return str(row[0])
def resolve_journal_mode() -> str:
"""Return the configured journal mode (``wal`` or ``delete``).
``database.journal_mode`` in config.yaml is the canonical operator
setting. ``wal`` remains the default; use ``delete`` when the backing
filesystem does not provide WAL-safe durability (for example macOS
virtiofs, NFS, or SMB). Invalid or malformed values fail safely to the
existing default.
"""
try:
from hermes_cli.config import load_config_readonly
config = load_config_readonly() or {}
database = config.get("database", {})
if not isinstance(database, dict):
return "wal"
raw = database.get("journal_mode", "wal")
except Exception:
return "wal"
if not isinstance(raw, str):
return "wal"
mode = raw.strip().lower()
return mode if mode in ("wal", "delete") else "wal"
class WalUnsupportedError(sqlite3.OperationalError):
"""Raised by :func:`apply_wal_with_fallback` when ``require_wal=True`` and
the filesystem cannot provide WAL journal mode.
Covers both shapes of WAL refusal on network filesystems (NFS / SMB / FUSE
/ the AgentFS NFS overlay): SQLite *raising* ``SQLITE_PROTOCOL`` ("locking
protocol"), and the quieter macOS-NFS case where ``PRAGMA journal_mode=WAL``
silently returns the still-effective mode without raising. Subclasses
``sqlite3.OperationalError`` so existing ``except sqlite3.OperationalError``
DB-init handling still catches it, while callers that specifically mandate
WAL can catch this narrower type.
"""
def apply_wal_with_fallback(
conn: sqlite3.Connection,
*,
db_label: str = "state.db",
require_wal: bool = False,
) -> str:
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
Returns the journal mode actually set (``"wal"`` or ``"delete"``).
On WAL-incompatible filesystems (NFS, SMB, some FUSE, ZFS), SQLite either
raises ``OperationalError("locking protocol")`` /
``OperationalError("disk I/O error")`` or — on macOS NFS / SMB /
the AgentFS NFS overlay — silently refuses the switch and leaves the DB in
DELETE. Either way the degradation is logged at ERROR level (it is a real
loss of concurrency — a write blocks concurrent readers — not a cosmetic
warning) and, by default, the function falls back to DELETE (the pre-WAL
default, which works on NFS and ZFS) so the feature keeps working.
On SQLite builds that still contain the WAL-reset corruption bug
(issue #69784), refuse to enable WAL on fresh / non-WAL databases
(prefer DELETE). If the on-disk DB is already WAL, keep WAL and warn
— never live-downgrade under possible concurrent openers.
This gate (#70055) is deliberately RETAINED. An earlier revision of the
lock-cancellation fix (#71724) reverted it on the theory that DELETE was
"the mode that corrupts", but that comparison was confounded: the clean
WAL result came from SQLite 3.53.1, which carries BOTH the WAL-reset fix
AND 3.51.0's defenses against close()-broken POSIX locks, so it says
nothing about 3.50.4. Re-measured on the actually-bundled 3.50.4 with
the lock fix in place, WAL and DELETE are both clean (0/3 each) — i.e.
there is no evidence that WAL is safer here, and upstream still documents
the WAL-reset bug as real through 3.51.2 with serious consequences. Until
a fixed runtime is delivered, keep new databases out of WAL.
Callers that genuinely require WAL concurrency (and would rather fail loudly
than run silently degraded) pass ``require_wal=True``; the function then
raises :class:`WalUnsupportedError` instead of returning ``"delete"``. All
current callers deliberately keep the default ``require_wal=False`` so
NFS-homed installs keep working.
The ERROR is deduplicated per ``db_label``: repeated connections to the
same underlying DB (e.g. kanban_db.connect() which is called on every
kanban operation) log once per process, not once per call. Different
db_labels log independently, so state.db and kanban.db each get one error
on the same NFS mount.
Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
both databases get identical fallback behavior.
Never downgrades to DELETE if the on-disk DB header reports WAL — see
_on_disk_journal_mode. That holds for both the NFS path and the
WAL-reset vulnerability path.
"""
configured = resolve_journal_mode()
# Vulnerable SQLite: do not enable WAL on new/non-WAL files. Resolve the
# operator setting first so an explicit DELETE request still verifies that
# SQLite actually accepted DELETE rather than silently returning MEMORY or
# another connection-specific mode.
if is_sqlite_wal_reset_vulnerable():
return _apply_delete_for_wal_reset_bug(
conn,
db_label=db_label,
require_delete=configured == "delete",
)
# Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.
# Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.
try:
current_mode = conn.execute("PRAGMA journal_mode").fetchone()
if current_mode and current_mode[0] == "wal":
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
except sqlite3.OperationalError:
pass
# #68545: honor the canonical database.journal_mode setting. Existing
# on-disk WAL databases were returned above and are never live-downgraded.
if configured == "delete":
row = conn.execute("PRAGMA journal_mode=DELETE").fetchone()
actual = str(row[0]).lower() if row else ""
if actual != "delete":
raise sqlite3.OperationalError(
f"could not set configured journal_mode=delete (got {actual or 'no result'})"
)
return actual
try:
# ``PRAGMA journal_mode=WAL`` is a query-that-sets: it RETURNS the
# resulting journal mode. Network filesystems that refuse WAL by
# *raising* SQLITE_PROTOCOL ("locking protocol") are handled in the
# except branch below. But macOS NFS — and SMB/CIFS, and the AgentFS
# NFS overlay — refuse the switch WITHOUT raising: the pragma simply
# returns the still-effective mode (e.g. ``delete``). Trust the
# returned row, not the mere absence of an exception; otherwise we
# report a false ``"wal"`` AND skip the fallback WARNING, leaving the
# DB silently in DELETE (reader-blocks-writer) with no signal.
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
mode = str(row[0]).strip().lower() if row and row[0] is not None else ""
if mode == "wal":
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
# Silent refusal (macOS NFS / SMB / AgentFS overlay): WAL was not
# honored, but nothing raised.
silent_exc = WalUnsupportedError(
f"journal_mode=WAL refused without raising (still {mode!r})"
)
if require_wal:
raise silent_exc
_log_wal_fallback_once(db_label, silent_exc)
return mode or "delete"
except sqlite3.OperationalError as exc:
# The require_wal silent-refusal raise above is a WalUnsupportedError
# (an OperationalError subclass) and lands here — propagate it
# unchanged rather than re-running it through the marker logic.
if isinstance(exc, WalUnsupportedError):
raise
msg = str(exc).lower()
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
# Unrelated OperationalError — don't silently swallow.
raise
# ``disk i/o error`` is ambiguous: on ZFS / APFS-CoW it is a
# deterministic WAL-incompatibility (SHM corruption under concurrent
# connection bursts — #55305, #71498), but it can also be a one-shot
# transient EIO (page-cache pressure, brief lock contention).
# Treating a transient EIO as a permanent downgrade signal produced
# the mixed-journal-mode corruption pattern fixed in 5c49cd0ed0
# (process A downgrades to DELETE while sibling processes set WAL).
# Disambiguate by retrying the pragma a couple of times: transient
# EIO clears and we return "wal"; the deterministic filesystem cases
# keep failing and fall through to the guarded DELETE fallback.
if "disk i/o error" in msg:
for _ in range(2):
time.sleep(0.05)
try:
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
except sqlite3.OperationalError as retry_exc:
if "disk i/o error" not in str(retry_exc).lower():
raise
exc = retry_exc
continue
mode = (
str(row[0]).strip().lower()
if row and row[0] is not None
else ""
)
if mode == "wal":
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
break
# Don't downgrade if another process already set WAL on disk.
existing = _on_disk_journal_mode(conn)
if existing == "wal":
raise
if require_wal:
# Caller mandates WAL — fail loudly instead of degrading to DELETE.
raise WalUnsupportedError(str(exc)) from exc
_log_wal_fallback_once(db_label, exc)
conn.execute("PRAGMA journal_mode=DELETE")
return "delete"
def _apply_delete_for_wal_reset_bug(
conn: sqlite3.Connection,
*,
db_label: str,
require_delete: bool = False,
) -> str:
"""Avoid enabling WAL when the linked SQLite has the WAL-reset bug.
- Already-WAL on disk: leave WAL alone (no live downgrade) and warn.
- Otherwise: set DELETE and warn.
- For an explicit operator request, verify SQLite accepted DELETE.
"""
current = ""
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
if row and row[0] is not None:
current = str(row[0]).strip().lower()
except sqlite3.OperationalError:
current = ""
if current == "wal":
# Do not TRUNCATE / journal_mode=DELETE while other processes may
# still hold this WAL DB open — same safety rule as the NFS path.
_log_wal_reset_bug_once(db_label, kept_wal=True)
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
actual = ""
try:
row = conn.execute("PRAGMA journal_mode=DELETE").fetchone()
if row and row[0] is not None:
actual = str(row[0]).strip().lower()
except sqlite3.OperationalError:
if require_delete:
raise
# Best-effort for the automatic vulnerable-runtime fallback: DELETE is
# normally already the default for new file-backed databases.
if require_delete and actual != "delete":
raise sqlite3.OperationalError(
"could not set configured journal_mode=delete "
f"(got {actual or 'no result'})"
)
_log_wal_reset_bug_once(db_label, kept_wal=False)
return "delete"
def _log_wal_reset_bug_once(
db_label: str,
*,
kept_wal: bool,
) -> None:
"""Log once per (process, db_label) about the WAL-reset vulnerability path."""
with _wal_reset_bug_warned_lock:
if db_label in _wal_reset_bug_warned_paths:
return
_wal_reset_bug_warned_paths.add(db_label)
action = (
"is already in WAL mode — leaving WAL in place (no live "
"downgrade under concurrent openers)"
if kept_wal
else "using journal_mode=DELETE instead of enabling WAL"
)
logger.warning(
"%s: linked SQLite %s is vulnerable to the WAL-reset corruption "
"bug (https://sqlite.org/wal.html#walresetbug) — %s. "
"Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); "
"Hermes-managed installs can repair the embedded runtime with "
"`hermes update`. See `hermes doctor`. This warning fires once per "
"process per database.",
db_label,
sqlite3.sqlite_version,
action,
)
def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
"""Log a single ERROR per (process, db_label) about WAL fallback.
ERROR (not WARNING): a DB silently dropped to DELETE means a real loss of
concurrency — under the kanban dispatcher + workers a write blocks readers,
surfacing as SQLITE_BUSY/lock contention — so it must be loud, not cosmetic.
Without this dedup, NFS users running kanban (which opens a fresh
connection on every operation — see hermes_cli/kanban_db.py) would
fill errors.log with hundreds of identical errors per hour.
"""
with _wal_fallback_warned_lock:
if db_label in _wal_fallback_warned_paths:
return
_wal_fallback_warned_paths.add(db_label)
logger.error(
"%s: WAL journal_mode unsupported on this filesystem (%s) — "
"falling back to journal_mode=DELETE (slower rollback-journal "
"mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See "
"https://www.sqlite.org/wal.html for details. This message "
"fires once per process per database.",
db_label,
exc,
)
# ---------------------------------------------------------------------------
# Config-driven database pragmas
# ---------------------------------------------------------------------------
def apply_database_pragmas(
conn: sqlite3.Connection,
*,
db_label: str = "state.db",
) -> None:
"""Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.
Reads the ``database:`` section and applies configurable PRAGMAs when set
to integer values. The journal mode itself is NOT handled here —
``database.journal_mode`` is owned by :func:`resolve_journal_mode` inside
:func:`apply_wal_with_fallback`, which layers the operator setting under
all the safety guards (never live-downgrading an on-disk WAL DB,
filesystem fallback, WAL-reset-bug gating).
Supported keys under ``database:`` in config.yaml:
* ``cache_size`` — negative value = KiB, positive = pages
(e.g. ``-262144`` = 256 MB page cache)
* ``mmap_size`` — max bytes for memory-mapped I/O (0 = disabled)
* ``temp_store`` — 0=DEFAULT(file), 1=FILE, 2=MEMORY, 3=ALWAYS
* ``wal_autocheckpoint`` — WAL auto-checkpoint threshold in pages
* ``journal_size_limit`` — max journal/WAL size in bytes
Best-effort: config load or pragma failures are ignored so DB init
never breaks on a malformed ``database:`` section.
"""
try:
# Local import avoids a circular import with hermes_cli.config.
from hermes_cli.config import cfg_get, load_config_readonly
cfg = load_config_readonly()
except Exception:
return
# Performance PRAGMAs (applied to ALL connection types: writer, read_only,
# and WAL per-thread readers).
for pragma_name in (
"cache_size",
"mmap_size",
"temp_store",
"wal_autocheckpoint",
"journal_size_limit",
):
raw_value = cfg_get(cfg, "database", pragma_name, default=None)
if raw_value is None:
continue
try:
value = int(str(raw_value).strip())
except (TypeError, ValueError):
logger.warning(
"%s: ignoring non-integer database.%s=%r",
db_label,
pragma_name,
raw_value,
)
continue
try:
conn.execute(f"PRAGMA {pragma_name}={value}")
except sqlite3.OperationalError:
pass
# ---------------------------------------------------------------------------
# Malformed-schema recovery
# ---------------------------------------------------------------------------
# A distinct, nastier failure class than a malformed FTS *inverted index*:
# the ``sqlite_master`` schema table itself becomes inconsistent — most
# commonly a DUPLICATE object definition, e.g. two ``CREATE VIRTUAL TABLE
# messages_fts`` rows. SQLite parses the entire schema while preparing the
# FIRST statement on a connection, so on this class *every* statement raises
# before it runs — including ``PRAGMA journal_mode`` (which is why this trips
# in ``apply_wal_with_fallback`` during ``SessionDB.__init__``, long before
# ``_init_schema`` is reached) and even ``PRAGMA integrity_check`` and a plain
# ``DROP TABLE``. The only operations that still work are
# ``PRAGMA writable_schema=ON`` plus direct ``sqlite_master`` surgery.
#
# Symptom users hit (Desktop/Dashboard show "no sessions" while 200+ JSON
# files sit on disk):
# sqlite3.DatabaseError: malformed database schema (messages_fts) -
# table messages_fts already exists
#
# The canonical ``sessions`` / ``messages`` data is intact in these cases —
# only the derived schema is broken — so recovery preserves all transcripts
# and merely rebuilds the FTS layer.
_MALFORMED_SCHEMA_MARKERS = (
"malformed database schema",
"database disk image is malformed",
)
# Process-global guard so auto-repair is attempted at most once per DB path
# per process (prevents repair loops and serialises concurrent web_server /
# gateway opens against the same malformed file).
_repair_attempted_paths: set[str] = set()
_repair_attempt_lock = threading.Lock()
def is_malformed_db_error(exc: BaseException) -> bool:
"""True if *exc* is a SQLite 'malformed schema / disk image' error.
These are the corruption classes where the schema fails to parse, so
targeted ``sqlite_master`` surgery (not an ordinary FTS rebuild) is the
only recovery path.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
return any(marker in str(exc).lower() for marker in _MALFORMED_SCHEMA_MARKERS)
# Markers that mean the host filesystem cannot accept another write. Kept as
# plain substrings so OSError, sqlite3.OperationalError, and wrapped RPC
# error strings all match the same helper.
_DISK_FULL_MARKERS = (
"no space left on device",
"not enough space",
"database or disk is full", # SQLITE_FULL
"disk full",
"full disk",
"enospc",
)
def is_disk_full_error(exc: BaseException | str | None) -> bool:
"""True when *exc* (or a stringified error) is a disk-full / ENOSPC failure.
Covers:
* ``OSError`` with ``errno.ENOSPC``
* SQLite ``OperationalError: database or disk is full`` (SQLITE_FULL)
* Plain English / errno strings that survive RPC wrapping
"""
if exc is None:
return False
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOSPC:
return True
text = exc if isinstance(exc, str) else str(exc)
lowered = text.lower()
return any(marker in lowered for marker in _DISK_FULL_MARKERS)
def _claim_repair_attempt(db_path: Path) -> bool:
"""Claim the one-shot repair attempt for *db_path* in this process.
Returns True for the first caller, False afterwards. Keeps a malformed
DB from triggering an unbounded repair/reopen loop and stops concurrent