superlocalmemory 3.4.12 → 3.4.14
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.
- package/README.md +30 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +28 -18
- package/src/superlocalmemory/cli/daemon.py +47 -2
- package/src/superlocalmemory/core/embeddings.py +49 -6
- package/src/superlocalmemory/hooks/hook_handlers.py +43 -36
- package/src/superlocalmemory/retrieval/reranker.py +30 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -614
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -55
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
package/README.md
CHANGED
|
@@ -533,3 +533,33 @@ Part of [Qualixar](https://qualixar.com) · Author: [Varun Pratap Bhardwaj](http
|
|
|
533
533
|
<p align="center">
|
|
534
534
|
<sub>Built with mathematical rigor. Not in the race — here to help everyone build better AI memory systems.</sub>
|
|
535
535
|
</p>
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## ⭐ Support This Project
|
|
540
|
+
|
|
541
|
+
If this project solves a real problem for you, **please star the repo** — it helps other developers discover Qualixar and signals that the AI agent reliability community is growing. Every star matters.
|
|
542
|
+
|
|
543
|
+
[](https://star-history.com/#qualixar/superlocalmemory&Date)
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
## Part of the Qualixar AI Agent Reliability Platform
|
|
548
|
+
|
|
549
|
+
Qualixar is building the open-source infrastructure for AI agent reliability engineering. Seven products, seven peer-reviewed papers, one coherent platform. Each tool solves one reliability pillar:
|
|
550
|
+
|
|
551
|
+
| Product | Purpose | Install | Paper |
|
|
552
|
+
|---------|---------|---------|-------|
|
|
553
|
+
| **[SuperLocalMemory](https://github.com/qualixar/superlocalmemory)** | Persistent memory + learning for AI agents | `npx superlocalmemory` | [arXiv:2604.04514](https://arxiv.org/abs/2604.04514) |
|
|
554
|
+
| **[Qualixar OS](https://github.com/qualixar/qualixar-os)** | Universal agent runtime (13 execution topologies) | `npx qualixar-os` | [arXiv:2604.06392](https://arxiv.org/abs/2604.06392) |
|
|
555
|
+
| **[SLM Mesh](https://github.com/qualixar/slm-mesh)** | P2P coordination across AI agent sessions | `npm i slm-mesh` | — |
|
|
556
|
+
| **[SLM MCP Hub](https://github.com/qualixar/slm-mcp-hub)** | Federate 430+ MCP tools through one gateway | `pip install slm-mcp-hub` | — |
|
|
557
|
+
| **[AgentAssay](https://github.com/qualixar/agentassay)** | Token-efficient AI agent testing | `pip install agentassay` | [arXiv:2603.02601](https://arxiv.org/abs/2603.02601) |
|
|
558
|
+
| **[AgentAssert](https://github.com/qualixar/agentassert-abc)** | Behavioral contracts + drift detection | `pip install agentassert` | [arXiv:2602.22302](https://arxiv.org/abs/2602.22302) |
|
|
559
|
+
| **[SkillFortify](https://github.com/qualixar/skillfortify)** | Formal verification for AI agent skills | `pip install skillfortify` | [arXiv:2603.00195](https://arxiv.org/abs/2603.00195) |
|
|
560
|
+
|
|
561
|
+
**Zero cloud dependency. Local-first. EU AI Act compliant.**
|
|
562
|
+
|
|
563
|
+
Start here → **[qualixar.com](https://qualixar.com)** · [All papers on Qualixar HuggingFace](https://huggingface.co/Qualixar)
|
|
564
|
+
|
|
565
|
+
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.14",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
|
@@ -89,4 +89,4 @@
|
|
|
89
89
|
"dependencies": {
|
|
90
90
|
"docx": "^9.5.1"
|
|
91
91
|
}
|
|
92
|
-
}
|
|
92
|
+
}
|
package/pyproject.toml
CHANGED
|
@@ -230,20 +230,43 @@ def cmd_restart(args: Namespace) -> None:
|
|
|
230
230
|
_log(1, "Kill all SLM processes", "ok", f"{killed} processes killed")
|
|
231
231
|
time.sleep(3)
|
|
232
232
|
|
|
233
|
-
# Step 2: Clean stale files
|
|
233
|
+
# Step 2: Clean stale files + HOLD the lock to prevent races
|
|
234
|
+
# v3.4.13: Do NOT delete daemon.lock — HOLD it instead.
|
|
235
|
+
# If we delete it, `slm mcp` (still running in Claude) will see no lock,
|
|
236
|
+
# acquire a NEW lock, and start a second daemon during our restart.
|
|
234
237
|
cleaned = []
|
|
235
|
-
for fname in ("daemon.pid", "daemon.port", "
|
|
238
|
+
for fname in ("daemon.pid", "daemon.port", ".embedding-worker.pid", ".reranker-worker.pid"):
|
|
236
239
|
fpath = slm_dir / fname
|
|
237
240
|
if fpath.exists():
|
|
238
241
|
fpath.unlink(missing_ok=True)
|
|
239
242
|
cleaned.append(fname)
|
|
243
|
+
|
|
244
|
+
# Hold the lock file to block other processes from starting a daemon
|
|
245
|
+
_LOCK_FILE = slm_dir / "daemon.lock"
|
|
246
|
+
_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
restart_lock_fd = None
|
|
248
|
+
try:
|
|
249
|
+
restart_lock_fd = open(_LOCK_FILE, "w")
|
|
250
|
+
if sys.platform != "win32":
|
|
251
|
+
import fcntl
|
|
252
|
+
fcntl.flock(restart_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass # Best-effort — don't block restart if lock fails
|
|
255
|
+
|
|
240
256
|
_log(2, "Clean stale state files", "ok",
|
|
241
257
|
f"removed: {', '.join(cleaned)}" if cleaned else "already clean")
|
|
242
258
|
|
|
243
|
-
# Step 3: Start fresh daemon
|
|
259
|
+
# Step 3: Start fresh daemon (lock still held — no races)
|
|
244
260
|
time.sleep(1)
|
|
245
261
|
from superlocalmemory.cli.daemon import ensure_daemon
|
|
246
262
|
started = ensure_daemon()
|
|
263
|
+
|
|
264
|
+
# Release restart lock — daemon is now running with its own lock
|
|
265
|
+
if restart_lock_fd:
|
|
266
|
+
try:
|
|
267
|
+
restart_lock_fd.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
247
270
|
_log(3, "Start fresh daemon", "ok" if started else "fail",
|
|
248
271
|
"daemon started" if started else "failed to start — check slm doctor")
|
|
249
272
|
|
|
@@ -750,8 +773,8 @@ def cmd_remember(args: Namespace) -> None:
|
|
|
750
773
|
except Exception:
|
|
751
774
|
pass # Fall through to pending store
|
|
752
775
|
|
|
753
|
-
#
|
|
754
|
-
|
|
776
|
+
# v3.4.13: Store to pending DB (zero data loss) — daemon processes in background.
|
|
777
|
+
# NO subprocess spawn. Daemon's background loop picks up pending memories.
|
|
755
778
|
from superlocalmemory.cli.pending_store import store_pending
|
|
756
779
|
|
|
757
780
|
row_id = store_pending(
|
|
@@ -759,19 +782,6 @@ def cmd_remember(args: Namespace) -> None:
|
|
|
759
782
|
tags=args.tags or "",
|
|
760
783
|
)
|
|
761
784
|
|
|
762
|
-
cmd = [sys.executable, "-m", "superlocalmemory.cli.main",
|
|
763
|
-
"remember", args.content, "--sync"]
|
|
764
|
-
if args.tags:
|
|
765
|
-
cmd.extend(["--tags", args.tags])
|
|
766
|
-
log_dir = __import__("pathlib").Path.home() / ".superlocalmemory" / "logs"
|
|
767
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
768
|
-
log_file = log_dir / "async-remember.log"
|
|
769
|
-
with open(log_file, "a") as lf:
|
|
770
|
-
subprocess.Popen(
|
|
771
|
-
cmd, stdout=subprocess.DEVNULL, stderr=lf,
|
|
772
|
-
start_new_session=True,
|
|
773
|
-
)
|
|
774
|
-
|
|
775
785
|
if use_json:
|
|
776
786
|
from superlocalmemory.cli.json_output import json_print
|
|
777
787
|
json_print("remember", data={"queued": True, "async": True,
|
|
@@ -288,15 +288,60 @@ def stop_daemon() -> bool:
|
|
|
288
288
|
except Exception:
|
|
289
289
|
pass
|
|
290
290
|
|
|
291
|
-
# Clean up PID/port files
|
|
291
|
+
# Clean up PID/port files + worker PID files
|
|
292
292
|
_PID_FILE.unlink(missing_ok=True)
|
|
293
293
|
_PORT_FILE.unlink(missing_ok=True)
|
|
294
|
+
# v3.4.13: Clean worker PID files (singleton guards)
|
|
295
|
+
for pidfile in (".embedding-worker.pid", ".reranker-worker.pid"):
|
|
296
|
+
(Path.home() / ".superlocalmemory" / pidfile).unlink(missing_ok=True)
|
|
294
297
|
|
|
298
|
+
# v3.4.13: Wait for ALL workers to actually die before returning.
|
|
299
|
+
# Without this, `slm restart` starts a new daemon before old workers exit,
|
|
300
|
+
# causing duplicate embedding_workers (1.6GB each).
|
|
295
301
|
if killed:
|
|
296
|
-
logger.info("Stopped %d SLM processes", killed)
|
|
302
|
+
logger.info("Stopped %d SLM processes, waiting for exit...", killed)
|
|
303
|
+
_wait_for_workers_dead(timeout=10)
|
|
304
|
+
|
|
297
305
|
return True
|
|
298
306
|
|
|
299
307
|
|
|
308
|
+
def _wait_for_workers_dead(timeout: int = 10) -> None:
|
|
309
|
+
"""Wait until no SLM worker processes remain alive."""
|
|
310
|
+
targets = [
|
|
311
|
+
"superlocalmemory.server.unified_daemon",
|
|
312
|
+
"superlocalmemory.core.embedding_worker",
|
|
313
|
+
"superlocalmemory.core.recall_worker",
|
|
314
|
+
"superlocalmemory.core.reranker_worker",
|
|
315
|
+
]
|
|
316
|
+
my_pid = os.getpid()
|
|
317
|
+
deadline = time.time() + timeout
|
|
318
|
+
|
|
319
|
+
while time.time() < deadline:
|
|
320
|
+
alive = False
|
|
321
|
+
try:
|
|
322
|
+
import psutil
|
|
323
|
+
for proc in psutil.process_iter(["pid", "cmdline"]):
|
|
324
|
+
try:
|
|
325
|
+
if proc.pid == my_pid:
|
|
326
|
+
continue
|
|
327
|
+
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
328
|
+
if any(t in cmdline for t in targets):
|
|
329
|
+
alive = True
|
|
330
|
+
break
|
|
331
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
332
|
+
pass
|
|
333
|
+
except ImportError:
|
|
334
|
+
# No psutil — just wait a fixed time
|
|
335
|
+
time.sleep(3)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if not alive:
|
|
339
|
+
return
|
|
340
|
+
time.sleep(0.5)
|
|
341
|
+
|
|
342
|
+
logger.warning("Some SLM workers still alive after %ds timeout", timeout)
|
|
343
|
+
|
|
344
|
+
|
|
300
345
|
# ---------------------------------------------------------------------------
|
|
301
346
|
# Server: HTTP request handler with engine singleton
|
|
302
347
|
# ---------------------------------------------------------------------------
|
|
@@ -62,19 +62,50 @@ class DimensionMismatchError(RuntimeError):
|
|
|
62
62
|
# ---------------------------------------------------------------------------
|
|
63
63
|
|
|
64
64
|
_EMBEDDING_LOCK_FILE = Path.home() / ".superlocalmemory" / ".embedding.lock"
|
|
65
|
-
|
|
65
|
+
_EMBEDDING_PID_FILE = Path.home() / ".superlocalmemory" / ".embedding-worker.pid"
|
|
66
|
+
_MAX_CONCURRENT_WORKERS = int(os.environ.get("SLM_MAX_EMBEDDING_WORKERS", 1))
|
|
66
67
|
_embedding_lock_fd: int | None = None
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def _is_embedding_worker_alive() -> bool:
|
|
71
|
+
"""Check if an embedding worker PID file exists and that PID is alive.
|
|
72
|
+
|
|
73
|
+
v3.4.13: Machine-wide singleton guard. Before spawning a new worker,
|
|
74
|
+
check if one is already running. Prevents duplicate 1.6GB workers.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
if not _EMBEDDING_PID_FILE.exists():
|
|
78
|
+
return False
|
|
79
|
+
pid = int(_EMBEDDING_PID_FILE.read_text().strip())
|
|
80
|
+
os.kill(pid, 0) # Signal 0 = check if alive
|
|
81
|
+
return True
|
|
82
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
83
|
+
# PID file invalid or process dead — clean up stale file
|
|
84
|
+
_EMBEDDING_PID_FILE.unlink(missing_ok=True)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def register_embedding_worker_pid(pid: int) -> None:
|
|
89
|
+
"""Write the embedding worker PID to the machine-wide PID file."""
|
|
90
|
+
_EMBEDDING_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
_EMBEDDING_PID_FILE.write_text(str(pid))
|
|
92
|
+
|
|
93
|
+
|
|
69
94
|
def acquire_embedding_lock(timeout: float = 5.0) -> bool:
|
|
70
95
|
"""Acquire system-wide embedding worker lock.
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
v3.4.13: First checks if a worker PID is already alive (fast path).
|
|
98
|
+
Falls back to fcntl.flock on Unix. On Windows, falls back to PID check only.
|
|
99
|
+
Returns True if lock acquired (safe to spawn), False if another worker active.
|
|
74
100
|
"""
|
|
75
101
|
global _embedding_lock_fd
|
|
102
|
+
|
|
103
|
+
# v3.4.13: Fast path — if a worker PID is alive, don't even try the lock
|
|
104
|
+
if _is_embedding_worker_alive():
|
|
105
|
+
return False
|
|
106
|
+
|
|
76
107
|
if sys.platform == "win32":
|
|
77
|
-
return True # No file locking on Windows —
|
|
108
|
+
return True # No file locking on Windows — PID check above is the guard
|
|
78
109
|
|
|
79
110
|
import fcntl
|
|
80
111
|
_EMBEDDING_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -389,11 +420,21 @@ class EmbeddingService:
|
|
|
389
420
|
return True
|
|
390
421
|
|
|
391
422
|
def _ensure_worker(self) -> None:
|
|
392
|
-
"""Spawn worker subprocess if not running.
|
|
423
|
+
"""Spawn worker subprocess if not running.
|
|
424
|
+
|
|
425
|
+
v3.4.13: Machine-wide singleton — checks PID file before spawning.
|
|
426
|
+
Only ONE embedding_worker can exist at a time on the machine.
|
|
427
|
+
"""
|
|
393
428
|
if self._worker_proc is not None and self._worker_proc.poll() is None:
|
|
394
429
|
return
|
|
395
430
|
self._worker_proc = None
|
|
396
431
|
|
|
432
|
+
# v3.4.13: Check if another worker is already alive (machine-wide)
|
|
433
|
+
if _is_embedding_worker_alive():
|
|
434
|
+
logger.debug("Embedding worker already alive (PID file), skipping spawn")
|
|
435
|
+
self._available = False
|
|
436
|
+
return
|
|
437
|
+
|
|
397
438
|
# V3.3.28: Check memory pressure before spawning
|
|
398
439
|
if not self._check_memory_pressure():
|
|
399
440
|
logger.warning("Skipping embedding worker spawn due to memory pressure")
|
|
@@ -419,8 +460,10 @@ class EmbeddingService:
|
|
|
419
460
|
text=True,
|
|
420
461
|
bufsize=1,
|
|
421
462
|
env=env,
|
|
422
|
-
start_new_session=True,
|
|
463
|
+
start_new_session=True,
|
|
423
464
|
)
|
|
465
|
+
# v3.4.13: Register PID for machine-wide singleton guard
|
|
466
|
+
register_embedding_worker_pid(self._worker_proc.pid)
|
|
424
467
|
logger.info("Embedding worker spawned (PID %d)", self._worker_proc.pid)
|
|
425
468
|
self._worker_ready = True
|
|
426
469
|
except Exception as exc:
|
|
@@ -21,6 +21,8 @@ import subprocess
|
|
|
21
21
|
import sys
|
|
22
22
|
import tempfile
|
|
23
23
|
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
import urllib.error
|
|
24
26
|
|
|
25
27
|
# ---------------------------------------------------------------------------
|
|
26
28
|
# Cross-platform temp paths
|
|
@@ -34,6 +36,30 @@ _LAST_CONSOLIDATION = os.path.join(
|
|
|
34
36
|
)
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
_DAEMON_URL = "http://127.0.0.1:8765"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _daemon_post(path: str, body: dict, timeout: float = 3.0) -> bool:
|
|
43
|
+
"""POST to SLM daemon via stdlib urllib. Returns True on success.
|
|
44
|
+
|
|
45
|
+
v3.4.13: Hooks route through daemon HTTP instead of spawning subprocesses.
|
|
46
|
+
This eliminates the memory blast from concurrent worker spawns.
|
|
47
|
+
Uses ONLY stdlib — no httpx, no requests.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
data = json.dumps(body).encode("utf-8")
|
|
51
|
+
req = urllib.request.Request(
|
|
52
|
+
f"{_DAEMON_URL}{path}",
|
|
53
|
+
data=data,
|
|
54
|
+
headers={"Content-Type": "application/json"},
|
|
55
|
+
method="POST",
|
|
56
|
+
)
|
|
57
|
+
urllib.request.urlopen(req, timeout=timeout)
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
37
63
|
def handle_hook(action: str) -> None:
|
|
38
64
|
"""Dispatch to the appropriate hook handler. Called from main() fast path."""
|
|
39
65
|
handlers = {
|
|
@@ -202,15 +228,9 @@ def _hook_checkpoint() -> None:
|
|
|
202
228
|
if _cooldown_elapsed(lock_file, _OBSERVE_COOLDOWN, now):
|
|
203
229
|
_write_timestamp(lock_file, now)
|
|
204
230
|
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
["slm", "observe", f"File changed: {basename}"],
|
|
209
|
-
stdout=subprocess.DEVNULL,
|
|
210
|
-
stderr=subprocess.DEVNULL,
|
|
211
|
-
)
|
|
212
|
-
except Exception:
|
|
213
|
-
pass
|
|
231
|
+
# v3.4.13: Route through daemon HTTP (not subprocess) to prevent
|
|
232
|
+
# memory blast from concurrent embedding_worker spawns.
|
|
233
|
+
_daemon_post("/observe", {"content": f"File changed: {basename}"})
|
|
214
234
|
|
|
215
235
|
# Log to session activity
|
|
216
236
|
try:
|
|
@@ -286,33 +306,20 @@ def _hook_stop() -> None:
|
|
|
286
306
|
|
|
287
307
|
summary = " | ".join(parts)
|
|
288
308
|
|
|
289
|
-
# --- Save to SLM ---
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# --- Post-session skill evolution trigger (best-effort) ---
|
|
305
|
-
try:
|
|
306
|
-
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
307
|
-
if session_id:
|
|
308
|
-
subprocess.Popen(
|
|
309
|
-
["slm", "evolve", "--session", session_id],
|
|
310
|
-
stdout=subprocess.DEVNULL,
|
|
311
|
-
stderr=subprocess.DEVNULL,
|
|
312
|
-
start_new_session=True,
|
|
313
|
-
)
|
|
314
|
-
except Exception:
|
|
315
|
-
pass
|
|
309
|
+
# --- Save to SLM (v3.4.13: daemon HTTP, not subprocess) ---
|
|
310
|
+
if not _daemon_post("/observe", {"content": summary}, timeout=5.0):
|
|
311
|
+
# Fallback: try /remember if observe failed
|
|
312
|
+
_daemon_post("/remember", {"content": summary, "tags": "session-end"}, timeout=5.0)
|
|
313
|
+
|
|
314
|
+
# --- Post-session skill evolution trigger (best-effort, via tool-event) ---
|
|
315
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
316
|
+
if session_id:
|
|
317
|
+
_daemon_post("/api/v3/tool-event", {
|
|
318
|
+
"tool_name": "session_end",
|
|
319
|
+
"event_type": "session_end",
|
|
320
|
+
"session_id": session_id,
|
|
321
|
+
"output_summary": summary[:500],
|
|
322
|
+
})
|
|
316
323
|
|
|
317
324
|
# --- Auto-consolidation (if >24h since last run) ---
|
|
318
325
|
_maybe_consolidate()
|
|
@@ -27,8 +27,25 @@ import time
|
|
|
27
27
|
import weakref
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
30
32
|
from superlocalmemory.storage.models import AtomicFact
|
|
31
33
|
|
|
34
|
+
_RERANKER_PID_FILE = Path.home() / ".superlocalmemory" / ".reranker-worker.pid"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_reranker_worker_alive() -> bool:
|
|
38
|
+
"""Check if a reranker worker PID is already alive (machine-wide singleton)."""
|
|
39
|
+
try:
|
|
40
|
+
if not _RERANKER_PID_FILE.exists():
|
|
41
|
+
return False
|
|
42
|
+
pid = int(_RERANKER_PID_FILE.read_text().strip())
|
|
43
|
+
os.kill(pid, 0)
|
|
44
|
+
return True
|
|
45
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
46
|
+
_RERANKER_PID_FILE.unlink(missing_ok=True)
|
|
47
|
+
return False
|
|
48
|
+
|
|
32
49
|
# Track all live reranker instances for atexit cleanup
|
|
33
50
|
_live_rerankers: set[weakref.ref] = set()
|
|
34
51
|
|
|
@@ -148,12 +165,21 @@ class CrossEncoderReranker:
|
|
|
148
165
|
# ------------------------------------------------------------------
|
|
149
166
|
|
|
150
167
|
def _ensure_worker(self) -> None:
|
|
151
|
-
"""Spawn worker subprocess if not running.
|
|
168
|
+
"""Spawn worker subprocess if not running. Machine-wide singleton.
|
|
169
|
+
|
|
170
|
+
v3.4.13: Checks PID file before spawning — only ONE reranker worker
|
|
171
|
+
can exist at a time on the machine.
|
|
172
|
+
"""
|
|
152
173
|
if self._worker_proc is not None and self._worker_proc.poll() is None:
|
|
153
174
|
return
|
|
154
175
|
self._worker_proc = None
|
|
155
176
|
self._worker_ready = False
|
|
156
177
|
|
|
178
|
+
# v3.4.13: Machine-wide singleton guard
|
|
179
|
+
if _is_reranker_worker_alive():
|
|
180
|
+
logger.debug("Reranker worker already alive (PID file), skipping spawn")
|
|
181
|
+
return
|
|
182
|
+
|
|
157
183
|
worker_module = "superlocalmemory.core.reranker_worker"
|
|
158
184
|
try:
|
|
159
185
|
env = {
|
|
@@ -175,6 +201,9 @@ class CrossEncoderReranker:
|
|
|
175
201
|
env=env,
|
|
176
202
|
start_new_session=True,
|
|
177
203
|
)
|
|
204
|
+
# v3.4.13: Register PID for machine-wide singleton
|
|
205
|
+
_RERANKER_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
_RERANKER_PID_FILE.write_text(str(self._worker_proc.pid))
|
|
178
207
|
logger.info(
|
|
179
208
|
"Reranker worker spawned (PID %d)", self._worker_proc.pid,
|
|
180
209
|
)
|