superlocalmemory 3.8.3 → 3.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -0,0 +1,756 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Daemon-owned, cross-process coordinator for canonical SQLite writes.
6
+
7
+ This module implements the 3.8.6 first migration step: one daemon can claim
8
+ one ``memory.db`` path, and all in-process work submitted to that owner is
9
+ serialised by a single connection-owning thread. It intentionally does not
10
+ create an IPC protocol. CLI, MCP, and dashboard clients must reach this
11
+ coordinator through the authenticated daemon boundary as later migration work
12
+ removes their direct writes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from collections import deque
24
+ from contextlib import AbstractContextManager
25
+ from dataclasses import dataclass, field
26
+ from enum import StrEnum
27
+ from pathlib import Path
28
+ from types import MappingProxyType, ModuleType
29
+ from typing import Any, Callable, Literal, Mapping
30
+
31
+ from superlocalmemory.core.file_lock import LockHeldError, exclusive_lock
32
+ from superlocalmemory.storage.write_lock import get_write_lock
33
+
34
+ try:
35
+ import portalocker as _portalocker_import
36
+ except ImportError: # pragma: no cover - pinned production dependency
37
+ _portalocker: ModuleType | None = None
38
+ else:
39
+ _portalocker = _portalocker_import
40
+
41
+
42
+ class WriteCoordinatorError(RuntimeError):
43
+ """Base class for canonical writer failures."""
44
+
45
+
46
+ class OwnershipRequiredError(WriteCoordinatorError):
47
+ """Raised when a process has not claimed canonical write ownership."""
48
+
49
+
50
+ class QueueOverloadedError(WriteCoordinatorError):
51
+ """Raised when bounded coordinator capacity is exhausted."""
52
+
53
+
54
+ class WriteDeadlineExceededError(WriteCoordinatorError):
55
+ """Raised before a queued write can start within its caller deadline."""
56
+
57
+
58
+ class CommandConflictError(WriteCoordinatorError):
59
+ """A durable command id was reused for a different immutable request."""
60
+
61
+
62
+ class CommandRejectedError(WriteCoordinatorError):
63
+ """A deterministic command cannot succeed if replayed unchanged."""
64
+
65
+ def __init__(self, error_code: str = "COMMAND_REJECTED") -> None:
66
+ super().__init__("canonical command was deterministically rejected")
67
+ self.error_code = error_code
68
+
69
+
70
+ class Lane(StrEnum):
71
+ """Scheduling lanes, ordered to protect foreground memory operations."""
72
+
73
+ FOREGROUND = "foreground"
74
+ CONTROL = "control"
75
+ BACKGROUND = "background"
76
+
77
+
78
+ class CommandKind(StrEnum):
79
+ """Durable command families accepted by the canonical writer.
80
+
81
+ A command is intentionally more specific than an SQL statement. The
82
+ coordinator can therefore make the receipt part of the same SQLite commit
83
+ and safely replay an acknowledged request without calling its handler.
84
+ """
85
+
86
+ ADMISSION = "admission"
87
+ DELETE_FACT = "delete_fact"
88
+ UPDATE_FACT = "update_fact"
89
+ ARCHIVE_FACT = "archive_fact"
90
+ MERGE_FACT = "merge_fact"
91
+ SET_FACT_SCOPE = "set_fact_scope"
92
+
93
+
94
+ JsonValue = None | bool | int | float | str | tuple["JsonValue", ...] | Mapping[str, "JsonValue"]
95
+
96
+
97
+ def _freeze_json(value: Any) -> JsonValue:
98
+ """Make JSON-shaped command/receipt data immutable before it crosses lanes."""
99
+ if value is None or isinstance(value, (bool, int, float, str)):
100
+ return value
101
+ if isinstance(value, Mapping):
102
+ return MappingProxyType({str(key): _freeze_json(item) for key, item in value.items()})
103
+ if isinstance(value, (list, tuple)):
104
+ return tuple(_freeze_json(item) for item in value)
105
+ raise TypeError("command payloads and receipts must be JSON-compatible")
106
+
107
+
108
+ def _thaw_json(value: JsonValue) -> Any:
109
+ """Return ordinary JSON-compatible values for stable receipt encoding."""
110
+ if isinstance(value, Mapping):
111
+ return {key: _thaw_json(item) for key, item in value.items()}
112
+ if isinstance(value, tuple):
113
+ return [_thaw_json(item) for item in value]
114
+ return value
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class WriteCommand:
119
+ """An immutable request that can be committed and replayed exactly once."""
120
+
121
+ command_id: str
122
+ kind: CommandKind
123
+ payload: Mapping[str, JsonValue] = field(default_factory=lambda: MappingProxyType({}))
124
+
125
+ def __post_init__(self) -> None:
126
+ if not self.command_id or not isinstance(self.command_id, str):
127
+ raise ValueError("command_id must be a non-empty string")
128
+ object.__setattr__(self, "kind", CommandKind(self.kind))
129
+ frozen = _freeze_json(dict(self.payload))
130
+ if not isinstance(frozen, Mapping): # pragma: no cover - dict input is enforced above
131
+ raise TypeError("command payload must be an object")
132
+ object.__setattr__(self, "payload", frozen)
133
+
134
+ @classmethod
135
+ def create(
136
+ cls,
137
+ kind: CommandKind,
138
+ payload: Mapping[str, Any] | None = None,
139
+ *,
140
+ command_id: str | None = None,
141
+ ) -> "WriteCommand":
142
+ """Create a command with a caller-supplied or generated idempotency key."""
143
+ resolved_id = str(uuid.uuid4()) if command_id is None else command_id
144
+ return cls(resolved_id, CommandKind(kind), payload or {})
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class WriteResult:
149
+ """An immutable receipt produced by a typed command handler."""
150
+
151
+ command_id: str
152
+ kind: CommandKind
153
+ receipt: Mapping[str, JsonValue] = field(default_factory=lambda: MappingProxyType({}))
154
+
155
+ def __post_init__(self) -> None:
156
+ if not self.command_id or not isinstance(self.command_id, str):
157
+ raise ValueError("command_id must be a non-empty string")
158
+ object.__setattr__(self, "kind", CommandKind(self.kind))
159
+ frozen = _freeze_json(dict(self.receipt))
160
+ if not isinstance(frozen, Mapping): # pragma: no cover - dict input is enforced above
161
+ raise TypeError("command receipt must be an object")
162
+ object.__setattr__(self, "receipt", frozen)
163
+
164
+ @classmethod
165
+ def from_receipt(
166
+ cls,
167
+ command: WriteCommand,
168
+ receipt: Mapping[str, Any] | None = None,
169
+ ) -> "WriteResult":
170
+ """Bind a handler receipt to the command being processed."""
171
+ return cls(command.command_id, command.kind, receipt or {})
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class WriteCapability:
176
+ """A worker-thread-only capability for DatabaseManager binding.
177
+
178
+ This is an in-process authority boundary, not a security credential. Its
179
+ coordinator identity token, exact resolved database path, and worker
180
+ identity keep ordinary manager code from attaching a foreign connection.
181
+ """
182
+
183
+ db_path: Path
184
+ owner_id: str
185
+ worker_ident: int
186
+ _issuer: Any = field(repr=False, compare=False)
187
+ _token: object = field(repr=False, compare=False)
188
+
189
+ def _validate(self, db_path: Path) -> None:
190
+ issuer = self._issuer
191
+ if getattr(issuer, "_capability_token", None) is not self._token:
192
+ raise WriteCoordinatorError("untrusted coordinator capability")
193
+ if getattr(issuer, "owner_id", None) != self.owner_id:
194
+ raise WriteCoordinatorError("coordinator capability identity mismatch")
195
+ if getattr(issuer, "db_path", None) != self.db_path:
196
+ raise WriteCoordinatorError("coordinator capability database identity mismatch")
197
+ if getattr(issuer, "_worker_ident", None) != self.worker_ident:
198
+ raise WriteCoordinatorError("coordinator capability worker identity mismatch")
199
+ if threading.get_ident() != self.worker_ident:
200
+ raise WriteCoordinatorError("coordinator capability used outside its worker thread")
201
+ if db_path.expanduser().resolve() != self.db_path:
202
+ raise WriteCoordinatorError("coordinator capability targets a different database")
203
+
204
+
205
+ CommandHandler = Callable[[sqlite3.Connection, WriteCapability, WriteCommand], WriteResult]
206
+
207
+
208
+ _Priority = Literal["foreground", "control", "background"]
209
+ _MAX_QUEUE_DEPTH = 4_096
210
+ _FOREGROUND_BURST = 8
211
+ _SQLITE_BUSY_CODES = {sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED}
212
+
213
+
214
+ @dataclass(slots=True)
215
+ class _Execution:
216
+ sql: str | None
217
+ parameters: tuple[Any, ...]
218
+ lane: Lane
219
+ deadline: float
220
+ command: WriteCommand | None = None
221
+ completion: threading.Event = field(default_factory=threading.Event)
222
+ rows: list[sqlite3.Row] | None = None
223
+ result: WriteResult | None = None
224
+ error: BaseException | None = None
225
+ cancelled: bool = False
226
+
227
+
228
+ class WriteCoordinator:
229
+ """Own one writable ``memory.db`` connection for a daemon lifetime.
230
+
231
+ ``claim_ownership`` is deliberately separate from construction so a caller
232
+ can report an already-running daemon without attempting an SQLite open.
233
+ ``execute`` exists only as the migration adapter for small bounded storage
234
+ commands. Product code will use typed commands as writer families move to
235
+ this coordinator.
236
+ """
237
+
238
+ def __init__(
239
+ self,
240
+ db_path: str | Path,
241
+ *,
242
+ owner_id: str | None = None,
243
+ max_queue_depth: int = _MAX_QUEUE_DEPTH,
244
+ ) -> None:
245
+ if max_queue_depth < 1:
246
+ raise ValueError("max_queue_depth must be at least one")
247
+ self._db_path = Path(db_path).expanduser().resolve()
248
+ self._owner_id = owner_id or str(uuid.uuid4())
249
+ self._lock_path = self._db_path.with_name(f"{self._db_path.name}.writer.lock")
250
+ # Expand-migrate-contract compatibility: legacy in-process writers
251
+ # already serialize on this per-path RLock. The coordinator must join
252
+ # that same critical section until every background/control writer has
253
+ # moved behind typed commands; otherwise the new admission connection
254
+ # can race the materializer connection and reintroduce SQLITE_BUSY.
255
+ self._process_write_lock = get_write_lock(self._db_path)
256
+ self._max_queue_depth = max_queue_depth
257
+ self._ownership_context: AbstractContextManager[int] | None = None
258
+ self._lock_fd: int | None = None
259
+ self._condition = threading.Condition()
260
+ self._queues: dict[Lane, deque[_Execution]] = {
261
+ Lane.FOREGROUND: deque(),
262
+ Lane.CONTROL: deque(),
263
+ Lane.BACKGROUND: deque(),
264
+ }
265
+ self._queued_count = 0
266
+ self._foreground_served = 0
267
+ self._stopping = False
268
+ self._worker: threading.Thread | None = None
269
+ self._lease_release_pending = False
270
+ self._lease_release_reaper: threading.Thread | None = None
271
+ self._worker_ready = threading.Event()
272
+ self._worker_error: BaseException | None = None
273
+ self._worker_ident: int | None = None
274
+ self._capability_token = object()
275
+ self._handlers: dict[CommandKind, CommandHandler] = {}
276
+
277
+ @property
278
+ def db_path(self) -> Path:
279
+ """Resolved canonical database path owned by this coordinator."""
280
+ return self._db_path
281
+
282
+ @property
283
+ def owner_id(self) -> str:
284
+ """Opaque daemon instance identifier recorded in the ownership lease."""
285
+ return self._owner_id
286
+
287
+ def claim_ownership(self) -> bool:
288
+ """Claim the cross-platform owner lease without waiting on another daemon."""
289
+ if _portalocker is None:
290
+ raise WriteCoordinatorError("portalocker is required for canonical writer ownership")
291
+ if self._ownership_context is not None:
292
+ if self._lease_release_pending:
293
+ raise WriteCoordinatorError("canonical writer is still shutting down")
294
+ return True
295
+ self._lock_path.parent.mkdir(parents=True, exist_ok=True)
296
+ context = exclusive_lock(self._lock_path, timeout_s=0.0)
297
+ try:
298
+ fd = context.__enter__()
299
+ except LockHeldError:
300
+ return False
301
+ except OSError as exc:
302
+ raise WriteCoordinatorError(
303
+ f"could not claim canonical writer lock for {self._db_path}"
304
+ ) from exc
305
+
306
+ self._ownership_context = context
307
+ self._lock_fd = fd
308
+ try:
309
+ self._write_owner_metadata(fd)
310
+ except BaseException:
311
+ self.release_ownership()
312
+ raise
313
+ return True
314
+
315
+ def release_ownership(self) -> None:
316
+ """Stop the worker and release the ownership lease, if held."""
317
+ try:
318
+ self.stop()
319
+ except WriteCoordinatorError:
320
+ self._release_lease_after_worker_exit()
321
+ raise
322
+ self._release_lease_if_worker_stopped()
323
+
324
+ def start(self) -> None:
325
+ """Start the sole connection-owning worker after a successful claim."""
326
+ self._require_ownership()
327
+ with self._condition:
328
+ worker = self._worker
329
+ if worker is None:
330
+ if self._lease_release_pending:
331
+ raise WriteCoordinatorError("canonical writer is still shutting down")
332
+ self._stopping = False
333
+ self._worker_error = None
334
+ self._worker_ready.clear()
335
+ worker = threading.Thread(
336
+ target=self._run,
337
+ name=f"slm-write-coordinator-{self._owner_id[:8]}",
338
+ daemon=True,
339
+ )
340
+ self._worker = worker
341
+ worker.start()
342
+ else:
343
+ if self._stopping:
344
+ raise WriteCoordinatorError("canonical writer is stopping")
345
+ if not self._worker_ready.wait(timeout=2.0):
346
+ self.stop()
347
+ raise WriteCoordinatorError("canonical writer did not start within two seconds")
348
+ if self._worker_error is not None:
349
+ error = self._worker_error
350
+ self.stop()
351
+ raise WriteCoordinatorError("canonical writer could not open memory.db") from error
352
+
353
+ def stop(self, deadline_s: float = 2.0) -> None:
354
+ """Stop accepting work and wait briefly for the sole writer thread."""
355
+ worker = self._worker
356
+ if worker is None:
357
+ return
358
+ with self._condition:
359
+ self._stopping = True
360
+ self._condition.notify_all()
361
+ worker.join(timeout=max(0.0, deadline_s))
362
+ if worker.is_alive():
363
+ raise WriteCoordinatorError("canonical writer did not stop before its deadline")
364
+ self._worker = None
365
+
366
+ def _release_lease_after_worker_exit(self) -> None:
367
+ """Release a requested lease only after its live worker has terminated."""
368
+ with self._condition:
369
+ worker = self._worker
370
+ if worker is None or not worker.is_alive() or self._lease_release_pending:
371
+ return
372
+ self._lease_release_pending = True
373
+ reaper = threading.Thread(
374
+ target=self._reap_worker_then_release_lease,
375
+ args=(worker,),
376
+ name=f"slm-write-lease-reaper-{self._owner_id[:8]}",
377
+ daemon=True,
378
+ )
379
+ self._lease_release_reaper = reaper
380
+ reaper.start()
381
+
382
+ def _reap_worker_then_release_lease(self, worker: threading.Thread) -> None:
383
+ """Wait without a shutdown deadline, then relinquish a drained lease."""
384
+ worker.join()
385
+ self._release_lease_if_worker_stopped(expected_worker=worker)
386
+
387
+ def _release_lease_if_worker_stopped(
388
+ self,
389
+ *,
390
+ expected_worker: threading.Thread | None = None,
391
+ ) -> None:
392
+ """Release ownership only when no writer thread can still use the DB."""
393
+ with self._condition:
394
+ worker = self._worker
395
+ if worker is not None:
396
+ if expected_worker is not None and worker is not expected_worker:
397
+ return
398
+ if worker.is_alive():
399
+ return
400
+ self._worker = None
401
+ context = self._ownership_context
402
+ self._ownership_context = None
403
+ self._lock_fd = None
404
+ self._lease_release_pending = False
405
+ self._lease_release_reaper = None
406
+ if context is not None:
407
+ context.__exit__(None, None, None)
408
+
409
+ def execute(
410
+ self,
411
+ sql: str,
412
+ parameters: tuple[Any, ...] = (),
413
+ *,
414
+ priority: _Priority | Lane = Lane.FOREGROUND,
415
+ timeout: float = 1.0,
416
+ ) -> list[sqlite3.Row]:
417
+ """Execute one bounded statement through the daemon-owned connection.
418
+
419
+ The migration adapter rejects empty SQL and expired requests. It is
420
+ intentionally not an escape hatch for slow work or unbounded batches.
421
+ """
422
+ if not isinstance(sql, str) or not sql.strip():
423
+ raise ValueError("sql must be a non-empty statement")
424
+ if timeout <= 0:
425
+ raise ValueError("timeout must be greater than zero")
426
+ lane = self._coerce_lane(priority)
427
+ self.start()
428
+ item = _Execution(
429
+ sql=sql,
430
+ parameters=tuple(parameters),
431
+ lane=lane,
432
+ deadline=time.monotonic() + timeout,
433
+ )
434
+ self._enqueue(item)
435
+ remaining = max(0.0, item.deadline - time.monotonic())
436
+ if not item.completion.wait(remaining):
437
+ with self._condition:
438
+ item.cancelled = True
439
+ raise WriteDeadlineExceededError("canonical write exceeded its caller deadline")
440
+ if item.error is not None:
441
+ raise item.error
442
+ return item.rows or []
443
+
444
+ def register_handler(self, kind: CommandKind, handler: CommandHandler) -> None:
445
+ """Register the sole handler for a durable command family.
446
+
447
+ Handler registration is a daemon-start concern. Refusing changes
448
+ after the worker starts avoids a request observing a partially changed
449
+ command dispatch table.
450
+ """
451
+ if not callable(handler):
452
+ raise TypeError("command handler must be callable")
453
+ command_kind = CommandKind(kind)
454
+ with self._condition:
455
+ if self._worker is not None:
456
+ raise WriteCoordinatorError("command handlers must be registered before start")
457
+ if command_kind in self._handlers:
458
+ raise WriteCoordinatorError(f"handler already registered for {command_kind.value}")
459
+ self._handlers[command_kind] = handler
460
+
461
+ def submit(
462
+ self,
463
+ command: WriteCommand,
464
+ *,
465
+ priority: _Priority | Lane = Lane.FOREGROUND,
466
+ timeout: float = 1.0,
467
+ ) -> WriteResult:
468
+ """Run a typed command and persist its receipt in the same commit."""
469
+ if not isinstance(command, WriteCommand):
470
+ raise TypeError("command must be a WriteCommand")
471
+ if timeout <= 0:
472
+ raise ValueError("timeout must be greater than zero")
473
+ lane = self._coerce_lane(priority)
474
+ self.start()
475
+ item = _Execution(
476
+ sql=None,
477
+ parameters=(),
478
+ lane=lane,
479
+ deadline=time.monotonic() + timeout,
480
+ command=command,
481
+ )
482
+ self._enqueue(item)
483
+ remaining = max(0.0, item.deadline - time.monotonic())
484
+ if not item.completion.wait(remaining):
485
+ with self._condition:
486
+ item.cancelled = True
487
+ raise WriteDeadlineExceededError("canonical write exceeded its caller deadline")
488
+ if item.error is not None:
489
+ raise item.error
490
+ if item.result is None: # pragma: no cover - defensive worker invariant
491
+ raise WriteCoordinatorError("canonical command completed without a receipt")
492
+ return item.result
493
+
494
+ def _enqueue(self, item: _Execution) -> None:
495
+ with self._condition:
496
+ if self._stopping:
497
+ raise WriteCoordinatorError("canonical writer is stopping")
498
+ if self._queued_count >= self._max_queue_depth:
499
+ raise QueueOverloadedError("canonical writer queue is full")
500
+ self._queues[item.lane].append(item)
501
+ self._queued_count += 1
502
+ self._condition.notify()
503
+
504
+ def _run(self) -> None:
505
+ self._worker_ident = threading.get_ident()
506
+ try:
507
+ conn = self._open_connection()
508
+ except BaseException as exc:
509
+ self._worker_error = exc
510
+ self._worker_ready.set()
511
+ return
512
+ self._worker_ready.set()
513
+ try:
514
+ while True:
515
+ item = self._next_item()
516
+ if item is None:
517
+ return
518
+ self._execute_item(conn, item)
519
+ finally:
520
+ self._worker_ident = None
521
+ conn.close()
522
+
523
+ def _open_connection(self) -> sqlite3.Connection:
524
+ conn = sqlite3.connect(str(self._db_path), timeout=1.0)
525
+ conn.row_factory = sqlite3.Row
526
+ conn.execute("PRAGMA foreign_keys=ON")
527
+ conn.execute("PRAGMA busy_timeout=1000")
528
+ conn.execute("PRAGMA journal_mode=WAL")
529
+ return conn
530
+
531
+ def _next_item(self) -> _Execution | None:
532
+ with self._condition:
533
+ while self._queued_count == 0 and not self._stopping:
534
+ self._condition.wait()
535
+ if self._queued_count == 0:
536
+ return None
537
+
538
+ lane = self._select_lane()
539
+ item = self._queues[lane].popleft()
540
+ self._queued_count -= 1
541
+ return item
542
+
543
+ def _select_lane(self) -> Lane:
544
+ foreground = self._queues[Lane.FOREGROUND]
545
+ control = self._queues[Lane.CONTROL]
546
+ background = self._queues[Lane.BACKGROUND]
547
+ can_continue_foreground = self._foreground_served < _FOREGROUND_BURST
548
+ if foreground and (can_continue_foreground or not (control or background)):
549
+ self._foreground_served += 1
550
+ return Lane.FOREGROUND
551
+ if control:
552
+ self._foreground_served = 0
553
+ return Lane.CONTROL
554
+ if background:
555
+ self._foreground_served = 0
556
+ return Lane.BACKGROUND
557
+ self._foreground_served = 0
558
+ return Lane.FOREGROUND
559
+
560
+ def _execute_item(self, conn: sqlite3.Connection, item: _Execution) -> None:
561
+ if item.cancelled or time.monotonic() >= item.deadline:
562
+ item.error = WriteDeadlineExceededError("canonical write expired before execution")
563
+ item.completion.set()
564
+ return
565
+ try:
566
+ with self._process_write_lock:
567
+ synchronous = "FULL" if item.lane is Lane.FOREGROUND else "NORMAL"
568
+ conn.execute(f"PRAGMA synchronous={synchronous}")
569
+ conn.execute("BEGIN IMMEDIATE")
570
+ if item.command is not None:
571
+ item.result = self._execute_command(conn, item.command)
572
+ else:
573
+ if item.sql is None: # pragma: no cover - execution invariant
574
+ raise WriteCoordinatorError("missing coordinator SQL command")
575
+ cursor = conn.execute(item.sql, item.parameters)
576
+ item.rows = cursor.fetchall()
577
+ conn.commit()
578
+ except WriteCoordinatorError as exc:
579
+ conn.rollback()
580
+ item.error = exc
581
+ except sqlite3.Error as exc:
582
+ conn.rollback()
583
+ if self._is_busy(exc):
584
+ item.error = QueueOverloadedError("canonical writer is temporarily busy")
585
+ else:
586
+ item.error = WriteCoordinatorError("canonical write command was rejected")
587
+ item.error.__cause__ = exc
588
+ except BaseException as exc:
589
+ conn.rollback()
590
+ item.error = WriteCoordinatorError("canonical write command failed")
591
+ item.error.__cause__ = exc
592
+ finally:
593
+ item.completion.set()
594
+
595
+ def _execute_command(self, conn: sqlite3.Connection, command: WriteCommand) -> WriteResult:
596
+ """Dispatch one command and atomically append its immutable receipt."""
597
+ payload = _thaw_json(command.payload)
598
+ if not isinstance(payload, dict):
599
+ raise WriteCoordinatorError("command payload must be an object")
600
+ request_hash = _required_text(payload, "request_hash")
601
+ profile_id = _required_text(payload, "profile_id")
602
+ idempotency_key = _required_text(payload, "idempotency_key")
603
+ existing = conn.execute(
604
+ "SELECT command_kind, request_hash, profile_id, idempotency_key, "
605
+ "receipt_json FROM write_commits WHERE command_id = ?",
606
+ (command.command_id,),
607
+ ).fetchone()
608
+ if existing is not None:
609
+ if existing["command_kind"] != command.kind.value:
610
+ raise CommandConflictError("command id was already committed with a different kind")
611
+ if (
612
+ existing["request_hash"] != request_hash
613
+ or existing["profile_id"] != profile_id
614
+ or existing["idempotency_key"] != idempotency_key
615
+ ):
616
+ raise CommandConflictError(
617
+ "command id was already committed for a different request"
618
+ )
619
+ try:
620
+ receipt = json.loads(existing["receipt_json"])
621
+ except (TypeError, json.JSONDecodeError) as exc:
622
+ raise WriteCoordinatorError("stored command receipt is invalid") from exc
623
+ if not isinstance(receipt, dict):
624
+ raise WriteCoordinatorError("stored command receipt is not an object")
625
+ return WriteResult(command.command_id, command.kind, receipt)
626
+
627
+ handler = self._handlers.get(command.kind)
628
+ if handler is None:
629
+ raise WriteCoordinatorError(f"no handler registered for {command.kind.value}")
630
+ worker_ident = self._worker_ident
631
+ if worker_ident is None: # pragma: no cover - worker-only call
632
+ raise WriteCoordinatorError("typed command dispatch requires the writer worker")
633
+ capability = WriteCapability(
634
+ self._db_path,
635
+ self._owner_id,
636
+ worker_ident,
637
+ self,
638
+ self._capability_token,
639
+ )
640
+ result = handler(conn, capability, command)
641
+ if not isinstance(result, WriteResult):
642
+ raise WriteCoordinatorError("command handler must return WriteResult")
643
+ if result.command_id != command.command_id or result.kind is not command.kind:
644
+ raise WriteCoordinatorError(
645
+ "command handler returned a receipt for a different command"
646
+ )
647
+ receipt = _thaw_json(result.receipt)
648
+ if not isinstance(payload, dict) or not isinstance(receipt, dict):
649
+ raise WriteCoordinatorError("admission payload and receipt must be objects")
650
+ _reject_receipt_memory_content(receipt)
651
+ journal_id = _required_text(payload, "journal_id")
652
+ operation_id = _required_text(receipt, "operation_id")
653
+ next_sequence = int(
654
+ conn.execute(
655
+ "SELECT COALESCE(MAX(commit_sequence), 0) + 1 FROM write_commits"
656
+ ).fetchone()[0]
657
+ )
658
+ receipt.setdefault("commit_sequence", next_sequence)
659
+ committed_result = WriteResult(command.command_id, command.kind, receipt)
660
+ receipt_json = json.dumps(
661
+ receipt,
662
+ sort_keys=True,
663
+ separators=(",", ":"),
664
+ ensure_ascii=False,
665
+ )
666
+ conn.execute(
667
+ "INSERT INTO write_commits("
668
+ "commit_sequence, command_id, journal_id, command_kind, request_hash, "
669
+ "profile_id, idempotency_key, operation_id, receipt_json, committed_at"
670
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
671
+ (
672
+ next_sequence,
673
+ command.command_id,
674
+ journal_id,
675
+ command.kind.value,
676
+ request_hash,
677
+ profile_id,
678
+ idempotency_key,
679
+ operation_id,
680
+ receipt_json,
681
+ time.time(),
682
+ ),
683
+ )
684
+ return committed_result
685
+
686
+ def _write_owner_metadata(self, fd: int) -> None:
687
+ metadata = {
688
+ "owner_id": self._owner_id,
689
+ "pid": os.getpid(),
690
+ "database": str(self._db_path),
691
+ "claimed_at_ms": int(time.time() * 1000),
692
+ }
693
+ payload = json.dumps(metadata, sort_keys=True).encode("utf-8") + b"\n"
694
+ os.ftruncate(fd, 0)
695
+ os.lseek(fd, 0, os.SEEK_SET)
696
+ os.write(fd, payload)
697
+ os.fsync(fd)
698
+
699
+ def _require_ownership(self) -> None:
700
+ if self._ownership_context is None:
701
+ raise OwnershipRequiredError("canonical writer ownership has not been claimed")
702
+
703
+ @staticmethod
704
+ def _coerce_lane(priority: _Priority | Lane) -> Lane:
705
+ try:
706
+ return Lane(priority)
707
+ except ValueError as exc:
708
+ raise ValueError(f"unknown coordinator priority: {priority}") from exc
709
+
710
+ @staticmethod
711
+ def _is_busy(error: sqlite3.Error) -> bool:
712
+ code = getattr(error, "sqlite_errorcode", None)
713
+ return code in _SQLITE_BUSY_CODES
714
+
715
+
716
+ def _required_text(value: Mapping[str, Any], key: str) -> str:
717
+ candidate = value.get(key)
718
+ if not isinstance(candidate, str) or not candidate:
719
+ raise WriteCoordinatorError(f"admission command is missing {key}")
720
+ return candidate
721
+
722
+
723
+ def _reject_receipt_memory_content(value: Any) -> None:
724
+ """Keep the immutable command ledger free of deleted or edited memory text."""
725
+ if isinstance(value, Mapping):
726
+ for key, child in value.items():
727
+ if key.casefold() in {
728
+ "content",
729
+ "content_preview",
730
+ "raw_content",
731
+ "memory_content",
732
+ "source_content",
733
+ }:
734
+ raise WriteCoordinatorError(
735
+ "immutable command receipts must contain metadata only"
736
+ )
737
+ _reject_receipt_memory_content(child)
738
+ elif isinstance(value, (list, tuple)):
739
+ for child in value:
740
+ _reject_receipt_memory_content(child)
741
+
742
+
743
+ __all__ = [
744
+ "CommandConflictError",
745
+ "CommandKind",
746
+ "CommandRejectedError",
747
+ "Lane",
748
+ "OwnershipRequiredError",
749
+ "QueueOverloadedError",
750
+ "WriteCapability",
751
+ "WriteCommand",
752
+ "WriteCoordinator",
753
+ "WriteCoordinatorError",
754
+ "WriteDeadlineExceededError",
755
+ "WriteResult",
756
+ ]