superlocalmemory 3.8.11 → 3.8.12

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 (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +7 -3
  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 +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +15 -3
  34. package/src/superlocalmemory/cli/daemon.py +219 -10
  35. package/src/superlocalmemory/cli/setup_wizard.py +45 -1
  36. package/src/superlocalmemory/core/component_registry.py +25 -0
  37. package/src/superlocalmemory/core/config.py +35 -1
  38. package/src/superlocalmemory/core/engine_wiring.py +81 -5
  39. package/src/superlocalmemory/core/recall_pipeline.py +25 -4
  40. package/src/superlocalmemory/core/reranker_worker.py +23 -4
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/learning/feedback.py +288 -29
  44. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  45. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  46. package/src/superlocalmemory/mcp/tools_active.py +109 -58
  47. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  48. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
@@ -274,6 +274,35 @@ def _embedding_is_remote(config: Any) -> bool:
274
274
  return provider in ("openai", "openai-compatible", "remote") or bool(endpoint)
275
275
 
276
276
 
277
+ def _reranker_is_remote(config: Any) -> bool:
278
+ """True when reranking runs against a remote endpoint (v3.8.12, #105).
279
+
280
+ In that case the local ~130MB English cross-encoder download is pointless
281
+ — and worse, misleading, since it is not the model that will score recall.
282
+ Unlike embeddings, the endpoint alone is NOT sufficient: a stray endpoint
283
+ against a local backend is a misconfiguration the engine reports, not a
284
+ remote setup.
285
+ """
286
+ rt = getattr(config, "retrieval", None)
287
+ if rt is None:
288
+ return False
289
+ backend = (getattr(rt, "cross_encoder_backend", "") or "").strip().lower()
290
+ endpoint = getattr(rt, "cross_encoder_endpoint", "") or ""
291
+ return backend in ("openai", "remote") and bool(endpoint)
292
+
293
+
294
+ # Remote-reranker keys the wizard never asks about. ``retrieval`` is otherwise
295
+ # a mode-owned block that the wizard resets from the template, which would
296
+ # silently delete a working remote endpoint on every re-run (#105).
297
+ _USER_OWNED_RETRIEVAL_KEYS = (
298
+ "cross_encoder_backend",
299
+ "cross_encoder_endpoint",
300
+ "cross_encoder_api_key",
301
+ "cross_encoder_model",
302
+ "cross_encoder_timeout_seconds",
303
+ )
304
+
305
+
277
306
  def _build_wizard_config(mode):
278
307
  """Apply mode-owned presets without erasing existing user-owned blocks."""
279
308
  from superlocalmemory.core.config import SLMConfig
@@ -285,7 +314,14 @@ def _build_wizard_config(mode):
285
314
  existing = SLMConfig.load()
286
315
  existing.mode = mode
287
316
  existing.llm = template.llm
317
+ preserved = {
318
+ key: getattr(existing.retrieval, key)
319
+ for key in _USER_OWNED_RETRIEVAL_KEYS
320
+ if hasattr(existing.retrieval, key)
321
+ }
288
322
  existing.retrieval = template.retrieval
323
+ for key, value in preserved.items():
324
+ setattr(existing.retrieval, key, value)
289
325
  existing.math = template.math
290
326
  existing.channel_weights = template.channel_weights
291
327
  return existing
@@ -566,7 +602,15 @@ def run_wizard(auto: bool = False) -> None:
566
602
  print()
567
603
  print("─── Step 4b/10: Download Reranker Model ───")
568
604
 
569
- if not st_ok:
605
+ if _reranker_is_remote(config):
606
+ # v3.8.12 (#105): a remote /v1/rerank endpoint scores the results, so
607
+ # the local English cross-encoder would be dead weight on disk.
608
+ rt = config.retrieval
609
+ print(" ✓ Skipped — remote rerank endpoint configured")
610
+ print(f" backend={getattr(rt, 'cross_encoder_backend', '?')}, "
611
+ f"model={getattr(rt, 'cross_encoder_model', '?')}")
612
+ print(" No local reranker model needed.")
613
+ elif not st_ok:
570
614
  print(" ⚠ Skipped (sentence-transformers not installed)")
571
615
  else:
572
616
  _download_reranker(_RERANKER_MODEL)
@@ -164,6 +164,20 @@ def _embedding_is_remote(config: Any) -> bool:
164
164
  return False
165
165
 
166
166
 
167
+ def _reranker_is_remote(config: Any) -> bool:
168
+ """True when reranking is served by a remote endpoint (v3.8.12, #105).
169
+
170
+ Same shape as ``_embedding_is_remote``: conservative False on any
171
+ import/attribute error so a local reranker is still probed.
172
+ """
173
+ try:
174
+ from superlocalmemory.cli.setup_wizard import _reranker_is_remote as _r
175
+
176
+ return bool(_r(config))
177
+ except Exception:
178
+ return False
179
+
180
+
167
181
  # --------------------------------------------------------------------------
168
182
  # Individual probes — each returns a Component (never raises)
169
183
  # --------------------------------------------------------------------------
@@ -259,6 +273,17 @@ def probe_reranker_model(config: Any = None) -> Component:
259
273
  if config is not None else True
260
274
  except Exception:
261
275
  enabled = True
276
+ if enabled and config is not None and _reranker_is_remote(config):
277
+ # v3.8.12 (#105): a remote /v1/rerank endpoint supplies the scores, so
278
+ # the local 130MB English cross-encoder is neither downloaded nor used.
279
+ # Reporting it MISSING would push the operator to "fix" a component the
280
+ # configured runtime never touches.
281
+ return Component(
282
+ key="reranker_model", label="Reranker model",
283
+ category=CATEGORY_RECOMMENDED, status=STATUS_OK,
284
+ detail="remote rerank endpoint (no local model required)",
285
+ last_checked=time.time(),
286
+ )
262
287
  comp = _probe_hf_model(
263
288
  "reranker_model", "Reranker model", _RERANKER_MODEL,
264
289
  category=CATEGORY_RECOMMENDED if enabled else CATEGORY_OPTIONAL,
@@ -267,7 +267,41 @@ class RetrievalConfig:
267
267
  # relevant facts before reranking. See bench-v342-locomo.md.
268
268
  use_cross_encoder: bool = True
269
269
  cross_encoder_model: str = "cross-encoder/ms-marco-MiniLM-L-12-v2"
270
- cross_encoder_backend: str = "" # "" = PyTorch (~500MB stable), "onnx" = ONNX (leaks on ARM64 CoreML)
270
+ # "" = PyTorch (~500MB stable), "onnx" = ONNX (leaks on ARM64 CoreML),
271
+ # "openai"/"remote" = v3.8.12 (issue #105) OpenAI-compatible /v1/rerank
272
+ # endpoint (llama-server, TEI, Infinity, vLLM, Cohere-shaped services).
273
+ cross_encoder_backend: str = ""
274
+
275
+ # v3.8.12 (issue #105): remote reranker endpoint. The bundled default
276
+ # cross-encoder (ms-marco-MiniLM-L-12-v2) is ENGLISH-ONLY, so non-English
277
+ # deployments scored their own language with a model that cannot read it.
278
+ # Pointing this at a multilingual reranker (bge-reranker-v2-m3, a Qwen
279
+ # reranker, …) is the same escape hatch remote embeddings got in v3.4.24.
280
+ #
281
+ # This key was accepted-and-ignored before 3.8.12 (issue #103): it was not
282
+ # a dataclass field, so ``SLMConfig.load`` filtered it out without a word.
283
+ # It is now read, validated, and — when it disagrees with the backend —
284
+ # reported as a loud configuration error instead of nothing at all.
285
+ cross_encoder_endpoint: str = ""
286
+ # Optional bearer token. Prefer the SLM_CROSS_ENCODER_API_KEY environment
287
+ # variable — it takes precedence and keeps the secret out of config.json.
288
+ cross_encoder_api_key: str = ""
289
+ # Per-request read budget for the remote reranker. Recall is interactive,
290
+ # so this stays tight: a slow reranker degrades to fusion scores rather
291
+ # than holding the recall open.
292
+ cross_encoder_timeout_seconds: float = 15.0
293
+
294
+ @property
295
+ def is_remote_cross_encoder(self) -> bool:
296
+ """True when reranking is served by a remote HTTP endpoint."""
297
+ from superlocalmemory.retrieval.remote_reranker import (
298
+ is_remote_cross_encoder_backend,
299
+ )
300
+
301
+ return (
302
+ is_remote_cross_encoder_backend(self.cross_encoder_backend)
303
+ and bool(self.cross_encoder_endpoint)
304
+ )
271
305
 
272
306
  # Agentic (Mode C only)
273
307
  agentic_max_rounds: int = 3
@@ -22,8 +22,88 @@ if TYPE_CHECKING:
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
24
 
25
+ def init_reranker(retrieval_config: Any) -> Any:
26
+ """Build the reranker the config asks for — remote endpoint or local worker.
27
+
28
+ v3.8.12 (issue #105). The remote branch is decided HERE, in the parent
29
+ process, before any subprocess exists. Spawning a worker whose only job
30
+ would be to forward an HTTP POST costs a fork, a machine-wide PID
31
+ singleton, and a warmup handshake for nothing — and issue #103 showed that
32
+ singleton blocking a reranker that was never local in the first place.
33
+
34
+ Misconfiguration is reported, never absorbed:
35
+ * remote backend with no/invalid endpoint -> reranking is DISABLED with
36
+ an error naming the fix. Quietly loading the English local model
37
+ instead would recreate the exact silent degradation #105 is about.
38
+ * endpoint set against a LOCAL backend -> error naming both keys, then
39
+ the local reranker runs as the backend actually requested. Before
40
+ 3.8.12 this combination was dropped in silence (issue #103).
41
+
42
+ Returns the reranker, or None when reranking must stay off.
43
+ """
44
+ from superlocalmemory.retrieval.remote_reranker import (
45
+ RemoteReranker,
46
+ RemoteRerankerConfigError,
47
+ is_remote_cross_encoder_backend,
48
+ validate_remote_reranker_config,
49
+ )
50
+ from superlocalmemory.retrieval.reranker import CrossEncoderReranker
51
+
52
+ backend = getattr(retrieval_config, "cross_encoder_backend", "") or ""
53
+ endpoint = getattr(retrieval_config, "cross_encoder_endpoint", "") or ""
54
+ model = getattr(
55
+ retrieval_config, "cross_encoder_model",
56
+ "cross-encoder/ms-marco-MiniLM-L-12-v2",
57
+ )
58
+ remote_requested = is_remote_cross_encoder_backend(backend)
59
+
60
+ error = validate_remote_reranker_config(backend, endpoint)
61
+ if error and remote_requested:
62
+ logger.error(
63
+ "Remote reranker not started — %s Reranking is DISABLED; recall "
64
+ "returns fusion-ranked results.", error,
65
+ )
66
+ return None
67
+ if error:
68
+ logger.error(
69
+ "Reranker configuration conflict — %s Continuing with the local "
70
+ "cross-encoder as configured.", error,
71
+ )
72
+ elif remote_requested:
73
+ try:
74
+ return RemoteReranker(
75
+ model,
76
+ endpoint,
77
+ api_key=getattr(retrieval_config, "cross_encoder_api_key", ""),
78
+ backend=backend,
79
+ timeout_seconds=getattr(
80
+ retrieval_config, "cross_encoder_timeout_seconds", 15.0,
81
+ ),
82
+ )
83
+ except RemoteRerankerConfigError as exc:
84
+ logger.error(
85
+ "Remote reranker not started — %s Reranking is DISABLED.", exc,
86
+ )
87
+ return None
88
+
89
+ return CrossEncoderReranker(model, backend=backend)
90
+
91
+
25
92
  def _log_reranker_warmup_status(reranker: Any) -> None:
26
93
  """Record non-blocking reranker warmup state without alarming first-run users."""
94
+ from superlocalmemory.retrieval.remote_reranker import RemoteReranker
95
+
96
+ # isinstance, not a duck-typed attribute probe: MagicMock fabricates any
97
+ # attribute on demand, so ``getattr(reranker, "is_remote", False)`` would
98
+ # route every mocked reranker in the suite down the remote branch. Same
99
+ # hazard RetrievalEngine guards against when it checks the TYPE for
100
+ # ``rerank_with_status``.
101
+ if isinstance(reranker, RemoteReranker):
102
+ # The remote reranker logs its own probe outcome (endpoint, model, and
103
+ # the precise transport error). A second generic line about a local
104
+ # worker singleton would be noise at best and misleading at worst.
105
+ reranker.warmup_sync()
106
+ return
27
107
  ready = reranker.warmup_sync(timeout=180)
28
108
  if ready:
29
109
  logger.info("Cross-encoder reranker warm and ready")
@@ -508,7 +588,6 @@ def init_retrieval(
508
588
  from superlocalmemory.retrieval.bm25_channel import BM25Channel
509
589
  from superlocalmemory.retrieval.entity_channel import EntityGraphChannel
510
590
  from superlocalmemory.retrieval.temporal_channel import TemporalChannel
511
- from superlocalmemory.retrieval.reranker import CrossEncoderReranker
512
591
  from superlocalmemory.retrieval.profile_channel import ProfileChannel
513
592
  from superlocalmemory.retrieval.bridge_discovery import BridgeDiscovery
514
593
 
@@ -541,10 +620,7 @@ def init_retrieval(
541
620
 
542
621
  reranker = None
543
622
  if config.retrieval.use_cross_encoder:
544
- reranker = CrossEncoderReranker(
545
- config.retrieval.cross_encoder_model,
546
- backend=config.retrieval.cross_encoder_backend,
547
- )
623
+ reranker = init_reranker(config.retrieval)
548
624
 
549
625
  profile_ch = ProfileChannel(db)
550
626
  bridge = BridgeDiscovery(db)
@@ -283,7 +283,14 @@ class _ReadOnlyLearningView:
283
283
  connection.close()
284
284
 
285
285
  def count_feedback(self, profile_id: str) -> int:
286
- """Count legacy feedback without running schema initialization."""
286
+ """Count legacy feedback without running schema initialization.
287
+
288
+ Reports the raw ``learning_feedback`` table, which the dashboard
289
+ surfaces as ``legacy_feedback_rows`` alongside a pending-migration
290
+ card. Do NOT gate a ranking phase on this — use ``count_signals``,
291
+ which is the counter every other surface resolves its phase from
292
+ (issue #106).
293
+ """
287
294
  connection = self._connection()
288
295
  try:
289
296
  row = connection.execute(
@@ -408,17 +415,31 @@ def apply_adaptive_ranking(
408
415
  if not learning_db.exists():
409
416
  return response
410
417
 
418
+ # issue #106: count the CANONICAL store, not the legacy one. The
419
+ # dashboard's Living Brain panel and ranker-phase card both resolve their
420
+ # phase from ``learning_signals``; this gate read ``learning_feedback``,
421
+ # so the phase a user was shown and the phase that actually ranked their
422
+ # results were computed from different tables and could disagree without
423
+ # limit. ``learning_feedback`` rows reach this counter through
424
+ # ``legacy_migration``, which copies them forward.
411
425
  try:
412
- signal_count = _ReadOnlyLearningView(learning_db).count_feedback(pid)
426
+ signal_count = _ReadOnlyLearningView(learning_db).count_signals(pid)
413
427
  except sqlite3.Error:
414
428
  # A pre-learning database may not have this optional table yet.
415
429
  # Recall remains a query and cannot create it on demand.
416
430
  return response
417
431
 
418
- if signal_count < 50:
432
+ from superlocalmemory.learning.ranker import (
433
+ PHASE_2_THRESHOLD,
434
+ AdaptiveRanker,
435
+ )
436
+
437
+ # Thresholds come from ``learning.ranker`` — the same constants the
438
+ # dashboard gates on. Duplicating the literals here is how the two
439
+ # surfaces drifted apart in the first place.
440
+ if signal_count < PHASE_2_THRESHOLD:
419
441
  return response # Phase 1: no change
420
442
 
421
- from superlocalmemory.learning.ranker import AdaptiveRanker
422
443
  ranker = AdaptiveRanker(signal_count=signal_count)
423
444
 
424
445
  from datetime import UTC
@@ -230,6 +230,11 @@ def _worker_main() -> None:
230
230
 
231
231
 
232
232
  _KNOWN_BACKENDS = ("onnx", "", "pytorch", "torch")
233
+ # Backends this worker can never serve — they are handled over HTTP by
234
+ # superlocalmemory.retrieval.remote_reranker in the parent process (#105).
235
+ # Duplicated as a literal on purpose: this module runs as a bare subprocess
236
+ # and must not import the retrieval package (or, transitively, httpx).
237
+ _REMOTE_BACKENDS = ("openai", "remote")
233
238
 
234
239
 
235
240
  def _load_model(
@@ -253,12 +258,26 @@ def _load_model(
253
258
  # the PyTorch tier and fail there with a confusing model-load error. A
254
259
  # user who set backend="openai" expecting a remote reranker got five
255
260
  # silent failures and no hint that the value meant nothing. Name it.
261
+ #
262
+ # v3.8.12 (issue #105): remote reranking now EXISTS, but it is served in
263
+ # the parent process — this worker holds torch/ONNX and cannot forward an
264
+ # HTTP request. Reaching here with a remote backend means the parent
265
+ # routed wrong (or a caller drove the worker directly), so the message
266
+ # points at the config keys that select the remote path.
267
+ if backend in _REMOTE_BACKENDS:
268
+ return None, "", "", (
269
+ f"unknown backend {backend!r} for the LOCAL reranker worker. "
270
+ f"{backend!r} selects the remote reranker, which runs in the "
271
+ f"parent process — set retrieval.cross_encoder_endpoint (e.g. "
272
+ f"\"http://127.0.0.1:8041/v1/rerank\") so SuperLocalMemory routes "
273
+ f"reranking over HTTP instead of spawning this worker."
274
+ )
256
275
  if backend not in _KNOWN_BACKENDS:
257
276
  return None, "", "", (
258
- f"unknown backend {backend!r}; supported values are "
259
- f"'onnx' or '' (PyTorch). SuperLocalMemory has no remote/"
260
- f"OpenAI-compatible reranker backend the cross-encoder always "
261
- f"runs locally, so 'cross_encoder_endpoint' has no effect."
277
+ f"unknown backend {backend!r}; supported values are 'onnx' or ''"
278
+ f" (PyTorch) for local reranking, or 'openai'/'remote' with "
279
+ f"retrieval.cross_encoder_endpoint set for a remote "
280
+ f"OpenAI-compatible /v1/rerank endpoint."
262
281
  )
263
282
 
264
283
  tier_errors: list[str] = []
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any, Mapping
27
27
 
28
28
  from superlocalmemory.infra.data_root import canonical_data_root
29
+ from superlocalmemory.infra.process_identity import process_start_token_for
29
30
 
30
31
  DAEMON_DESCRIPTOR_SCHEMA = 1
31
32
  DAEMON_PROTOCOL = 1
@@ -88,6 +89,11 @@ class DaemonDescriptor:
88
89
  state: str
89
90
  version: str
90
91
  started_at: float
92
+ # Clock-independent process identity. Optional and defaulted so a
93
+ # descriptor written by an older release still parses; platforms without a
94
+ # boot-relative start time (Windows) legitimately store None and fall back
95
+ # to the creation-time comparison. See infra/process_identity.py.
96
+ process_start_token: str | None = None
91
97
 
92
98
  def public_health_fields(self) -> dict[str, Any]:
93
99
  """Identity fields safe to expose on the loopback health endpoint."""
@@ -112,6 +118,7 @@ def build_descriptor(
112
118
  version: str,
113
119
  pid: int | None = None,
114
120
  process_create_time: float | None = None,
121
+ process_start_token: str | None = None,
115
122
  instance_id: str | None = None,
116
123
  capability: str | None = None,
117
124
  state: str = "starting",
@@ -141,6 +148,11 @@ def build_descriptor(
141
148
  state=state,
142
149
  version=version,
143
150
  started_at=float(started_at if started_at is not None else time.time()),
151
+ process_start_token=(
152
+ process_start_token
153
+ if process_start_token is not None
154
+ else process_start_token_for(actual_pid)
155
+ ),
144
156
  )
145
157
 
146
158
 
@@ -238,6 +250,10 @@ def read_descriptor(
238
250
  return None
239
251
  if not (1 <= descriptor.port <= 65535) or descriptor.pid <= 0:
240
252
  return None
253
+ if descriptor.process_start_token is not None and not isinstance(
254
+ descriptor.process_start_token, str
255
+ ):
256
+ return None
241
257
  return descriptor
242
258
 
243
259
 
@@ -0,0 +1,180 @@
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 | https://varunpratap.com
4
+
5
+ """Clock-independent identity for one running local process.
6
+
7
+ Why this module exists
8
+ ----------------------
9
+ A process's *wall-clock* creation time is not a stable identifier on every
10
+ platform. On Linux -- and therefore inside WSL2 -- psutil derives it as::
11
+
12
+ create_time = /proc/<pid>/stat:starttime / CLOCK_TICKS + /proc/stat:btime
13
+
14
+ ``starttime`` is boot-relative and never changes for the life of the process.
15
+ ``btime`` is the kernel's *estimate* of the boot instant, recomputed from the
16
+ current wall clock, so any clock step moves ``btime`` and moves every process's
17
+ computed ``create_time`` with it -- retroactively. WSL2 periodically
18
+ resynchronises its VM clock against the Windows host, so a ``create_time``
19
+ recorded when the daemon started stops matching the ``create_time`` computed
20
+ for that very same process minutes later. Issue #104 measured a ~35 second
21
+ divergence after roughly four minutes of uptime.
22
+
23
+ Any *constant* tolerance on that comparison is a delay, not a fix: the
24
+ divergence is unbounded and keeps growing. The correct identifier is one the
25
+ wall clock cannot move at all, which is what this module produces.
26
+
27
+ What a start token is
28
+ ---------------------
29
+ ``process_start_token_for(pid)`` returns an opaque string identifying one
30
+ process *instance*, derived without reference to wall-clock time, or ``None``
31
+ when the platform cannot supply one. Tokens are only comparable when they use
32
+ the same scheme, so :func:`compare_start_tokens` is deliberately tri-state --
33
+ callers fall back to a weaker signal instead of guessing.
34
+
35
+ Schemes
36
+ -------
37
+ ``lx1``
38
+ Linux/WSL2: ``lx1:<boot_id>:<starttime_ticks>``. Both halves come straight
39
+ from procfs and are an opaque UUID and an integer tick count rather than
40
+ timestamps, so a clock adjustment cannot rewrite either. ``boot_id``
41
+ differs across reboots, so a post-reboot PID collision can never look like
42
+ a match.
43
+ ``mn1``
44
+ Platforms where psutil exposes a monotonic creation time (macOS, NetBSD,
45
+ and Linux if procfs is unreadable): ``mn1:<value>``. psutil builds its own
46
+ PID-reuse identity from exactly this value for exactly this reason.
47
+
48
+ Windows has no monotonic variant, and needs none: its creation time comes from
49
+ ``GetProcessTimes``, a kernel timestamp that a clock adjustment does not
50
+ rewrite. ``process_start_token_for`` returns ``None`` there and the caller's
51
+ creation-time comparison stays correct.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import logging
57
+ import sys
58
+ from pathlib import Path
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ LINUX_SCHEME = "lx1"
63
+ MONOTONIC_SCHEME = "mn1"
64
+
65
+ _PROCFS = Path("/proc")
66
+ # "man proc" numbers /proc/<pid>/stat fields from 1 and starttime is field 22.
67
+ # The comm field can contain spaces and parentheses, so parsing starts after
68
+ # the last ')', which drops fields 1 and 2 -- hence 22 - 3 == 19.
69
+ _STARTTIME_INDEX = 19
70
+
71
+
72
+ def _boot_id() -> str | None:
73
+ """Return this boot's opaque kernel identifier, or None when unavailable.
74
+
75
+ Deliberately strict: without a boot id, two processes from different boots
76
+ could share a PID *and* a tick count, so the token would be unsound. A
77
+ missing boot id therefore means "no token" rather than a weaker token.
78
+ """
79
+ try:
80
+ value = (_PROCFS / "sys" / "kernel" / "random" / "boot_id").read_text(
81
+ encoding="utf-8",
82
+ ).strip()
83
+ except OSError:
84
+ return None
85
+ return value or None
86
+
87
+
88
+ def _linux_start_ticks(pid: int) -> int | None:
89
+ """Return boot-relative start ticks for a PID from procfs, or None."""
90
+ try:
91
+ data = (_PROCFS / str(pid) / "stat").read_bytes()
92
+ except OSError:
93
+ return None
94
+ closing = data.rfind(b")")
95
+ if closing < 0:
96
+ return None
97
+ fields = data[closing + 2:].split()
98
+ if len(fields) <= _STARTTIME_INDEX:
99
+ return None
100
+ try:
101
+ return int(fields[_STARTTIME_INDEX])
102
+ except ValueError:
103
+ return None
104
+
105
+
106
+ def _linux_start_token(pid: int) -> str | None:
107
+ boot_id = _boot_id()
108
+ if boot_id is None:
109
+ return None
110
+ ticks = _linux_start_ticks(pid)
111
+ if ticks is None:
112
+ return None
113
+ return f"{LINUX_SCHEME}:{boot_id}:{ticks}"
114
+
115
+
116
+ def _monotonic_start_token(pid: int) -> str | None:
117
+ """Return psutil's monotonic creation time as a token, or None.
118
+
119
+ ``Process._proc.create_time(monotonic=True)`` is the same private accessor
120
+ psutil uses internally to build ``Process._ident``. It is guarded on every
121
+ axis -- missing psutil, missing attribute, platforms whose implementation
122
+ takes no ``monotonic`` keyword (Windows) -- and degrades to ``None``.
123
+ """
124
+ try:
125
+ import psutil
126
+
127
+ platform_process = getattr(psutil.Process(pid), "_proc", None)
128
+ except Exception: # noqa: BLE001 - identity probing must never raise
129
+ return None
130
+ create_time = getattr(platform_process, "create_time", None)
131
+ if create_time is None:
132
+ return None
133
+ try:
134
+ raw = create_time(monotonic=True)
135
+ except TypeError:
136
+ # No monotonic variant on this platform (Windows).
137
+ return None
138
+ except Exception: # noqa: BLE001
139
+ return None
140
+ try:
141
+ return f"{MONOTONIC_SCHEME}:{float(raw)!r}"
142
+ except (TypeError, ValueError):
143
+ return None
144
+
145
+
146
+ def process_start_token_for(pid: int) -> str | None:
147
+ """Return a clock-independent identity token for ``pid``, or None.
148
+
149
+ ``None`` is a normal answer, not an error: it means "this platform cannot
150
+ prove process identity without the wall clock", and the caller should fall
151
+ back to comparing creation times.
152
+ """
153
+ try:
154
+ pid = int(pid)
155
+ except (TypeError, ValueError):
156
+ return None
157
+ if pid <= 0:
158
+ return None
159
+ if sys.platform.startswith("linux"):
160
+ token = _linux_start_token(pid)
161
+ if token is not None:
162
+ return token
163
+ return _monotonic_start_token(pid)
164
+
165
+
166
+ def compare_start_tokens(recorded: str | None, observed: str | None) -> bool | None:
167
+ """Tri-state comparison of two start tokens.
168
+
169
+ ``True`` -- same scheme, identical value: proven the same process instance.
170
+ ``False`` -- same scheme, different value: proven a different instance.
171
+ ``None`` -- not comparable (either side missing, or different schemes);
172
+ the caller must fall back rather than assume either way.
173
+ """
174
+ if not recorded or not observed:
175
+ return None
176
+ if not isinstance(recorded, str) or not isinstance(observed, str):
177
+ return None
178
+ if recorded.split(":", 1)[0] != observed.split(":", 1)[0]:
179
+ return None
180
+ return recorded == observed