Repository navigation
Expand file tree
/
Copy pathhermes_state.py
More file actions
1732 lines (1592 loc) · 93 KB
/
Copy pathhermes_state.py
File metadata and controls
1732 lines (1592 loc) · 93 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: session metadata, message history, model
config, FTS5 search. WAL mode (concurrent readers + one writer); compression
splits sessions via parent_session_id chains; sessions are source-tagged
('cli', 'telegram', ...). Batch-runner / RL trajectories live elsewhere.
"""
import asyncio
import atexit
import hashlib
import json
import logging
import os
import queue
import random
import re
import sqlite3
import stat
import sys
import threading
import time
import uuid
from collections import deque
from contextlib import contextmanager, suppress
from pathlib import Path
from hermes_dir_acl import make_user_dir
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
from hermes_state_common import (
TITLE_SOURCE_DERIVED as _TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM as _TITLE_SOURCE_LLM,
TITLE_SOURCE_USER as _TITLE_SOURCE_USER,
escape_like as _escape_like, _placeholders,
stat_db_file_identity as _stat_db_file_identity,
)
from hermes_state_holders import read_only_db_uri
from hermes_state_pidns import holder_pid_checkable
from hermes_state_health import (
STORAGE_CORRUPT, mark_storage_corrupt, note_storage_error, storage_corrupt_reason, storage_state,
)
from hermes_state_errors import (
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_malformed_db_error,
is_malformed_schema_error, is_sqlite_lock_error,
)
from hermes_state_guard import (
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root,
_register_test_instance, _set_last_init_error, get_last_init_error,
)
from hermes_state_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for
from hermes_state_sessions import SessionSessionsMixin
from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension
from hermes_state_portability import SessionPortabilityMixin
from hermes_state_telegram import SessionTelegramTopicsMixin
from hermes_state_profile_repair import SessionProfileRepairMixin
from hermes_state_schema import SessionSchemaMixin
import hermes_state_holders as _state_holders
import hermes_state_lockguard as _lockguard
from hermes_state_lockowners import log_write_lock_holders
from hermes_state_dbfile import (
_connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
_read_sqlite_application_id, _stat_sqlite_sidecar_identity,
_watched_sqlite_sidecar_paths, has_invalid_sqlite_header_preopen, is_zeroed_state_db, quarantine_cross_process_lock,
quarantine_invalid_state_db,
RetiredGenerationCaptureError, capture_retired_wal_generation, refuse_deleted_wal_generation,
)
from hermes_state_messages import SessionMessagesMixin
from hermes_state_prune_rewrite import SessionPruneRewriteMixin
from hermes_state_api_content import SessionApiContentMixin
from hermes_state_coverage import SessionCoverageMixin
from hermes_state_rewind import SessionRewindMixin
from hermes_state_wal import (
_WAL_INCOMPAT_MARKERS, _on_disk_journal_mode, apply_database_pragmas, apply_wal_with_fallback,
)
from hermes_state_repair import _claim_repair_attempt, preflight_db_writability, repair_state_db_schema
from hermes_state_titles import SessionTitlesMixin
from hermes_state_usage import SessionUsageMixin
from hermes_state_maintenance import SessionMaintenanceMixin
from hermes_state_gateway import SessionGatewayMixin
from hermes_state_compression import SessionCompressionMixin
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__)
_MAX_SAFE_MESSAGES = 20_000 # resume/export guard default
def _configured_transcript_limit(key: str, fallback: int = _MAX_SAFE_MESSAGES) -> int:
"""``sessions.<key>`` from config.yaml (lazy import: circular at load), else *fallback*; 0 disables."""
try:
from hermes_cli.config import load_config_readonly
value = (load_config_readonly().get("sessions") or {}).get(key)
if value is None:
return fallback
limit = int(value)
return limit if limit >= 0 else fallback
except Exception:
return fallback
def resolved_max_resume_messages() -> int:
return _configured_transcript_limit("max_resume_messages")
def resolved_max_export_messages() -> int:
return _configured_transcript_limit("max_export_messages")
class SessionResumeTooLargeError(ValueError):
def __init__(
self, message_count: int, limit: int = _MAX_SAFE_MESSAGES, scope: str = "across its lineage",
):
self.message_count, self.limit = message_count, limit
self.scope = scope
super().__init__(
f"This session is too long to reload safely ({message_count} messages; limit {limit}). "
"Start a fresh chat and keep a copy with the dashboard Sessions page's Export action or "
"`hermes sessions export --format md --session-id <id>` (neither is capped), or raise the limit "
"with `hermes config set sessions.max_resume_messages 0`."
)
class SessionExportTooLargeError(ValueError):
def __init__(self, session_id: str, message_count: int, limit: int = _MAX_SAFE_MESSAGES):
self.session_id, self.message_count, self.limit = session_id, message_count, limit
# User-facing refusal shared by every in-memory JSON/JSONL export (CLI and console).
super().__init__(
f"Session '{session_id}' has more than {limit:,} exportable messages; the JSON/JSONL "
"backup is built in memory and capped per session. Use the dashboard Sessions page's streaming "
"Export action, or set sessions.max_export_messages: 0 in config.yaml to disable the guard."
)
def _compression_lock_holder_process_is_dead(holder: str) -> bool:
"""True only when a ``pid=<n>`` lock holder's local PID is provably gone.
Reclaim on kernel proof only: unstructured/same-process holders (another
thread's live lease) and any probe doubt keep the lease until TTL expiry
(PID reuse must never steal a live lease; a wrongly-kept one self-heals).
Foreign/unstamped PID namespaces defer to TTL: see ``hermes_state_pidns``."""
match = re.search(r"(?:^|:)pid=(\d+)(?::|$)", holder or "")
pid = int(match.group(1)) if match else 0
if pid <= 0 or pid == os.getpid():
return False
if not holder_pid_checkable(holder):
return False # foreign / unknown namespace: defer to TTL
if psutil is not None:
try:
return not psutil.pid_exists(pid) # recycled PIDs read as alive (conservative)
except Exception:
return False
# psutil-less fallback is POSIX-only: on Windows os.kill(pid, 0) maps sig=0 to
# CTRL_C_EVENT and can kill the target's console group.
if os.name == "nt":
return False
try:
os.kill(pid, 0) # windows-footgun: ok — nt early-returns just above
except ProcessLookupError:
return True
except (OSError, OverflowError): # PermissionError is an OSError: alive but foreign
return False
return False
# Billing buckets that aren't a routable provider identity: a session that persisted only
# one of these (never ran /model) falls back to the config default. Shared by
# session_gateway_runtime and tui_gateway.server so they cannot drift.
_BARE_BILLING_PROVIDERS = frozenset({"auto", "custom"})
T = TypeVar("T")
# Import-time snapshot lets _default_db_path() detect a re-pointed DEFAULT_DB_PATH
# (tests monkeypatch the constant directly).
DEFAULT_DB_PATH = _IMPORT_DEFAULT_DB_PATH = get_hermes_home() / "state.db"
# Back off from read-only opens after one fails: not per query, but short enough that
# transient fd pressure doesn't strand the read pool.
_READ_OPEN_RETRY_SECONDS = 60.0
# Transient SQLITE_IOERR retry budget for READ-ONLY opens (#100436): a WAL writer's checkpoint/
# reset/frame flush surfaces "disk I/O error" to a concurrent mode=ro reader for a millisecond-
# wide window — the ro connection cannot perform WAL recovery because recovery writes the -shm
# index, which mode=ro refuses. The writer closes the window on its own, so a few short retries
# make the open succeed instead of 500-ing the whole /api/sessions poll (or any other ro opener).
# Deliberately NOT for writable opens: a writer owns the transition, so an IOERR there is a real
# storage/fd problem. A persistent IOERR still exhausts the budget and propagates.
_READ_ONLY_IOERR_RETRY_ATTEMPTS, _READ_ONLY_IOERR_RETRY_BACKOFF_S = 3, 0.05
# SQLite busy handler budget for reads. Under DELETE (rollback-journal) mode a reader needs a SHARED
# lock, which every commit from another process blocks across its journal+db fsyncs; the writer
# connection's 1 s timeout exists for writes (they retry at application level) and starved readers
# into "database is locked" (dashboard 503s, `hermes sessions list` crashes) under a busy gateway.
_READ_BUSY_TIMEOUT_S = 5.0
def _default_db_path() -> Path:
"""Default state DB path at CALL time: a re-pointed ``DEFAULT_DB_PATH`` wins, else
``get_hermes_home()`` is resolved fresh (a runtime HERMES_HOME redirect works regardless of import)."""
return DEFAULT_DB_PATH if DEFAULT_DB_PATH != _IMPORT_DEFAULT_DB_PATH else get_hermes_home() / "state.db"
# Live-DB guard knobs live HERE (not in hermes_state_guard): the hermetic conftest monkeypatches
# ``hermes_state._STATE_DB_GUARD_BYPASS`` (``@pytest.mark.live_system_guard_bypass`` escape hatch)
# and ``_EXTRA_DENY_ROOTS`` (the pre-sandbox root, so custom-HERMES_HOME deployments are covered).
_STATE_DB_GUARD_BYPASS = False
_STATE_DB_GUARD_EXTRA_DENY_ROOTS: tuple[Path, ...] = ()
def _ensure_test_isolation(db_path: Path) -> None:
"""Raise before any connection/mkdir/pragma/byte probe when a pytest-context process
(env OR ancestry) resolves a production DB.
Env alone is not enough: a child spawned with a rebuilt environment loses ``PYTEST_*`` and
``HERMES_HOME`` together, which is precisely the state in which it writes to production (#82770).
"""
if _STATE_DB_GUARD_BYPASS or os.environ.get(_STATE_DB_GUARD_BYPASS_ENV) or not _in_test_context():
return
try:
resolved = Path(db_path).expanduser().resolve()
except Exception:
return
roots = [r for r in (_real_platform_state_root(),) if r is not None]
for extra in _STATE_DB_GUARD_EXTRA_DENY_ROOTS:
try:
roots.append(Path(extra).expanduser().resolve())
except Exception:
continue
for root in roots:
if _is_production_state_db(resolved, root):
raise RuntimeError(
"live-system guard: test attempted to open production "
f"state.db at {resolved} (under real Hermes root {root}). "
"Tests must run against a temporary HERMES_HOME — pass an "
"explicit tmp db_path or let the hermetic conftest redirect "
"HERMES_HOME. If this test genuinely needs the live database, mark it with "
"@pytest.mark.live_system_guard_bypass — or, for a spawned "
f"child process, export {_STATE_DB_GUARD_BYPASS_ENV}=1 in "
"its environment."
)
def _secure_state_db_files(db_path: Path, *, create_main: bool = False) -> None:
"""Create/tighten a writable state database and its sidecars to 0600.
SQLite otherwise creates ``state.db``, ``-wal``, and ``-shm`` according to
the process umask (commonly 0644 under 0022). Read-only SessionDB
attachments never call this helper and remain observational.
Existing files are tightened with ``chmod(2)`` on the path: opening the
file and closing that descriptor would drop every POSIX ``fcntl`` lock the
process holds on its inode — including the locks of an already-open SQLite
connection to the same database. A lock-losing close in one process lets a
sibling's connection take the shared-memory DMS exclusively at its own
close, checkpoint, and unlink the sidecars while long-lived holders
(gateway, desktop ``hermes serve``) keep using the deleted inodes.
"""
if os.name == "nt":
return
main_path = db_path
if create_main:
# O_EXCL: only a brand-new inode gets a descriptor. Opening an existing
# file here and closing it would drop this process's POSIX locks on it.
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_CLOEXEC"):
flags |= os.O_CLOEXEC
try:
fd = os.open(main_path, flags, 0o600)
except FileExistsError:
pass
except IsADirectoryError:
# Not a database file at all; sqlite3.connect() raises the
# canonical error for this, and a directory leaks no row data.
return
else:
os.close(fd)
for path in (
main_path,
db_path.with_name(db_path.name + "-wal"),
db_path.with_name(db_path.name + "-shm"),
):
# fchmod on an fd of a pre-existing file cannot be used here: close(fd)
# would release this process's POSIX locks on that inode, stripping the
# locks of any live SQLite connection to the same database. chmod(2)
# never opens the file, so it leaves the lock state untouched.
try:
st = os.lstat(path)
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
# Refuse a planted symlink exactly like O_NOFOLLOW would.
continue
if not stat.S_ISREG(st.st_mode):
continue
os.chmod(path, 0o600)
# Openings of the background-review harness prompts (agent/background_review.py).
_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:
"""Persisted harness prompt (older builds wrote the forked curator's turns
into real sessions; replaying them hijacks the session)."""
if not isinstance(msg, dict) or msg.get("role") not in {"user", "system"}:
return False
content = msg.get("content")
return isinstance(content, str) and content.lstrip().startswith(_REVIEW_HARNESS_PREFIXES)
def _strip_background_review_harness(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop harness messages and the curator-mode assistant reply that immediately followed each."""
if not messages:
return messages
out: list[dict[str, Any]] = []
skip_next_assistant = False
previous_was_harness = False
for msg in messages:
if _is_background_review_harness_message(msg):
# A consecutive harness prompt occupies the preceding prompt's
# immediate reply slot, so it must not arm another assistant skip.
skip_next_assistant = not previous_was_harness
previous_was_harness = True
continue
if skip_next_assistant:
skip_next_assistant = False
if isinstance(msg, dict) and msg.get("role") == "assistant":
previous_was_harness = False
continue # the curator-mode reply to the harness prompt
previous_was_harness = False
out.append(msg)
return out
# Matches a bare protocol/tool-name marker such as "[memory]" or "[skill_manage]".
_STALE_TOOL_CALL_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
def _is_stale_tool_call_marker_message(msg: dict[str, Any]) -> bool:
"""Assistant tool-call turn whose content is a bare ``[marker]`` (an older
conversation_loop persisted a local template's marker as the final response)."""
if not isinstance(msg, dict) or msg.get("role") != "assistant" or not msg.get("tool_calls"):
return False
content = msg.get("content")
return isinstance(content, str) and bool(_STALE_TOOL_CALL_MARKER_RE.fullmatch(content.strip()))
def _strip_stale_tool_call_markers(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Blank stale ``[marker]`` assistant content (replaying it teaches the model
to keep emitting it); tool_call/result pairing stays intact."""
repaired = 0
for msg in filter(_is_stale_tool_call_marker_message, messages):
msg["content"] = ""
repaired += 1
if repaired:
logger.info(
"Cleared %d stale tool-call marker message(s) while restoring session (#78148)", repaired,
)
return messages
_SESSION_DB_CONSEQUENCE = "Sessions will not be saved until this is fixed."
_NETWORK_DRIVE_HINT = " If the database lives on a network drive, move it to a local disk."
_NETWORK_DRIVE_GLOSS = "the session database could not be opened; it may be on a network or unsupported drive"
_NETWORK_DRIVE_ACTION = (
"Move it to a local disk (`hermes {profile_arg}doctor` shows where it is), then start Hermes again."
)
def format_session_db_unavailable(
prefix: str = "Hermes can't open its session history right now",
*,
details: bool = False,
) -> str:
"""User-facing one-liner: ``<prefix>: <gloss>. <consequence> <action>[ network hint]``.
The cause table lives in ``hermes_state_user_copy`` so CLI, gateway and TUI agree. Chat
surfaces (gateway, TUI) get the one-liner; ``details=True`` (the CLI banner) appends a
``Details: <raw cause>`` line for the raw SQLite text. Network filesystems (NFS/SMB/FUSE/ZFS)
cannot host SQLite's write-ahead log: when the raw cause carries one of those markers the
message names the network-drive suspicion, because ``hermes doctor --fix`` cannot repair a
mount — only moving the file can."""
from hermes_constants import profile_cli_selector
profile_arg = profile_cli_selector()
cause = get_last_init_error()
if not cause:
return (
f"{prefix}. {_SESSION_DB_CONSEQUENCE} Run `hermes {profile_arg}doctor` to check the "
"storage location."
)
from hermes_state_user_copy import describe_storage_failure
failure = describe_storage_failure(cause)
gloss, action, hint = failure.gloss, failure.action, ""
if any(m in cause.lower() for m in _WAL_INCOMPAT_MARKERS):
if failure.cause == "unknown":
gloss, action = _NETWORK_DRIVE_GLOSS, _NETWORK_DRIVE_ACTION.replace("{profile_arg}", profile_arg)
else:
hint = _NETWORK_DRIVE_HINT
text = f"{prefix}: {gloss}. {_SESSION_DB_CONSEQUENCE} {action}{hint}"
if details:
from hermes_state_user_copy import storage_failure_details
text += f"\nDetails: {storage_failure_details(cause)}"
return text
# Auto-repair at most once per DB path per process (no repair loops; serialises concurrent
# web_server / gateway opens on the same malformed file).
_repair_attempted_paths: set[str] = set()
_repair_attempt_lock = threading.Lock()
# Cross-process schema-surgery lock timeout (``_repair_attempt_lock`` covers one interpreter
# only); sized for the slowest legitimate holder (VACUUM, multi-GB DB).
_REPAIR_LOCK_TIMEOUT_SECONDS = 120.0
_IS_WINDOWS = sys.platform == "win32"
def _close_time_checkpoint_configurable() -> bool:
"""Whether this runtime can switch off SQLite's close-time checkpoint (Python 3.12+ ``setconfig``)."""
return (getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None) is not None
and hasattr(sqlite3.Connection, "setconfig"))
def divert_session_transcript_jsonl(session_id: str, messages) -> Optional[Path]:
"""Append pending messages to HERMES_HOME/sessions/<id>.jsonl (state.db was replaced under a
live process). Returns the path, or None if nothing to write."""
sid = str(session_id or "").strip()
if not sid or not messages:
return None
sessions_dir = get_hermes_home() / "sessions"
make_user_dir(sessions_dir, parents=True, exist_ok=True)
path = sessions_dir / f"{sid}.jsonl"
with path.open("a", encoding="utf-8") as handle:
for msg in messages:
if msg is not None:
record = msg if isinstance(msg, dict) else {"content": str(msg)}
handle.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
return path
# Process-wide shared SessionDB registry: long-lived in-process callers share ONE writer
# connection per resolved path via hermes_state_registry.acquire(); one-shots use SessionDB() + close().
def _foreign_state_db_holders(db_path: Path) -> list[tuple[int, str]]:
"""Compatibility delegate to the state-holder authority."""
return _state_holders.foreign_state_db_holders(db_path)
# ── Process-wide shared SessionDB registry (#90837) ── lives in hermes_state_registry.py (acquire /
# release / close_all / release_or_close). Long-lived in-process callers (gateway, tui_gateway, cron,
# in-process tools) share ONE writer connection per resolved path via hermes_state_registry.acquire(); CLI
# one-shots, recovery flows, and read-only cross-profile opens use SessionDB() directly with their own close().
class SessionDB(
SessionSessionsMixin, SessionFtsSetupMixin, SessionSearchMixin, SessionSchemaMixin,
SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin,
SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin,
SessionMessagesMixin, SessionPruneRewriteMixin, SessionApiContentMixin, SessionCoverageMixin, SessionRewindMixin, SessionProfileRepairMixin,
):
"""SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL)."""
# Only these state-owned producers join automatic stale-open reconciliation; messaging/UI
# sources have their own lifecycle owners; unknown sources fail closed.
# See #60609. `recovered` = placeholders `hermes sessions recover` synthesizes for
# orphaned messages (no live owner, never stamped ended_at); without it they are immortal.
_AUTO_PRUNE_STALE_OPEN_SOURCES: tuple[str, ...] = (
"cli", "cron", "kanban", "acp", "api_server", "subagent", "tool", "recovered",
)
# ── Write-contention tuning ──
# SQLite's deterministic busy handler convoys under many hermes processes: keep its
# timeout short (1s) and retry with random jitter. Patience is TIME-based (a sibling
# legitimately holds the lock for seconds: checkpoint at close, VACUUM, recovery, FTS
# optimize); attempt-counted budgets destroyed turns on a healthy store. Transcript
# writes (failure aborts the turn) get the long budget; observation-only activity
# writes sit on the response-critical path and get a sub-second one.
_WRITE_PATIENCE_S, _TRANSCRIPT_WRITE_PATIENCE_S, _ACTIVITY_WRITE_PATIENCE_S = 20.0, 60.0, 0.5
# A live compression lock gets a short wait (compression publishes in seconds), but the lease
# is a correctness boundary: a writer still locked out afterwards is refused.
# Observation-only activity heartbeat/label writes (#76354 review S1): these run on (or adjacent to) the
# response-critical path and must never wait out the full routine patience under contention. Sub-second
# budget; a skipped write is retried naturally at the next heartbeat window.
# A live compression lock gets its own, much shorter budget than the write lock. Compression publishes
# in a couple of seconds, so a brief wait saves the overwhelming majority of concurrent turns (#75083).
# It deliberately stays short: the lease is a correctness boundary, not just a busy signal (see
# test_compression_lease_blocks_non_owner_but_allows_owner_flush), so a writer that is still locked out
# after this budget must still be refused rather than allowed to land a stale turn in a session whose
# compression is genuinely long-running or wedged.
_COMPRESSION_BUSY_WAIT_S = 5.0
_WRITE_RETRY_MIN_S, _WRITE_RETRY_MAX_S = 0.020, 0.150 # fast jitter for the first _SLOW_AFTER_S
_WRITE_RETRY_SLOW_AFTER_S = 2.0
_WRITE_RETRY_SLOW_MIN_S, _WRITE_RETRY_SLOW_MAX_S = 0.250, 1.000
# PASSIVE WAL checkpoint every N successful writes.
_CHECKPOINT_EVERY_N_WRITES = 50
# Bounded FTS ``'merge'`` (ms of lock each) instead of ``'optimize'`` (9-18s per index on a 10GB
# DB, longer than a writer's patience); up to _COMMANDS_PER_PASS per index, stopping on no-progress.
_FTS_MERGE_EVERY_N_WRITES, _FTS_MERGE_MAX_PAGES_PER_INDEX, _FTS_MERGE_COMMANDS_PER_PASS = 1000, 500, 4
# Imports cap lower than exports: an import holds one BEGIN IMMEDIATE.
_IMPORT_MAX_SESSIONS, _IMPORT_MAX_MESSAGES_PER_SESSION, _IMPORT_MAX_TOTAL_MESSAGES = 500, 10_000, 50_000
_IMPORT_MAX_SESSION_BYTES, _IMPORT_MAX_TOTAL_BYTES = 5 * 1024 * 1024, 25 * 1024 * 1024
# Accounting workers retire when idle so a bound-method target can't keep an abandoned SessionDB alive.
_TOKEN_WRITER_IDLE_SECONDS = 30.0
@staticmethod
def _store_system_prompt(conn, system_prompt: Optional[str]) -> Optional[str]:
if system_prompt is None:
return None
prompt_hash = hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()
conn.execute(
"INSERT OR IGNORE INTO system_prompts (hash, prompt) VALUES (?, ?)",
(prompt_hash, system_prompt),
)
return prompt_hash
@staticmethod
def _delete_unreferenced_system_prompts(conn) -> None:
conn.execute(
"DELETE FROM system_prompts WHERE NOT EXISTS ("
"SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash) AND NOT EXISTS ("
"SELECT 1 FROM sessions WHERE sessions.tool_names = system_prompts.hash)"
)
@staticmethod
def _session_row_dict(row: sqlite3.Row) -> dict[str, Any]:
data = dict(row)
for column in ("system_prompt", "tool_names"):
if f"_{column}_resolved" in data:
resolved = data.pop(f"_{column}_resolved")
if column in data:
data[column] = resolved
# Lift /new vs /branch markers out of model_config so list payloads
# (which strip that heavy field) can still tell a reset sibling from
# a genuine fork. Keep this a local helper: tests sometimes replace
# the SessionDB name with a factory lambda.
if not (data.get("_reset_from") and data.get("_branched_from")):
raw = data.get("model_config")
cfg = None
if isinstance(raw, str) and raw:
try:
cfg = json.loads(raw)
except (TypeError, ValueError):
cfg = None
elif isinstance(raw, dict):
cfg = raw
if isinstance(cfg, dict):
if not data.get("_reset_from"):
value = cfg.get("_reset_from")
if isinstance(value, str) and value.strip():
data["_reset_from"] = value.strip()
if not data.get("_branched_from"):
value = cfg.get("_branched_from")
if isinstance(value, str) and value.strip():
data["_branched_from"] = value.strip()
return data
@staticmethod
def _close_connection_quietly(conn: Optional[sqlite3.Connection]) -> None:
"""Close a partially initialized connection without masking its error."""
if conn is None:
return
try:
conn.close()
except Exception:
logger.debug("Could not close a SessionDB connection", exc_info=True)
def _close_conn_logged(self, conn, label: str) -> None:
"""Close *conn*; a failing close leaks a tracked fd: logged at WARNING, never swallowed."""
try:
conn.close()
except Exception as exc:
logger.warning("%s close failed for %s: %s", label, self.db_path, exc)
def __init__(self, db_path: Path | None = None, read_only: bool = False):
self.db_path = db_path or _default_db_path()
_ensure_test_isolation(self.db_path) # before any connection/pragma/mkdir
self.read_only = read_only
# Keep only the opening call site, never a frame (which pins caller locals).
self._creation_site = "unknown"
caller = None
try:
caller = sys._getframe(1)
self._creation_site = (
f"{caller.f_globals.get('__name__', '?')}.{caller.f_code.co_name}:{caller.f_lineno}"
)
except Exception:
pass # Diagnostic metadata must not prevent opening the database.
finally:
del caller
self._lock = threading.Lock()
# Read-path split (WAL only): reads borrow from a BOUNDED read-only pool so they
# never queue behind writer flushes on self._lock (see _read_ctx); unbounded
# per-thread connections pinned fds for the process lifetime and hit EMFILE.
self._read_pool: queue.LifoQueue[sqlite3.Connection] = queue.LifoQueue(maxsize=_READ_POOL_MAX)
# Permits bound PEAK descriptors (the pool bounds only the idle set), shared per
# DATABASE PATH; acquired non-blocking so a permitless reader degrades to the writer lock.
# One permit per live read connection, held from before the open in _get_read_conn() until after the
# close in _close_read_conn(). See _READ_POOL_MAX. Acquired non-blocking on purpose: a reader that
# cannot get a permit must degrade to the writer lock, not queue here — blocking would convert fd
# exhaustion into a stall, which is the same outage with a different stack trace. Permits are shared
# per DATABASE PATH, not per instance: the descriptors they ration belong to the file, and one
# process holds several SessionDB objects on the same state.db (#98573). See _PathReadBudget.
self._read_budget = _read_budget_for(self.db_path)
self._read_permits = self._read_budget.permits
self._read_conns_lock = threading.Lock()
# Set when close() begins; an in-flight reader then closes its own connection
# instead of re-populating a pool nobody will drain again.
self._read_conns_closed = False
# Read-open failure backoff is a TIMESTAMP, not a sticky bool: the likeliest trigger
# is transient EMFILE, and a permanent flag would demote every reader forever.
self._read_open_failed_at = 0.0
self._wal_active, self._write_count = False, 0
# File identity of the opened state.db, compared on every write so an out-of-band
# replace cannot limp through in-place surgery (inode: mv/new-file; application_id: cp).
self._db_file_identity: Optional[tuple] = None
self._db_file_application_id: int = 0
self._db_sidecar_identity: dict[str, tuple] = {}
self._db_replaced = self._db_wal_generation_lost = False
# Durable capture of a lost WAL generation (see _capture_retired_generation): once per handle.
self._retired_generation_capture: Optional[Path] = None
self._retired_capture_lock = threading.Lock()
self._retire_connection: Optional[Callable[[Any], None]] = None
self._connection_pinned = False # one unmatched C reference taken at most once per handle
self._wal_lock_guard: dict = {} # hermes_state_lockguard.hold() record, see _open_writer
self._db_corrupt, self._db_corrupt_reason = False, "" # sticky quarantine (StateDbCorruptError)
self._fts_usermerge_floor_applied = False # one-shot usermerge-floor write guard
self._fts_enabled = self._fts_stale = self._trigram_available = False
# _fts_cjk_loaded: tokenizer on the writer connection; _fts_cjk_available: messages_fts_cjk
# is queryable AND not marked stale.
self._fts_cjk_loaded = self._fts_cjk_available = self._fts_unavailable_warned = False
self._conn = None
# Async token accounting; distinct from self._lock so enqueue/flush never contends with writes.
self._token_queue: deque = deque()
self._token_queue_cond = threading.Condition(threading.Lock())
self._token_writer_thread: Optional[threading.Thread] = None
self._token_writer_stop = self._token_writer_busy = False
self._token_atexit_hook: Optional[Callable[[], None]] = None
# Opened via hermes_state_registry.acquire(): close() releases a refcount instead.
# Set True when this instance is opened via hermes_state_registry.acquire(). Makes close() a no-op so the
# registry (not individual callers) controls the connection lifecycle (#90837).
self._shared_registry_owned = False
initialization_complete = False
try:
if read_only:
self._open_read_only()
else:
# Where SQLite's close-time checkpoint cannot be switched off, a lost-generation handle
# is retired unclosed (see close()). Resolve that capability before opening a writer:
# late cleanup must not import ctypes or look up it in a cleared module dictionary.
if not _close_time_checkpoint_configurable():
self._retire_connection = _prepare_connection_retirement()
self._open_writer()
self._record_db_file_identity()
initialization_complete = True
except Exception as exc:
# Surface WHY via /resume and friends; callers keep their ``_session_db = None`` path.
_set_last_init_error(f"{type(exc).__name__}: {exc}")
raise
finally:
if not initialization_complete:
conn, self._conn = self._conn, None
self._close_connection_quietly(conn)
else:
# Only a successfully opened handle owns a writer connection. Failed
# construction must not leave a diagnostic member behind.
self._read_budget.register(self)
# Test-isolation runs only (gated inside the helper): register
# for the suite-level leak sweep in tests/conftest.py.
_register_test_instance(self)
def _open_writer(self) -> None:
"""Writable open: preflight, zero-byte quarantine, connect + schema (one in-place repair of a
malformed sqlite_master), generation stamp."""
# Never materialize a deleted/archived named profile's home: a multiplexer or Desktop backend
# still holding the profile's route would otherwise re-scaffold it on the next turn (#94590).
mkdir_under_hermes_home(self.db_path.parent)
try:
# Serialize zero-byte check, quarantine, connect and schema commit so concurrent
# openers don't race the absent-path -> schema-commit window.
if not self.db_path.exists() or has_invalid_sqlite_header_preopen(self.db_path):
# The directory must be writable before the lock file can be created in it; the
# directory never vanishes mid-quarantine, so checking it unlocked is race-free.
preflight_db_writability(self.db_path, db_label="state.db", directory_only=True)
with quarantine_cross_process_lock(self.db_path) as lock_acquired:
if not lock_acquired:
logger.warning(
"startup quarantine lock for %s not acquired within 5s; proceeding",
self.db_path,
)
# Preflight under the lock: unlocked, it can list state.db just before a
# concurrent opener renames it aside, and os.access on the vanished path
# then reports a writable DB as "read-only for this user".
preflight_db_writability(self.db_path, db_label="state.db")
self._handle_quarantine_if_invalid(already_locked=lock_acquired)
self._connect_and_init_with_lock_patience()
else:
# Read-only file/sidecar preflight BEFORE the first connection: an actionable message
# instead of an opaque "attempt to write a readonly database" from inside _init_schema.
preflight_db_writability(self.db_path, db_label="state.db")
self._handle_quarantine_if_invalid(already_locked=False)
self._connect_and_init_with_lock_patience()
except sqlite3.DatabaseError as exc:
# A malformed schema fails on the very first statement (before _init_schema), so the
# FTS-rebuild layer never sees it: repair sqlite_master in place (backup first), reopen once.
if not is_malformed_schema_error(exc) or not _claim_repair_attempt(self.db_path):
raise
logger.error(
"state.db schema is malformed (%s) — attempting automatic "
"repair (a backup copy is made first).", exc,
)
self._close_connection_quietly(self._conn)
if not repair_state_db_schema(self.db_path).get("repaired"):
raise
self._connect_and_init_with_lock_patience()
# FTS optimization is OPT-IN (`hermes db optimize`); no background worker races session lifecycle.
self._ensure_db_file_generation()
if self._wal_active:
# OFD copies of the two POSIX locks that keep a sibling's close from unlinking this WAL
# generation: any in-process open()/close() of state.db or -shm cancels SQLite's own
# (howtocorrupt §2.2); these survive it. Lifted in close().
self._wal_lock_guard = _lockguard.hold(self.db_path)
def _open_read_only(self) -> None:
"""Read-only attach for cross-profile aggregation: no schema init, NO write
lock (sidebar polling never contends with that profile's backend); the DB
must exist. FTS flags are probed with SELECTs only, and the connection is
closed on ANY probe failure (malformed schema raises DatabaseError) so a
leaked tracked connection cannot block the forensic backup the writable heal takes next."""
for attempt in range(_READ_ONLY_IOERR_RETRY_ATTEMPTS + 1):
try:
self._conn = conn = self._connect_read_only(timeout=_READ_BUSY_TIMEOUT_S)
try:
apply_database_pragmas(conn, db_label="state.db")
cursor = conn.cursor()
self._fts_enabled = self._fts_table_probe(cursor, "messages_fts") is True
if self._fts_enabled:
self._trigram_available = (
self._fts_table_probe(cursor, "messages_fts_trigram") is True
)
except BaseException:
self._conn = None
self._close_connection_quietly(conn)
raise
return
except sqlite3.OperationalError as ioerr:
# In-flight WAL checkpoint/reset/frame-flush on the writer side can surface
# SQLITE_IOERR to a mode=ro reader (it can't do the -shm recovery the read
# needs). Closes in milliseconds: retry a bounded number of times before
# classifying the store as failed (#100436; see _READ_ONLY_IOERR_RETRY_ATTEMPTS).
# A lock is NOT retried here: the connection already waited _READ_BUSY_TIMEOUT_S,
# and a retry would multiply that wait on blocking callers (TUI, `hermes status`).
transient = _DISK_IO_ERROR_MARKER in str(ioerr).lower()
if attempt >= _READ_ONLY_IOERR_RETRY_ATTEMPTS or not transient:
raise
time.sleep(_READ_ONLY_IOERR_RETRY_BACKOFF_S)
def _connect_read_only(self, timeout: float) -> sqlite3.Connection:
"""``mode=ro`` tracked connection with Row factory. check_same_thread=False: pooled connections
are borrowed by whichever thread reads next; exclusive ownership is enforced by pool checkout."""
conn = _connect_tracked_db(
read_only_db_uri(self.db_path), tracking_path=self.db_path, uri=True,
check_same_thread=False, timeout=timeout, isolation_level=None,
)
conn.row_factory = sqlite3.Row
return conn
def _handle_quarantine_if_invalid(self, already_locked: bool = False) -> None:
"""Quarantine a zero-byte/headerless state.db so a fresh one can open; if quarantine failed,
raise the clear message instead of opening the zeroed file."""
if not (self.db_path.exists() and has_invalid_sqlite_header_preopen(self.db_path)):
return
try:
zsize = self.db_path.stat().st_size
except OSError:
zsize = -1
qpath = quarantine_invalid_state_db(self.db_path, already_locked=already_locked)
where = f"moved aside to {qpath}" if qpath else "left in place (it could not be moved aside)"
msg = (
f"state.db was empty or damaged ({zsize} bytes) and has been {where}; Hermes started with a "
"fresh, empty session database. To bring old sessions back, run "
f"`hermes sessions recover --source {qpath or self.db_path} --inspect-only`, or restore a "
"snapshot with `/snapshot list` then `/snapshot restore <id>` (terminal `hermes` chat only)."
)
logger.error(msg)
_set_last_init_error(msg)
if qpath is None and self.db_path.exists() and has_invalid_sqlite_header_preopen(self.db_path):
raise sqlite3.DatabaseError(msg)
def _open_writer_conn(self) -> sqlite3.Connection:
"""Connect + WAL/pragma/tokenizer setup for a writer connection (no schema init). Short timeout:
jittered application-level retry handles contention, not SQLite's busy handler;
isolation_level=None: explicit BEGIN IMMEDIATE."""
conn = _connect_tracked_db(
str(self.db_path), check_same_thread=False, timeout=1.0, isolation_level=None,
)
try:
conn.row_factory = sqlite3.Row
mode = apply_wal_with_fallback(conn, db_label="state.db")
# "wal" is also the *assumed* mode when the on-disk probe was blocked by a concurrent opener
# (#86515): the lock-free mode=ro read pool needs a confirmed WAL header, so confirm it here.
# Unknown -> reads queue on the writer lock (slow but correct) instead of racing SQLITE_BUSY
# on a file that may really be in rollback-journal mode.
self._wal_active = mode == "wal" and _on_disk_journal_mode(conn) == "wal"
# Existing WAL/SHM files may predate the main-file hardening;
# normalize any sidecars that became visible during WAL setup.
_secure_state_db_files(self.db_path)
apply_database_pragmas(conn, db_label="state.db")
conn.execute("PRAGMA foreign_keys=ON")
self._fts_cjk_loaded = load_fts5_cjk_extension(conn)
except BaseException:
self._close_connection_quietly(conn)
raise
return conn
def _connect_and_init(self) -> None:
# Refuse before sqlite3.connect (under the startup lock) so we cannot mint
# a replacement WAL while a live writer still holds a deleted sidecar inode.
refuse_deleted_wal_generation(self.db_path)
# Create/tighten the main database before sqlite3.connect() so a
# permissive process umask can never expose a fresh profile store.
_secure_state_db_files(self.db_path, create_main=True)
self._conn = self._open_writer_conn()
self._init_schema()
def _connect_and_init_with_lock_patience(self) -> None:
"""Open + init, waiting out a sibling's write lock with jittered patience:
_init_schema's DDL runs on a 1s-timeout connection, so a sibling's VACUUM
or checkpoint used to fail the ENTIRE open and callers disabled
persistence for the whole run. Non-lock errors propagate immediately."""
# Lock contention during open: _init_schema's DDL/reconcile statements run on a 1s-timeout
# connection with no retry, so a sibling process holding the write lock (VACUUM, TRUNCATE checkpoint
# at close, a long FTS pass from an older still-running install) used to fail the ENTIRE open —
# callers then disable persistence for the whole run ("Failed to initialize SessionDB ... database
# is locked", #74478). The store is healthy; wait it out with the same jittered patience the write
# path uses.
deadline = time.monotonic() + self._WRITE_PATIENCE_S
while True:
try:
self._connect_and_init()
return
except sqlite3.OperationalError as exc:
if not is_sqlite_lock_error(exc):
raise
self._close_connection_quietly(self._conn)
now = time.monotonic()
if now >= deadline:
log_write_lock_holders(self.db_path, self._WRITE_PATIENCE_S)
raise
jitter = random.uniform(self._WRITE_RETRY_SLOW_MIN_S, self._WRITE_RETRY_SLOW_MAX_S)
time.sleep(min(jitter, max(deadline - now, 0.001)))
# ── Read-path split ──
def _get_read_conn(self) -> Optional[sqlite3.Connection]:
"""Open a fresh read-only connection, or None when unavailable (callers
return it to self._read_pool). WAL only: WAL readers never block on the
writer, so reads skip self._lock; under DELETE journal mode (NFS fallback)
readers hit SQLITE_BUSY storms, so the legacy locked path stays. Autocommit
reads see everything committed so far (read-your-writes for flush-then-search)."""
if not self._wal_active or self.read_only:
return None
with self._read_conns_lock:
failed_at = self._read_open_failed_at
backing_off = failed_at and time.monotonic() - failed_at < _READ_OPEN_RETRY_SECONDS
if self._read_conns_closed or backing_off:
return None
# Permit BEFORE the open: openers race for permits, not descriptors.
if not self._read_budget.acquire(self):
logger.debug(
"read pool at capacity (%d) for %s; serving this read from the "
"locked writer connection", _READ_POOL_MAX, self.db_path,
)
return None
conn = None # bound before the try so the handlers can close a half-open one
try:
conn = self._connect_read_only(timeout=_READ_BUSY_TIMEOUT_S)
apply_database_pragmas(conn, db_label="state.db")
if self._fts_cjk_loaded: # registers in the connection, not the file: ro is fine
load_fts5_cjk_extension(conn)
except BaseException as exc:
# A half-open connection (open ok, extension load failed) is a live tracked descriptor,
# the leak shape this pool exists to fix; a stranded permit would shrink the read
# path by one slot forever. (Not _close_read_conn: callers release their own permit.)
if conn is not None:
self._close_conn_logged(conn, "partially-opened read conn")
self._read_budget.release()
if not isinstance(exc, sqlite3.Error):
raise
with self._read_conns_lock:
self._read_open_failed_at = time.monotonic()
logger.debug("read-only connection open failed for %s", self.db_path, exc_info=True)
return None
return conn
def _evict_one_idle_read_conn(self) -> bool:
"""Close one idle pooled connection (a peer on the same file wants its permit); never a live one."""
try:
conn = self._read_pool.get_nowait()
except queue.Empty:
return False
self._close_read_conn(conn)
return True
def _close_read_conn(self, conn) -> None:
"""Close a pooled read connection and release its permit even when the close fails (withholding
it would narrow the read path forever). Over-releasing the BoundedSemaphore raises ValueError."""
try:
self._close_conn_logged(conn, "read-conn")
finally:
self._read_budget.release()
def _checkout_read_conn(self) -> Optional[sqlite3.Connection]:
"""Borrow a read connection, opening on a miss; None when the read path is unavailable.
A pool hit costs no permit (the connection already holds one)."""
if not self._wal_active or self.read_only:
return None
try:
return self._read_pool.get_nowait()
except queue.Empty:
return self._get_read_conn()
@contextmanager
def _read_ctx(self, *, reopen: bool = True) -> Iterator[sqlite3.Connection]:
"""Yield a connection for read-only statements: a pooled read-only
connection with NO lock under WAL; otherwise (non-WAL, open failure,
ceiling reached) the writer connection under self._lock — deliberate
degradation: slower beats EMFILE, which the supervisor cannot see.
``reopen=False``: a read after ``close()`` raises instead of reopening the
writer (an optional reader must not hold a home that is being deleted)."""
conn = self._checkout_read_conn()
if conn is not None:
try:
yield conn
finally:
returned = False
with self._read_conns_lock:
if not self._read_conns_closed:
try:
self._read_pool.put_nowait(conn)
returned = True
except queue.Full:
pass
if not returned:
# close() drained the pool (or queue.Full: unreachable while
# permits == maxsize, load-bearing if they drift): surplus.
self._close_read_conn(conn)
return
with self._lock:
if self._conn is None: # close() raced a still-unwinding reader
if not reopen:
raise sqlite3.ProgrammingError(f"SessionDB for {self.db_path} is closed")
self._reopen_after_close_locked(context="read")
conn = cast(sqlite3.Connection, self._conn)
if self._wal_active or self.read_only: # WAL: no SHARED-lock wait; ro: opened with the read budget
yield conn
return
# DELETE-mode writer connection: wait out a sibling's commit like a pooled reader would.
previous_ms = conn.execute("PRAGMA busy_timeout").fetchone()[0]
conn.execute(f"PRAGMA busy_timeout={int(_READ_BUSY_TIMEOUT_S * 1000)}")
try:
yield conn
finally:
with suppress(sqlite3.Error):
conn.execute(f"PRAGMA busy_timeout={int(previous_ms)}")
def _reopen_after_close_locked(self, context: str = "write") -> None:
"""Reopen the writer after ``close()`` raced a live caller (a teardown owner
set ``_conn = None`` while a worker still had a transcript flush to land).
Loud (WARNING) and bounded (only after an explicit close()). Caller holds
``self._lock``. No _init_schema: no DDL races with siblings during teardown."""
if self.read_only:
raise sqlite3.ProgrammingError(
f"SessionDB for {self.db_path} was closed (read-only handle); "
f"cannot serve a {context} after close()"
)
# A reopen resolves the PATH again: a replaced file would be written through stale WAL/shm
# assumptions; a quarantined handle must never hand a fresh connection to a damaged file.
if self._db_corrupt and not (self._db_replaced or self._db_file_was_replaced()):
raise self._corrupt_error(
f"state.db connection for {self.db_path} is quarantined after "
f"structural corruption; refusing to reopen for a {context} "
"after close(). "
)
self._halt_if_db_generation_changed()
logger.warning(
"state.db connection for %s was closed while a %s was still in "
"flight — reopening (teardown/worker race, #94736)", self.db_path, context,
)
try:
self._conn = self._open_writer_conn()
except Exception as exc:
raise sqlite3.OperationalError(