superlocalmemory 3.7.1 → 3.7.3

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 (32) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin/scripts/slm-launch +11 -3
  7. package/plugin/scripts/slm-launch.bat +8 -2
  8. package/plugin-src/manifest.json +1 -1
  9. package/plugin-src/requirements.txt +1 -1
  10. package/plugin-src/scripts/slm-launch +11 -3
  11. package/plugin-src/scripts/slm-launch.bat +8 -2
  12. package/pyproject.toml +1 -1
  13. package/src/superlocalmemory/__init__.py +1 -1
  14. package/src/superlocalmemory/cli/main.py +2 -2
  15. package/src/superlocalmemory/cli/scale_engine_cmd.py +13 -1
  16. package/src/superlocalmemory/core/backend_orchestrator.py +15 -0
  17. package/src/superlocalmemory/core/embedding_worker.py +7 -1
  18. package/src/superlocalmemory/core/embeddings.py +8 -0
  19. package/src/superlocalmemory/core/engine.py +1 -3
  20. package/src/superlocalmemory/core/engine_wiring.py +0 -5
  21. package/src/superlocalmemory/core/ram_lock.py +42 -4
  22. package/src/superlocalmemory/core/scale_engine.py +450 -35
  23. package/src/superlocalmemory/evolution/budget.py +43 -8
  24. package/src/superlocalmemory/hooks/claude_code_hooks.py +2 -1
  25. package/src/superlocalmemory/hooks/context_payload.py +2 -1
  26. package/src/superlocalmemory/mcp/http_transport.py +2 -1
  27. package/src/superlocalmemory/mcp/tools_core.py +46 -29
  28. package/src/superlocalmemory/mesh/broker.py +111 -61
  29. package/src/superlocalmemory/optimize/proxy/server.py +2 -1
  30. package/src/superlocalmemory/retrieval/spreading_activation.py +53 -3
  31. package/src/superlocalmemory/server/routes/brain.py +2 -1
  32. package/src/superlocalmemory/server/unified_daemon.py +24 -7
@@ -41,17 +41,20 @@ class ScaleEngineManager:
41
41
 
42
42
  MANIFEST_NAME = "scale-engine.json"
43
43
  SCHEMA_VERSION = 1
44
+ LIFECYCLE_LOCK = "scale-engine.lifecycle.lock"
45
+ PROMOTION_JOURNAL = "scale-engine.promotion.json"
44
46
 
45
47
  def __init__(
46
48
  self,
47
49
  config: Any,
48
50
  *,
49
51
  backend_factory: Callable[[Path, Path], tuple[Any, Any]] | None = None,
52
+ profile_id: str | None = None,
50
53
  ) -> None:
51
54
  self.config = config
52
55
  self.data_dir = Path(getattr(config, "data_dir", None) or config.base_dir)
53
56
  self.db_path = Path(getattr(config, "db_path", None) or self.data_dir / "memory.db")
54
- self.profile_id = getattr(config, "active_profile", "default")
57
+ self.profile_id = profile_id or getattr(config, "active_profile", "default")
55
58
  self._backend_factory = backend_factory or self._real_backend_factory
56
59
 
57
60
  @property
@@ -66,6 +69,14 @@ class ScaleEngineManager:
66
69
  def active_paths(self) -> tuple[Path, Path]:
67
70
  return self.data_dir / "cozo", self.data_dir / "lance"
68
71
 
72
+ @property
73
+ def lifecycle_lock_path(self) -> Path:
74
+ return self.data_dir / self.LIFECYCLE_LOCK
75
+
76
+ @property
77
+ def promotion_journal_path(self) -> Path:
78
+ return self.data_dir / self.PROMOTION_JOURNAL
79
+
69
80
  def status(self) -> dict[str, Any]:
70
81
  manifests: list[dict[str, Any]] = []
71
82
  if self.staging_root.exists():
@@ -74,21 +85,93 @@ class ScaleEngineManager:
74
85
  manifests.append(json.loads(path.read_text()))
75
86
  except (OSError, json.JSONDecodeError):
76
87
  manifests.append({"stage_id": path.parent.name, "state": "corrupt"})
88
+ backups = (
89
+ sorted(p.name for p in self.backup_root.glob("*") if p.is_dir())
90
+ if self.backup_root.exists()
91
+ else []
92
+ )
93
+ paths_present = {
94
+ "cozo": self.active_paths[0].exists(),
95
+ "lance": self.active_paths[1].exists(),
96
+ }
97
+ state = getattr(self.config, "scale_engine_state", "local_core")
98
+ legacy_projection_candidate = (
99
+ state == "local_core"
100
+ and all(paths_present.values())
101
+ and not manifests
102
+ and not backups
103
+ and self._has_legacy_projection_layout()
104
+ )
105
+ runtime = self._runtime_backend_status()
77
106
  return {
78
- "state": getattr(self.config, "scale_engine_state", "local_core"),
79
- "active": {"cozo": self.active_paths[0].exists(), "lance": self.active_paths[1].exists()},
107
+ "state": state,
108
+ # This command reads persisted state, not the live daemon. Never
109
+ # turn a last-known backend row into a present-tense routing claim.
110
+ "active": {"cozo": False, "lance": False},
111
+ "last_daemon_observation": runtime,
112
+ "paths_present": paths_present,
113
+ "retrieval_routing": (
114
+ "daemon_runtime_check_required" if state == "promoted" else "canonical_sqlite"
115
+ ),
116
+ "legacy_projection_candidate": legacy_projection_candidate,
117
+ "legacy_candidate_requires_confirmation": legacy_projection_candidate,
118
+ "migration_repair_required": (
119
+ self.promotion_journal_path.exists()
120
+ or (state == "local_core" and bool(manifests))
121
+ ),
80
122
  "stages": manifests,
81
- "backups": sorted(p.name for p in self.backup_root.glob("*") if p.is_dir()) if self.backup_root.exists() else [],
123
+ "backups": backups,
82
124
  }
83
125
 
126
+ def adopt_legacy_projection(self) -> dict[str, Any] | None:
127
+ """Safely adopt a v3.5-era projection into the staged lifecycle.
128
+
129
+ A legacy projection proves only that an older runtime created files.
130
+ It cannot establish parity with today's canonical SQLite database.
131
+ Rebuild a fresh stage, verify it while the canonical database is
132
+ stable, and promote it atomically; the legacy directories become the
133
+ explicit rollback copy.
134
+ """
135
+ if not self.status()["legacy_projection_candidate"]:
136
+ return None
137
+ lock_path = self._acquire_lifecycle_lock()
138
+ try:
139
+ # Re-check inside the lock. A concurrent command may have
140
+ # completed promotion while this caller waited to acquire it.
141
+ if not self.status()["legacy_projection_candidate"]:
142
+ return None
143
+ prepared = self._prepare()
144
+ self._verify(prepared["stage_id"])
145
+ return self._promote(prepared["stage_id"])
146
+ except Exception:
147
+ # A failed adoption must leave the canonical path selected. The
148
+ # stage is retained for operator inspection instead of deleting
149
+ # evidence about why an upgrade could not be completed.
150
+ self.config.scale_engine_state = "local_core"
151
+ self.config.graph_backend = "auto"
152
+ self.config.vector_backend = "auto"
153
+ self._save_config()
154
+ raise
155
+ finally:
156
+ self._release_lifecycle_lock(lock_path)
157
+
84
158
  def prepare(self) -> dict[str, Any]:
85
159
  """Build a new projection in a private staging directory."""
160
+ lock_path = self._acquire_lifecycle_lock()
161
+ try:
162
+ self._recover_interrupted_promotion()
163
+ return self._prepare()
164
+ finally:
165
+ self._release_lifecycle_lock(lock_path)
166
+
167
+ def _prepare(self) -> dict[str, Any]:
168
+ """Build a new projection while the caller owns the lifecycle lock."""
86
169
  self._require_default_profile()
87
170
  self._require_canonical_db()
88
171
  stage_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
89
172
  stage_dir = self.staging_root / stage_id
90
173
  cozo_dir, lance_dir = stage_dir / "cozo", stage_dir / "lance"
91
- stage_dir.mkdir(parents=True, exist_ok=False)
174
+ self._mkdir_durable(stage_dir, exist_ok=False)
92
175
  cozo = lance = None
93
176
  try:
94
177
  cozo, lance = self._backend_factory(cozo_dir, lance_dir)
@@ -96,6 +179,7 @@ class ScaleEngineManager:
96
179
  cozo.bulk_import_from_sqlite(conn, self.profile_id)
97
180
  lance.bulk_import_from_sqlite(conn, self.profile_id)
98
181
  canonical = self._canonical_counts(conn)
182
+ source_fingerprint = self._projection_fingerprint(conn, canonical)
99
183
  observed = self._observed_counts(cozo, lance)
100
184
  manifest = {
101
185
  "schema_version": self.SCHEMA_VERSION,
@@ -105,7 +189,7 @@ class ScaleEngineManager:
105
189
  "profile_id": self.profile_id,
106
190
  "canonical": canonical,
107
191
  "observed": observed,
108
- "source_fingerprint": self._source_fingerprint(canonical),
192
+ "source_fingerprint": source_fingerprint,
109
193
  }
110
194
  self._write_manifest(stage_dir, manifest)
111
195
  self.config.scale_engine_state = "prepared"
@@ -120,6 +204,15 @@ class ScaleEngineManager:
120
204
 
121
205
  def verify(self, stage_id: str) -> dict[str, Any]:
122
206
  """Prove a staged projection matches the current canonical SQLite data."""
207
+ lock_path = self._acquire_lifecycle_lock()
208
+ try:
209
+ self._recover_interrupted_promotion()
210
+ return self._verify(stage_id)
211
+ finally:
212
+ self._release_lifecycle_lock(lock_path)
213
+
214
+ def _verify(self, stage_id: str) -> dict[str, Any]:
215
+ """Verify while the caller owns the lifecycle lock."""
123
216
  stage_dir, manifest = self._load_stage(stage_id)
124
217
  self._validate_manifest(manifest, state="prepared")
125
218
  self._require_default_profile()
@@ -128,8 +221,9 @@ class ScaleEngineManager:
128
221
  cozo, lance = self._backend_factory(stage_dir / "cozo", stage_dir / "lance")
129
222
  with self._readonly_connection() as conn:
130
223
  canonical = self._canonical_counts(conn)
224
+ source_fingerprint = self._projection_fingerprint(conn, canonical)
131
225
  observed = self._observed_counts(cozo, lance)
132
- if manifest["source_fingerprint"] != self._source_fingerprint(canonical):
226
+ if manifest["source_fingerprint"] != source_fingerprint:
133
227
  raise ScaleEngineError("canonical SQLite changed after preparation; prepare a new stage")
134
228
  if canonical != manifest["canonical"] or observed != canonical:
135
229
  raise ScaleEngineError(
@@ -146,6 +240,15 @@ class ScaleEngineManager:
146
240
 
147
241
  def promote(self, stage_id: str) -> dict[str, Any]:
148
242
  """Move a verified stage into active paths, preserving a rollback copy."""
243
+ lock_path = self._acquire_lifecycle_lock()
244
+ try:
245
+ self._recover_interrupted_promotion()
246
+ return self._promote(stage_id)
247
+ finally:
248
+ self._release_lifecycle_lock(lock_path)
249
+
250
+ def _promote(self, stage_id: str) -> dict[str, Any]:
251
+ """Promote while the caller owns the lifecycle lock."""
149
252
  stage_dir, manifest = self._load_stage(stage_id)
150
253
  self._validate_manifest(manifest, state="verified")
151
254
  staged = (stage_dir / "cozo", stage_dir / "lance")
@@ -153,39 +256,78 @@ class ScaleEngineManager:
153
256
  raise ScaleEngineError("verified stage is incomplete; prepare a new stage")
154
257
  backup_dir = self.backup_root / f"{stage_id}-{uuid.uuid4().hex[:6]}"
155
258
  active = self.active_paths
156
- self.backup_root.mkdir(parents=True, exist_ok=True)
157
- # Keep an explicit empty rollback point as well: a first promotion has
158
- # no former projection directories, but rollback must still be able to
159
- # return the installation to Local Core without deleting anything.
160
- backup_dir.mkdir(parents=True, exist_ok=False)
161
- moved_active: list[tuple[Path, Path]] = []
162
- moved_stage: list[tuple[Path, Path]] = []
259
+ gate = sqlite3.connect(self.db_path, timeout=30)
163
260
  try:
261
+ # The stage was built from a point-in-time SQLite snapshot. Hold a
262
+ # short writer fence for the final fingerprint check and directory
263
+ # swap so no successful promotion can trail a canonical write.
264
+ gate.execute("BEGIN IMMEDIATE")
265
+ canonical = self._canonical_counts(gate)
266
+ if manifest["source_fingerprint"] != self._projection_fingerprint(gate, canonical):
267
+ raise ScaleEngineError("canonical SQLite changed after verification; prepare a new stage")
268
+ self._mkdir_durable(self.backup_root)
269
+ journal = {
270
+ "schema_version": self.SCHEMA_VERSION,
271
+ "operation": "promotion",
272
+ "state": "intent",
273
+ "stage_id": stage_id,
274
+ "backup_id": backup_dir.name,
275
+ "moves": [],
276
+ }
277
+ self._write_promotion_journal(journal)
278
+ self._mkdir_durable(backup_dir, exist_ok=False)
164
279
  for name, source, destination in zip(("cozo", "lance"), active, staged):
165
280
  if source.exists():
166
281
  target = backup_dir / name
167
- os.replace(source, target)
168
- moved_active.append((source, target))
169
- os.replace(destination, source)
170
- moved_stage.append((destination, source))
282
+ move = {"name": name, "kind": "active_to_backup", "state": "intent"}
283
+ journal["moves"].append(move)
284
+ self._write_promotion_journal(journal)
285
+ self._replace_durable(source, target)
286
+ move["state"] = "complete"
287
+ self._write_promotion_journal(journal)
288
+ move = {"name": name, "kind": "stage_to_active", "state": "intent"}
289
+ journal["moves"].append(move)
290
+ self._write_promotion_journal(journal)
291
+ self._replace_durable(destination, source)
292
+ move["state"] = "complete"
293
+ self._write_promotion_journal(journal)
171
294
  manifest.update({"state": "promoted", "promoted_at": _utc_now(), "backup_id": backup_dir.name})
172
295
  self._write_manifest(stage_dir, manifest)
173
296
  self.config.scale_engine_state = "promoted"
174
297
  self.config.graph_backend = "cozo"
175
298
  self.config.vector_backend = "lancedb"
176
299
  self._save_config()
300
+ journal["state"] = "committed"
301
+ self._write_promotion_journal(journal)
302
+ self.promotion_journal_path.unlink(missing_ok=True)
303
+ gate.rollback()
177
304
  return manifest
178
305
  except Exception as exc:
179
- for staged_path, active_path in reversed(moved_stage):
180
- if active_path.exists():
181
- os.replace(active_path, staged_path)
182
- for active_path, backup_path in reversed(moved_active):
183
- if backup_path.exists():
184
- os.replace(backup_path, active_path)
306
+ try:
307
+ gate.rollback()
308
+ except sqlite3.Error:
309
+ pass
310
+ try:
311
+ self._recover_interrupted_promotion()
312
+ except ScaleEngineError as recovery_error:
313
+ raise ScaleEngineError(
314
+ f"promotion interrupted; automatic recovery needs repair: {recovery_error}"
315
+ ) from exc
185
316
  raise ScaleEngineError(f"promotion rolled back: {exc}") from exc
317
+ finally:
318
+ gate.close()
186
319
 
187
320
  def rollback(self, backup_id: str) -> dict[str, Any]:
188
321
  """Restore an explicitly named pre-promotion backup."""
322
+ lock_path = self._acquire_lifecycle_lock()
323
+ try:
324
+ self._recover_interrupted_promotion()
325
+ return self._rollback(backup_id)
326
+ finally:
327
+ self._release_lifecycle_lock(lock_path)
328
+
329
+ def _rollback(self, backup_id: str) -> dict[str, Any]:
330
+ """Roll back while the caller owns the lifecycle lock."""
189
331
  backup_dir = self.backup_root / backup_id
190
332
  if not backup_dir.is_dir():
191
333
  raise ScaleEngineError(f"backup does not exist: {backup_id}")
@@ -193,19 +335,47 @@ class ScaleEngineManager:
193
335
  active = self.active_paths
194
336
  displaced = self.backup_root / f"rollback-displaced-{uuid.uuid4().hex[:8]}"
195
337
  try:
338
+ journal = {
339
+ "schema_version": self.SCHEMA_VERSION,
340
+ "operation": "rollback",
341
+ "state": "intent",
342
+ "backup_id": backup_id,
343
+ "displaced_id": displaced.name,
344
+ "moves": [],
345
+ }
346
+ self._write_promotion_journal(journal)
196
347
  for name, source, target in zip(("cozo", "lance"), active, backup_paths):
197
348
  if source.exists():
198
- displaced.mkdir(parents=True, exist_ok=True)
199
- os.replace(source, displaced / name)
349
+ self._mkdir_durable(displaced)
350
+ move = {"name": name, "kind": "active_to_displaced", "state": "intent"}
351
+ journal["moves"].append(move)
352
+ self._write_promotion_journal(journal)
353
+ self._replace_durable(source, displaced / name)
354
+ move["state"] = "complete"
355
+ self._write_promotion_journal(journal)
200
356
  if target.exists():
201
- os.replace(target, source)
357
+ move = {"name": name, "kind": "backup_to_active", "state": "intent"}
358
+ journal["moves"].append(move)
359
+ self._write_promotion_journal(journal)
360
+ self._replace_durable(target, source)
361
+ move["state"] = "complete"
362
+ self._write_promotion_journal(journal)
202
363
  self.config.scale_engine_state = "local_core"
203
364
  self.config.graph_backend = "auto"
204
365
  self.config.vector_backend = "auto"
205
366
  self._save_config()
367
+ journal["state"] = "committed"
368
+ self._write_promotion_journal(journal)
369
+ self.promotion_journal_path.unlink(missing_ok=True)
206
370
  return {"state": "local_core", "restored_backup": backup_id, "displaced": displaced.name}
207
371
  except Exception as exc:
208
- raise ScaleEngineError(f"rollback failed; inspect {backup_dir}: {exc}") from exc
372
+ try:
373
+ self._recover_interrupted_promotion()
374
+ except ScaleEngineError as recovery_error:
375
+ raise ScaleEngineError(
376
+ f"rollback interrupted; automatic recovery needs repair: {recovery_error}"
377
+ ) from exc
378
+ raise ScaleEngineError(f"rollback recovered: {exc}") from exc
209
379
 
210
380
  def _real_backend_factory(self, cozo_dir: Path, lance_dir: Path) -> tuple[Any, Any]:
211
381
  from superlocalmemory.graph.cozo_backend import CozoDBGraphBackend
@@ -243,17 +413,67 @@ class ScaleEngineManager:
243
413
  raise ScaleEngineError(f"projection health failed: cozo={graph}, lancedb={vector}")
244
414
  return {"entities": int(graph["entities"]), "edges": int(graph["edges"]), "vectors": int(vector["vectors"])}
245
415
 
246
- def _source_fingerprint(self, counts: dict[str, int]) -> str:
416
+ def _projection_fingerprint(
417
+ self, conn: sqlite3.Connection, counts: dict[str, int]
418
+ ) -> str:
419
+ """Hash the exact projection source rows inside one SQLite snapshot."""
247
420
  digest = hashlib.sha256()
248
421
  digest.update(json.dumps(counts, sort_keys=True).encode())
249
- for path in (self.db_path, self.db_path.with_name(self.db_path.name + "-wal")):
250
- if path.exists():
251
- digest.update(path.name.encode())
252
- with path.open("rb") as handle:
253
- for block in iter(lambda: handle.read(1024 * 1024), b""):
254
- digest.update(block)
422
+ tables = (
423
+ ("canonical_entities", "entity_id, canonical_name, entity_type, first_seen, last_seen, fact_count, profile_id", "entity_id"),
424
+ ("atomic_facts", "fact_id, canonical_entities_json, lifecycle, profile_id", "fact_id"),
425
+ ("graph_edges", "source_id, target_id, edge_type, weight, profile_id", "source_id, target_id, edge_type"),
426
+ )
427
+ for table, columns, ordering in tables:
428
+ try:
429
+ rows = conn.execute(
430
+ f"SELECT {columns} FROM {table} WHERE profile_id=? ORDER BY {ordering}",
431
+ (self.profile_id,),
432
+ )
433
+ for row in rows:
434
+ self._digest_row(digest, table, row)
435
+ except sqlite3.OperationalError as exc:
436
+ raise ScaleEngineError(f"canonical SQLite missing required {table} table") from exc
437
+ try:
438
+ rows = conn.execute(
439
+ "SELECT fer.rowid, fer.fact_id, vec.vector FROM fact_embeddings_rowids fer "
440
+ "JOIN atomic_facts af ON af.fact_id = fer.fact_id "
441
+ "LEFT JOIN fact_embeddings_vector_chunks00 vec ON vec.rowid = fer.rowid "
442
+ "WHERE af.profile_id=? ORDER BY fer.rowid",
443
+ (self.profile_id,),
444
+ )
445
+ for row in rows:
446
+ self._digest_row(digest, "fact_embeddings_rowids", row)
447
+ except sqlite3.OperationalError:
448
+ pass
255
449
  return digest.hexdigest()
256
450
 
451
+ @staticmethod
452
+ def _digest_row(digest: Any, table: str, row: Any) -> None:
453
+ digest.update(table.encode())
454
+ digest.update(json.dumps(list(row), default=str, separators=(",", ":")).encode())
455
+
456
+ def _has_legacy_projection_layout(self) -> bool:
457
+ cozo, lance = self.active_paths
458
+ return (cozo / "graph").is_dir() and (lance / "embeddings.lance").exists()
459
+
460
+ def _runtime_backend_status(self) -> dict[str, str]:
461
+ status = {"cozo": "unknown", "lance": "unknown"}
462
+ try:
463
+ with self._readonly_connection() as conn:
464
+ rows = conn.execute(
465
+ "SELECT backend_name, status FROM backend_status "
466
+ "WHERE backend_name IN ('cozo', 'lancedb')"
467
+ )
468
+ for name, value in rows:
469
+ if name == "cozo":
470
+ status["cozo"] = str(value)
471
+ elif name == "lancedb":
472
+ status["lance"] = str(value)
473
+ except sqlite3.OperationalError:
474
+ pass
475
+ return status
476
+
257
477
  def _load_stage(self, stage_id: str) -> tuple[Path, dict[str, Any]]:
258
478
  stage_dir = self.staging_root / stage_id
259
479
  try:
@@ -280,6 +500,201 @@ class ScaleEngineManager:
280
500
  if not self.db_path.exists():
281
501
  raise ScaleEngineError(f"canonical SQLite database not found: {self.db_path}")
282
502
 
503
+ def _acquire_lifecycle_lock(self) -> Path:
504
+ """Serialize every mutating lifecycle command across processes."""
505
+ lock_path = self.lifecycle_lock_path
506
+ descriptor = None
507
+ for attempt in range(2):
508
+ try:
509
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
510
+ break
511
+ except FileExistsError as exc:
512
+ if attempt == 0 and self._clear_dead_legacy_adoption_lock(lock_path):
513
+ continue
514
+ raise ScaleEngineError(
515
+ "Scale Engine lifecycle operation already in progress; retry after it completes"
516
+ ) from exc
517
+ if descriptor is None:
518
+ raise ScaleEngineError("could not acquire Scale Engine lifecycle lock")
519
+ try:
520
+ with os.fdopen(descriptor, "w") as lock_file:
521
+ json.dump({"pid": os.getpid(), "started_at": _utc_now()}, lock_file)
522
+ except Exception:
523
+ lock_path.unlink(missing_ok=True)
524
+ raise
525
+ return lock_path
526
+
527
+ @staticmethod
528
+ def _release_lifecycle_lock(lock_path: Path) -> None:
529
+ lock_path.unlink(missing_ok=True)
530
+
531
+ @staticmethod
532
+ def _clear_dead_legacy_adoption_lock(lock_path: Path) -> bool:
533
+ """Recover only a lock whose recorded process no longer exists."""
534
+ try:
535
+ owner = json.loads(lock_path.read_text())
536
+ pid = owner.get("pid")
537
+ if not isinstance(pid, int) or pid <= 0:
538
+ return False
539
+ os.kill(pid, 0)
540
+ except ProcessLookupError:
541
+ lock_path.unlink(missing_ok=True)
542
+ return True
543
+ except (OSError, ValueError, json.JSONDecodeError):
544
+ return False
545
+ return False
546
+
547
+ def recover_interrupted_promotion(self) -> str | None:
548
+ """Recover a durable promotion journal before opening projection paths."""
549
+ lock_path = self._acquire_lifecycle_lock()
550
+ try:
551
+ return self._recover_interrupted_promotion()
552
+ finally:
553
+ self._release_lifecycle_lock(lock_path)
554
+
555
+ def _recover_interrupted_promotion(self) -> str | None:
556
+ """Finalize or reverse an interrupted directory swap under lifecycle lock."""
557
+ if not self.promotion_journal_path.exists():
558
+ return None
559
+ try:
560
+ journal = json.loads(self.promotion_journal_path.read_text())
561
+ backup_id = str(journal["backup_id"])
562
+ state = str(journal["state"])
563
+ except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc:
564
+ raise ScaleEngineError("invalid promotion journal; manual repair required") from exc
565
+ operation = str(journal.get("operation", "promotion"))
566
+ if state == "committed":
567
+ if operation == "promotion":
568
+ self.config.scale_engine_state = "promoted"
569
+ self.config.graph_backend = "cozo"
570
+ self.config.vector_backend = "lancedb"
571
+ elif operation == "rollback":
572
+ self.config.scale_engine_state = "local_core"
573
+ self.config.graph_backend = "auto"
574
+ self.config.vector_backend = "auto"
575
+ else:
576
+ raise ScaleEngineError(f"unknown journal operation: {operation!r}")
577
+ self._save_config()
578
+ self.promotion_journal_path.unlink(missing_ok=True)
579
+ return f"finalized_committed_{operation}"
580
+ if state != "intent":
581
+ raise ScaleEngineError(f"unknown promotion journal state: {state!r}")
582
+ backup_dir = self.backup_root / backup_id
583
+ active = dict(zip(("cozo", "lance"), self.active_paths))
584
+ stage_dir = self.staging_root / str(journal.get("stage_id", ""))
585
+ displaced_dir = self.backup_root / str(journal.get("displaced_id", ""))
586
+ moves = journal.get("moves")
587
+ if not isinstance(moves, list):
588
+ # Compatibility for journals written by the initial v3.7.3
589
+ # candidate before per-rename intents existed.
590
+ moves = [
591
+ {"name": name, "kind": "active_to_backup", "state": "complete"}
592
+ for name in journal.get("moved_active", [])
593
+ ] + [
594
+ {"name": name, "kind": "stage_to_active", "state": "complete"}
595
+ for name in journal.get("moved_stage", [])
596
+ ]
597
+ for move in reversed(moves):
598
+ self._reverse_journal_move(move, active, stage_dir, backup_dir, displaced_dir)
599
+ if operation == "promotion":
600
+ if backup_dir.exists() and not any(backup_dir.iterdir()):
601
+ backup_dir.rmdir()
602
+ self.config.scale_engine_state = "local_core"
603
+ self.config.graph_backend = "auto"
604
+ self.config.vector_backend = "auto"
605
+ elif operation == "rollback":
606
+ if displaced_dir.exists() and not any(displaced_dir.iterdir()):
607
+ displaced_dir.rmdir()
608
+ self.config.scale_engine_state = "promoted"
609
+ self.config.graph_backend = "cozo"
610
+ self.config.vector_backend = "lancedb"
611
+ else:
612
+ raise ScaleEngineError(f"unknown journal operation: {operation!r}")
613
+ self._save_config()
614
+ self.promotion_journal_path.unlink(missing_ok=True)
615
+ return f"reversed_interrupted_{operation}"
616
+
617
+ @staticmethod
618
+ def _reverse_journal_move(
619
+ move: Any,
620
+ active: dict[str, Path],
621
+ stage_dir: Path,
622
+ backup_dir: Path,
623
+ displaced_dir: Path,
624
+ ) -> None:
625
+ """Reverse one planned rename based on actual paths, not journal timing."""
626
+ if not isinstance(move, dict):
627
+ raise ScaleEngineError("invalid promotion journal move")
628
+ name = move.get("name")
629
+ kind = move.get("kind")
630
+ active_path = active.get(name)
631
+ if active_path is None:
632
+ raise ScaleEngineError(f"invalid promotion journal backend: {name!r}")
633
+ if kind == "stage_to_active":
634
+ source, target = active_path, stage_dir / name
635
+ elif kind == "active_to_backup":
636
+ source, target = backup_dir / name, active_path
637
+ elif kind == "active_to_displaced":
638
+ source, target = displaced_dir / name, active_path
639
+ elif kind == "backup_to_active":
640
+ source, target = active_path, backup_dir / name
641
+ else:
642
+ raise ScaleEngineError(f"invalid promotion journal move kind: {kind!r}")
643
+ if source.exists() and not target.exists():
644
+ ScaleEngineManager._replace_durable(source, target)
645
+ elif target.exists() and not source.exists():
646
+ return
647
+ else:
648
+ raise ScaleEngineError(f"cannot safely reconcile {kind} for {name}")
649
+
650
+ def _write_promotion_journal(self, journal: dict[str, Any]) -> None:
651
+ self._write_json_durable(self.promotion_journal_path, journal)
652
+
653
+ @staticmethod
654
+ def _write_json_durable(target: Path, payload: dict[str, Any]) -> None:
655
+ temporary = target.with_suffix(target.suffix + ".tmp")
656
+ with temporary.open("w") as handle:
657
+ json.dump(payload, handle, indent=2, sort_keys=True)
658
+ handle.write("\n")
659
+ handle.flush()
660
+ os.fsync(handle.fileno())
661
+ os.replace(temporary, target)
662
+ ScaleEngineManager._fsync_directory(target.parent)
663
+
664
+ @staticmethod
665
+ def _replace_durable(source: Path, target: Path) -> None:
666
+ """Rename a projection path and persist both directory entries."""
667
+ os.replace(source, target)
668
+ ScaleEngineManager._fsync_directory(source.parent)
669
+ if target.parent != source.parent:
670
+ ScaleEngineManager._fsync_directory(target.parent)
671
+
672
+ @staticmethod
673
+ def _mkdir_durable(path: Path, *, exist_ok: bool = True) -> None:
674
+ """Create a directory and persist every new parent entry before rename."""
675
+ missing: list[Path] = []
676
+ ancestor = path
677
+ while not ancestor.exists():
678
+ missing.append(ancestor)
679
+ ancestor = ancestor.parent
680
+ path.mkdir(parents=True, exist_ok=exist_ok)
681
+ for created in reversed(missing):
682
+ ScaleEngineManager._fsync_directory(created.parent)
683
+
684
+ @staticmethod
685
+ def _fsync_directory(directory_path: Path) -> None:
686
+ """Best-effort directory-entry durability across local filesystems."""
687
+ try:
688
+ directory = os.open(directory_path, os.O_RDONLY)
689
+ try:
690
+ os.fsync(directory)
691
+ finally:
692
+ os.close(directory)
693
+ except OSError:
694
+ # The file itself is already durable where directory fsync is not
695
+ # supported by the local filesystem (notably some Windows setups).
696
+ pass
697
+
283
698
  def _save_config(self) -> None:
284
699
  save = getattr(self.config, "save", None)
285
700
  if callable(save):