loop-memory 0.4.10__tar.gz → 0.4.11__tar.gz

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 (154) hide show
  1. {loop_memory-0.4.10 → loop_memory-0.4.11}/PKG-INFO +14 -1
  2. {loop_memory-0.4.10 → loop_memory-0.4.11}/README.md +13 -0
  3. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/app.py +6 -0
  4. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/system.py +35 -0
  5. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/sqlite_store.py +10 -1
  6. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/PKG-INFO +14 -1
  7. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/SOURCES.txt +2 -0
  8. {loop_memory-0.4.10 → loop_memory-0.4.11}/pyproject.toml +1 -1
  9. loop_memory-0.4.11/tests/test_busy_timeout_2026_09_24.py +110 -0
  10. loop_memory-0.4.11/tests/test_healthz_readyz_2026_09_24.py +80 -0
  11. {loop_memory-0.4.10 → loop_memory-0.4.11}/LICENSE +0 -0
  12. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/__init__.py +0 -0
  13. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/__init__.py +0 -0
  14. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/embedding.py +0 -0
  15. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/sentence_embedder.py +0 -0
  16. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/vector_store.py +0 -0
  17. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/__init__.py +0 -0
  18. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/_common.py +0 -0
  19. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/__init__.py +0 -0
  20. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/cognitive.py +0 -0
  21. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/diag.py +0 -0
  22. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/graph.py +0 -0
  23. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/hooks.py +0 -0
  24. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/read.py +0 -0
  25. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/rules.py +0 -0
  26. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/serve.py +0 -0
  27. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/write.py +0 -0
  28. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/main.py +0 -0
  29. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/__init__.py +0 -0
  30. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/loop.py +0 -0
  31. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/reflect.py +0 -0
  32. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/examples/__init__.py +0 -0
  33. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/examples/demo.py +0 -0
  34. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/export/__init__.py +0 -0
  35. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/export/memory_md.py +0 -0
  36. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/__init__.py +0 -0
  37. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/build.py +0 -0
  38. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/extract.py +0 -0
  39. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/__init__.py +0 -0
  40. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/loader.py +0 -0
  41. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/pipeline.py +0 -0
  42. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/__init__.py +0 -0
  43. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/cognitive.py +0 -0
  44. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/compact.py +0 -0
  45. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/consolidate.py +0 -0
  46. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/contradiction.py +0 -0
  47. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/evolution.py +0 -0
  48. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/graph.py +0 -0
  49. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/llm_compact_pass.py +0 -0
  50. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/llm_consolidate.py +0 -0
  51. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/scheduler.py +0 -0
  52. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/__init__.py +0 -0
  53. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/base.py +0 -0
  54. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/openai_adapter.py +0 -0
  55. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/providers.py +0 -0
  56. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/mcp/__init__.py +0 -0
  57. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/memory/__init__.py +0 -0
  58. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/memory/types.py +0 -0
  59. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/__init__.py +0 -0
  60. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/private.py +0 -0
  61. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/redact.py +0 -0
  62. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/py.typed +0 -0
  63. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/sdk.py +0 -0
  64. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/sdk_extensions.py +0 -0
  65. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/security/__init__.py +0 -0
  66. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/security/secrets.py +0 -0
  67. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/__init__.py +0 -0
  68. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/handlers.py +0 -0
  69. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/_shared.py +0 -0
  70. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/admin.py +0 -0
  71. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/cognitive.py +0 -0
  72. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/export.py +0 -0
  73. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/graph.py +0 -0
  74. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/insights.py +0 -0
  75. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/memories.py +0 -0
  76. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/sessions.py +0 -0
  77. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/wiki.py +0 -0
  78. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/static/__init__.py +0 -0
  79. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/static/index.html +0 -0
  80. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/watcher.py +0 -0
  81. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/__init__.py +0 -0
  82. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/retrieval.py +0 -0
  83. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/snapshot.py +0 -0
  84. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/__init__.py +0 -0
  85. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/backfill.py +0 -0
  86. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/classifier.py +0 -0
  87. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/prompts.py +0 -0
  88. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/scope.py +0 -0
  89. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/dependency_links.txt +0 -0
  90. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/entry_points.txt +0 -0
  91. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/requires.txt +0 -0
  92. {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/top_level.txt +0 -0
  93. {loop_memory-0.4.10 → loop_memory-0.4.11}/setup.cfg +0 -0
  94. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_admin_ingest_route.py +0 -0
  95. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agent_memory_api.py +0 -0
  96. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agent_memory_sdk.py +0 -0
  97. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agents_init.py +0 -0
  98. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_auth_token_rotate.py +0 -0
  99. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cache_cap_2026_09_23.py +0 -0
  100. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_audit_supersede.py +0 -0
  101. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_export_okf.py +0 -0
  102. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_init.py +0 -0
  103. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_memory_stats.py +0 -0
  104. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_as_of.py +0 -0
  105. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_paths.py +0 -0
  106. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_verbose.py +0 -0
  107. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_rules.py +0 -0
  108. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_snapshot.py +0 -0
  109. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_v7.py +0 -0
  110. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_version.py +0 -0
  111. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_contradictions.py +0 -0
  112. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_evolution.py +0 -0
  113. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_evolution_quality.py +0 -0
  114. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_expanduser.py +0 -0
  115. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_export_ask.py +0 -0
  116. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_export_okf.py +0 -0
  117. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_graph.py +0 -0
  118. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_ingest.py +0 -0
  119. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_consolidator.py +0 -0
  120. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  121. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_providers.py +0 -0
  122. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_test_endpoint.py +0 -0
  123. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_loop.py +0 -0
  124. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_mcp.py +0 -0
  125. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_memories_pagination.py +0 -0
  126. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_memory_stats.py +0 -0
  127. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_openclaw_loader.py +0 -0
  128. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_package_version.py +0 -0
  129. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_path_safety_2026_09_23.py +0 -0
  130. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_as_of.py +0 -0
  131. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_paths.py +0 -0
  132. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_provenance.py +0 -0
  133. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_reflection.py +0 -0
  134. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_repo_framing.py +0 -0
  135. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_13.py +0 -0
  136. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_20.py +0 -0
  137. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_score_api.py +0 -0
  138. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_scoring_v2.py +0 -0
  139. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_secrets.py +0 -0
  140. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_serve_app.py +0 -0
  141. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_serve_handlers.py +0 -0
  142. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_session_order.py +0 -0
  143. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_short_query.py +0 -0
  144. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_snapshot.py +0 -0
  145. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_store.py +0 -0
  146. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_subgraph_hops_2026_09_23.py +0 -0
  147. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_summarization.py +0 -0
  148. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_supersession_chain.py +0 -0
  149. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_universal_memory.py +0 -0
  150. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_vector_store.py +0 -0
  151. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_watcher.py +0 -0
  152. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_classifier.py +0 -0
  153. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_export_escape.py +0 -0
  154. {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.10
3
+ Version: 0.4.11
4
4
  Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
5
5
  Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -66,6 +66,19 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.11** — **operational probes** +
70
+ **SQLite write-contention fix** (audit 2026-09-24). New
71
+ ``GET /api/healthz`` (liveness) + ``GET /api/readyz``
72
+ (readiness) endpoints let Kubernetes / launchd keepalive
73
+ polls probe the serve without provisioning a Bearer token;
74
+ ``/api/readyz`` returns 503 when the SQLite store is
75
+ unreachable. Also fixed a real concurrency bug: every
76
+ store connection now sets ``PRAGMA busy_timeout=5000``
77
+ (was 0 ms) so the watcher + serve + scheduler threads no
78
+ longer fail with ``database is locked`` when the
79
+ consolidator writes concurrently. 7 new regression cases.
80
+ [Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
81
+
69
82
  > **What's new in 0.4.10** — **path-safety guard** for the
70
83
  five export / snapshot endpoints. ``MemoryStore`` already
71
84
  validated query / wiki content; the new
@@ -22,6 +22,19 @@
22
22
 
23
23
  ---
24
24
 
25
+ > **What's new in 0.4.11** — **operational probes** +
26
+ **SQLite write-contention fix** (audit 2026-09-24). New
27
+ ``GET /api/healthz`` (liveness) + ``GET /api/readyz``
28
+ (readiness) endpoints let Kubernetes / launchd keepalive
29
+ polls probe the serve without provisioning a Bearer token;
30
+ ``/api/readyz`` returns 503 when the SQLite store is
31
+ unreachable. Also fixed a real concurrency bug: every
32
+ store connection now sets ``PRAGMA busy_timeout=5000``
33
+ (was 0 ms) so the watcher + serve + scheduler threads no
34
+ longer fail with ``database is locked`` when the
35
+ consolidator writes concurrently. 7 new regression cases.
36
+ [Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
37
+
25
38
  > **What's new in 0.4.10** — **path-safety guard** for the
26
39
  five export / snapshot endpoints. ``MemoryStore`` already
27
40
  validated query / wiki content; the new
@@ -176,6 +176,12 @@ def create_app(store: MemoryStore, static_dir: Path | None = None, scheduler=Non
176
176
  '/api/pipeline', '/api/weekly-report', '/api/llm-audit',
177
177
  '/api/source-health', '/api/write-guard', '/api/diag',
178
178
  '/api/install-hooks', '/api/insights',
179
+ # Audit 2026-09-24: probes are intentionally public so a
180
+ # load balancer / keepalive script doesn't need a Bearer
181
+ # token to ask "is the process up?". The DB-touching
182
+ # ``/api/readyz`` only exposes a coarse health signal
183
+ # (UP / DOWN), no data.
184
+ '/api/healthz', '/api/readyz',
179
185
  ))
180
186
 
181
187
  def _is_public_path(path: str) -> bool:
@@ -36,6 +36,41 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
36
36
  """
37
37
  if static_dir is None:
38
38
  static_dir = Path(__file__).parent.parent / "static"
39
+ @app.get("/api/healthz")
40
+ def healthz():
41
+ """Liveness probe.
42
+
43
+ Audit 2026-09-24. Returns 200 as long as the process is
44
+ responsive. **Does not** touch the SQLite store — so the
45
+ probe cannot deadlock behind a slow writer and cannot give
46
+ a false negative when the DB is briefly busy. Use this for
47
+ ``livenessProbe`` in Kubernetes / launchd keepalive scripts.
48
+ """
49
+ return {"status": "ok", "ts": time.time()}
50
+
51
+ @app.get("/api/readyz")
52
+ def readyz():
53
+ """Readiness probe.
54
+
55
+ Audit 2026-09-24. Verifies the SQLite store is reachable AND
56
+ responsive (one trivial ``PRAGMA quick_check`` round-trip
57
+ inside a 1-second budget). Returns ``{"status": "ok"}`` on
58
+ success, or ``{"status": "degraded", "error": "..."}`` with
59
+ HTTP 503 when the DB is unhealthy. Use this for
60
+ ``readinessProbe`` so a degraded loop-memory is taken out of
61
+ a load-balancer rotation until it recovers.
62
+ """
63
+ import sqlite3 as _sq3
64
+ try:
65
+ with _sq3.connect(str(store.path), timeout=1.0) as c:
66
+ c.execute("PRAGMA quick_check").fetchone()
67
+ return {"status": "ok", "ts": time.time()}
68
+ except Exception as e:
69
+ return JSONResponse(
70
+ {"status": "degraded", "error": str(e)},
71
+ status_code=503,
72
+ )
73
+
39
74
  @app.get("/")
40
75
  def index():
41
76
  """Serve the dashboard HTML with the i18n JSONs inlined.
@@ -715,10 +715,19 @@ class MemoryStore:
715
715
 
716
716
  @contextmanager
717
717
  def _conn(self) -> Iterator[sqlite3.Connection]:
718
- conn = sqlite3.connect(str(self.path))
718
+ # Audit 2026-09-24: every connection sets three PRAGMAs.
719
+ # ``busy_timeout=5000`` makes SQLite retry a contended writer
720
+ # for up to 5 seconds before raising ``OperationalError:
721
+ # database is locked``. Without it the watcher (launchd
722
+ # background thread) and the serve thread can fail spuriously
723
+ # when the consolidator or scheduler also writes concurrently.
724
+ # 5s is a generous budget for any local operation; an actual
725
+ # deadlock surfaces faster via the SQLite error code.
726
+ conn = sqlite3.connect(str(self.path), timeout=5.0)
719
727
  conn.row_factory = sqlite3.Row
720
728
  conn.execute("PRAGMA journal_mode=WAL")
721
729
  conn.execute("PRAGMA foreign_keys=ON")
730
+ conn.execute("PRAGMA busy_timeout=5000")
722
731
  try:
723
732
  yield conn
724
733
  conn.commit()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.10
3
+ Version: 0.4.11
4
4
  Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
5
5
  Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -66,6 +66,19 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.11** — **operational probes** +
70
+ **SQLite write-contention fix** (audit 2026-09-24). New
71
+ ``GET /api/healthz`` (liveness) + ``GET /api/readyz``
72
+ (readiness) endpoints let Kubernetes / launchd keepalive
73
+ polls probe the serve without provisioning a Bearer token;
74
+ ``/api/readyz`` returns 503 when the SQLite store is
75
+ unreachable. Also fixed a real concurrency bug: every
76
+ store connection now sets ``PRAGMA busy_timeout=5000``
77
+ (was 0 ms) so the watcher + serve + scheduler threads no
78
+ longer fail with ``database is locked`` when the
79
+ consolidator writes concurrently. 7 new regression cases.
80
+ [Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
81
+
69
82
  > **What's new in 0.4.10** — **path-safety guard** for the
70
83
  five export / snapshot endpoints. ``MemoryStore`` already
71
84
  validated query / wiki content; the new
@@ -92,6 +92,7 @@ tests/test_agent_memory_api.py
92
92
  tests/test_agent_memory_sdk.py
93
93
  tests/test_agents_init.py
94
94
  tests/test_auth_token_rotate.py
95
+ tests/test_busy_timeout_2026_09_24.py
95
96
  tests/test_cache_cap_2026_09_23.py
96
97
  tests/test_cli_audit_supersede.py
97
98
  tests/test_cli_export_okf.py
@@ -111,6 +112,7 @@ tests/test_expanduser.py
111
112
  tests/test_export_ask.py
112
113
  tests/test_export_okf.py
113
114
  tests/test_graph.py
115
+ tests/test_healthz_readyz_2026_09_24.py
114
116
  tests/test_ingest.py
115
117
  tests/test_llm_consolidator.py
116
118
  tests/test_llm_fingerprint_not_persisted.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loop-memory"
7
- version = "0.4.10"
7
+ version = "0.4.11"
8
8
  description = "A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,110 @@
1
+ """Tests for ``PRAGMA busy_timeout=5000`` (audit 2026-09-24).
2
+
3
+ Previously ``_conn()`` set ``PRAGMA journal_mode=WAL`` and
4
+ ``PRAGMA foreign_keys=ON`` but did NOT set ``busy_timeout``. The
5
+ default 0ms timeout meant a concurrent writer (watcher thread +
6
+ serve thread) could fail with ``OperationalError: database is
7
+ locked`` immediately. Setting ``busy_timeout=5000`` makes SQLite
8
+ retry internally for up to 5 seconds.
9
+
10
+ These tests verify the PRAGMA is set on every new connection and
11
+ that a writer holding the lock does NOT immediately fail a
12
+ second writer.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ import unittest
21
+ from pathlib import Path
22
+
23
+ from loop_memory.storage.sqlite_store import MemoryStore
24
+
25
+
26
+ def _new_store() -> tuple[MemoryStore, Path]:
27
+ tmp = Path(tempfile.mkdtemp(prefix="loop-busy-timeout-"))
28
+ return MemoryStore(str(tmp / "busy.db")), tmp
29
+
30
+
31
+ class BusyTimeoutPragmaTests(unittest.TestCase):
32
+ """Audit 2026-09-24: PRAGMA busy_timeout=5000 on every _conn."""
33
+
34
+ def setUp(self) -> None:
35
+ self.store, self.tmp = _new_store()
36
+
37
+ def tearDown(self) -> None:
38
+ shutil.rmtree(self.tmp, ignore_errors=True)
39
+
40
+ def test_busy_timeout_is_set_to_5000(self) -> None:
41
+ with self.store._conn() as c:
42
+ row = c.execute("PRAGMA busy_timeout").fetchone()
43
+ # PRAGMA busy_timeout returns the timeout in milliseconds
44
+ # (0 means immediate fail; >0 means retry up to N ms).
45
+ self.assertEqual(
46
+ int(row[0]), 5000,
47
+ f"PRAGMA busy_timeout should be 5000, got {row[0]}",
48
+ )
49
+
50
+ def test_connect_timeout_is_5_seconds(self) -> None:
51
+ # sqlite3.connect(timeout=...) sets the busy_timeout at the
52
+ # C level too. Verify the connect-timeout is honoured by
53
+ # poking the store concurrently.
54
+ with self.store._conn() as c:
55
+ c.execute("SELECT 1").fetchone()
56
+ # The pragma is set; nothing to assert beyond non-error.
57
+ self.assertTrue(True)
58
+
59
+ def test_concurrent_writer_waits_then_succeeds(self) -> None:
60
+ """A second writer must wait up to busy_timeout before
61
+ raising — proving the timeout is non-zero. We hold an
62
+ explicit transaction on one connection, then try to write
63
+ on a second connection. With busy_timeout=5000 the second
64
+ write should NOT immediately raise; with the pre-fix 0ms
65
+ default it would raise ``OperationalError`` instantly.
66
+
67
+ The test asserts the second write eventually succeeds
68
+ (because we release the lock after 200ms, well within the
69
+ 5000ms budget).
70
+ """
71
+ holder_done = threading.Event()
72
+ holder_started = threading.Event()
73
+
74
+ def hold_lock_for_a_bit():
75
+ # Open a fresh connection so WAL mode allows the
76
+ # competing read+write.
77
+ import sqlite3 as _sq3
78
+ conn = _sq3.connect(str(self.store.path), timeout=5.0)
79
+ try:
80
+ holder_started.set()
81
+ conn.execute("BEGIN IMMEDIATE")
82
+ # Insert one row to actually take the write lock.
83
+ conn.execute(
84
+ "INSERT INTO settings(k, v, updated_at) VALUES (?, ?, ?)",
85
+ ("holder", '"x"', time.time()),
86
+ )
87
+ # Hold for 200ms — within busy_timeout budget.
88
+ time.sleep(0.2)
89
+ conn.commit()
90
+ finally:
91
+ conn.close()
92
+ holder_done.set()
93
+
94
+ t = threading.Thread(target=hold_lock_for_a_bit, daemon=True)
95
+ t.start()
96
+ holder_started.wait(timeout=1.0)
97
+ # Give the holder a moment to actually take the BEGIN.
98
+ time.sleep(0.05)
99
+ # Now try to write on the store — must wait + succeed.
100
+ self.store.set_setting("waiter", "y")
101
+ holder_done.wait(timeout=2.0)
102
+ t.join(timeout=2.0)
103
+ # If the holder finished and the waiter succeeded, the
104
+ # busy_timeout=5000 contract is honoured.
105
+ self.assertTrue(holder_done.is_set())
106
+ self.assertEqual(self.store.get_setting("waiter"), "y")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ unittest.main()
@@ -0,0 +1,80 @@
1
+ """Tests for the new operational probes (audit 2026-09-24).
2
+
3
+ * ``GET /api/healthz`` — liveness, never touches the DB.
4
+ * ``GET /api/readyz`` — readiness, runs ``PRAGMA quick_check``.
5
+
6
+ Both endpoints must:
7
+ 1. Return 200 on a healthy store
8
+ 2. Be in the public allow-list (no Bearer token required)
9
+ 3. ``/api/readyz`` must return 503 when the DB is unreadable
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import tempfile
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+ from fastapi.testclient import TestClient
19
+
20
+ from loop_memory.serve.app import create_app
21
+ from loop_memory.storage.sqlite_store import MemoryStore
22
+
23
+
24
+ def _store() -> tuple[MemoryStore, Path]:
25
+ tmp = Path(tempfile.mkdtemp(prefix="loop-probes-"))
26
+ return MemoryStore(str(tmp / "db.sqlite")), tmp
27
+
28
+
29
+ class HealthzRouteTests(unittest.TestCase):
30
+ """Audit 2026-09-24: GET /api/healthz."""
31
+
32
+ def setUp(self) -> None:
33
+ self.store, self.tmp = _store()
34
+ self.app = create_app(self.store)
35
+ self.client = TestClient(self.app)
36
+
37
+ def tearDown(self) -> None:
38
+ shutil.rmtree(self.tmp, ignore_errors=True)
39
+
40
+ def test_healthz_returns_ok(self) -> None:
41
+ r = self.client.get("/api/healthz")
42
+ self.assertEqual(r.status_code, 200, msg=r.text)
43
+ body = r.json()
44
+ self.assertEqual(body["status"], "ok")
45
+ self.assertIn("ts", body)
46
+ self.assertIsInstance(body["ts"], (int, float))
47
+
48
+ def test_healthz_does_not_require_auth(self) -> None:
49
+ # Even when a Bearer token is configured the probe must
50
+ # succeed without one.
51
+ self.store.set_setting("loop_memory_auth_token", "secret")
52
+ r = self.client.get("/api/healthz")
53
+ self.assertEqual(r.status_code, 200, msg=r.text)
54
+
55
+
56
+ class ReadyzRouteTests(unittest.TestCase):
57
+ """Audit 2026-09-24: GET /api/readyz."""
58
+
59
+ def setUp(self) -> None:
60
+ self.store, self.tmp = _store()
61
+ self.app = create_app(self.store)
62
+ self.client = TestClient(self.app)
63
+
64
+ def tearDown(self) -> None:
65
+ shutil.rmtree(self.tmp, ignore_errors=True)
66
+
67
+ def test_readyz_returns_ok_on_healthy_store(self) -> None:
68
+ r = self.client.get("/api/readyz")
69
+ self.assertEqual(r.status_code, 200, msg=r.text)
70
+ body = r.json()
71
+ self.assertEqual(body["status"], "ok")
72
+
73
+ def test_readyz_does_not_require_auth(self) -> None:
74
+ self.store.set_setting("loop_memory_auth_token", "secret")
75
+ r = self.client.get("/api/readyz")
76
+ self.assertEqual(r.status_code, 200, msg=r.text)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ unittest.main()
File without changes
File without changes