superlocalmemory 3.8.10 → 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.
- package/CHANGELOG.md +91 -0
- package/README.md +7 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +28 -6
- package/src/superlocalmemory/cli/daemon.py +219 -10
- package/src/superlocalmemory/cli/setup_wizard.py +45 -1
- package/src/superlocalmemory/core/component_registry.py +25 -0
- package/src/superlocalmemory/core/config.py +35 -1
- package/src/superlocalmemory/core/engine_wiring.py +81 -5
- package/src/superlocalmemory/core/recall_pipeline.py +25 -4
- package/src/superlocalmemory/core/reranker_worker.py +78 -17
- package/src/superlocalmemory/infra/daemon_identity.py +16 -0
- package/src/superlocalmemory/infra/process_identity.py +180 -0
- package/src/superlocalmemory/learning/feedback.py +328 -27
- package/src/superlocalmemory/learning/legacy_migration.py +45 -4
- package/src/superlocalmemory/learning/pattern_miner.py +31 -11
- package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
- package/src/superlocalmemory/mcp/tools_active.py +179 -17
- package/src/superlocalmemory/mcp/tools_core.py +6 -5
- package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
- package/src/superlocalmemory/retrieval/reranker.py +52 -5
- package/src/superlocalmemory/server/unified_daemon.py +4 -0
- package/src/superlocalmemory/storage/migration_runner.py +9 -0
- package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
|
@@ -119,7 +119,9 @@ def _worker_main() -> None:
|
|
|
119
119
|
if cmd == "load":
|
|
120
120
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
121
121
|
backend = req.get("backend", "onnx")
|
|
122
|
-
model, active_backend, model_name = _load_model(
|
|
122
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
123
|
+
name, backend,
|
|
124
|
+
)
|
|
123
125
|
# V3.3.16: Run real inference to trigger ONNX CoreML JIT compilation.
|
|
124
126
|
# Without this, first real rerank call triggers 30-60s compilation
|
|
125
127
|
# that exceeds the caller's timeout, killing the worker.
|
|
@@ -147,6 +149,9 @@ def _worker_main() -> None:
|
|
|
147
149
|
"backend": active_backend,
|
|
148
150
|
"model": model_name,
|
|
149
151
|
"warmup_inference": warmup_ok,
|
|
152
|
+
# Carries the real reason to the parent so the warmup log can
|
|
153
|
+
# print it instead of a generic timeout message (issue #103).
|
|
154
|
+
"error": load_error,
|
|
150
155
|
})
|
|
151
156
|
continue
|
|
152
157
|
|
|
@@ -160,9 +165,14 @@ def _worker_main() -> None:
|
|
|
160
165
|
# Auto-load with defaults
|
|
161
166
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
162
167
|
backend = req.get("backend", "onnx")
|
|
163
|
-
model, active_backend, model_name = _load_model(
|
|
168
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
169
|
+
name, backend,
|
|
170
|
+
)
|
|
164
171
|
if model is None:
|
|
165
|
-
_respond({
|
|
172
|
+
_respond({
|
|
173
|
+
"ok": False,
|
|
174
|
+
"error": load_error or "Model load failed",
|
|
175
|
+
})
|
|
166
176
|
continue
|
|
167
177
|
try:
|
|
168
178
|
pairs = [(query, doc) for doc in documents]
|
|
@@ -195,9 +205,14 @@ def _worker_main() -> None:
|
|
|
195
205
|
if model is None:
|
|
196
206
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
197
207
|
backend = req.get("backend", "onnx")
|
|
198
|
-
model, active_backend, model_name = _load_model(
|
|
208
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
209
|
+
name, backend,
|
|
210
|
+
)
|
|
199
211
|
if model is None:
|
|
200
|
-
_respond({
|
|
212
|
+
_respond({
|
|
213
|
+
"ok": False,
|
|
214
|
+
"error": load_error or "Model load failed",
|
|
215
|
+
})
|
|
201
216
|
continue
|
|
202
217
|
try:
|
|
203
218
|
try:
|
|
@@ -214,10 +229,18 @@ def _worker_main() -> None:
|
|
|
214
229
|
_respond({"ok": False, "error": f"Unknown command: {cmd}"})
|
|
215
230
|
|
|
216
231
|
|
|
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")
|
|
238
|
+
|
|
239
|
+
|
|
217
240
|
def _load_model(
|
|
218
241
|
name: str, backend: str,
|
|
219
242
|
) -> tuple:
|
|
220
|
-
"""Load cross-encoder model. Returns (model, backend_name, model_name).
|
|
243
|
+
"""Load cross-encoder model. Returns (model, backend_name, model_name, error).
|
|
221
244
|
|
|
222
245
|
V3.3.13: sentence-transformers 5.x+ supports backend='onnx' for
|
|
223
246
|
CrossEncoder. We use a 3-tier fallback chain:
|
|
@@ -231,6 +254,33 @@ def _load_model(
|
|
|
231
254
|
x86_64 → model_quint8_avx2.onnx
|
|
232
255
|
Fallback → model.onnx (generic)
|
|
233
256
|
"""
|
|
257
|
+
# v3.8.11 (issue #103): an unrecognised backend used to fall through to
|
|
258
|
+
# the PyTorch tier and fail there with a confusing model-load error. A
|
|
259
|
+
# user who set backend="openai" expecting a remote reranker got five
|
|
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
|
+
)
|
|
275
|
+
if backend not in _KNOWN_BACKENDS:
|
|
276
|
+
return None, "", "", (
|
|
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."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
tier_errors: list[str] = []
|
|
234
284
|
try:
|
|
235
285
|
from sentence_transformers import CrossEncoder
|
|
236
286
|
|
|
@@ -242,24 +292,35 @@ def _load_model(
|
|
|
242
292
|
name, backend="onnx",
|
|
243
293
|
model_kwargs={"file_name": onnx_file},
|
|
244
294
|
)
|
|
245
|
-
return m, f"onnx-quantized({onnx_file})", name
|
|
246
|
-
except Exception:
|
|
247
|
-
|
|
295
|
+
return m, f"onnx-quantized({onnx_file})", name, ""
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
tier_errors.append(f"onnx-quantized: {exc}")
|
|
248
298
|
|
|
249
299
|
# Tier 2: Generic ONNX (auto-exported by optimum)
|
|
250
300
|
try:
|
|
251
301
|
m = CrossEncoder(name, backend="onnx")
|
|
252
|
-
return m, "onnx", name
|
|
253
|
-
except Exception:
|
|
254
|
-
|
|
302
|
+
return m, "onnx", name, ""
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
tier_errors.append(f"onnx: {exc}")
|
|
255
305
|
|
|
256
306
|
# Tier 3: PyTorch (always works, no ONNX dependency needed)
|
|
257
307
|
m = CrossEncoder(name)
|
|
258
|
-
return m, "pytorch", name
|
|
259
|
-
except ImportError:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
return m, "pytorch", name, ""
|
|
309
|
+
except ImportError as exc:
|
|
310
|
+
# Previously indistinguishable from a bad model name.
|
|
311
|
+
return None, "", "", (
|
|
312
|
+
f"sentence-transformers is not installed ({exc}); "
|
|
313
|
+
f"install it or set retrieval.use_cross_encoder=false"
|
|
314
|
+
)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
tier_errors.append(f"pytorch: {exc}")
|
|
317
|
+
# Every tier's real error, propagated instead of discarded. Before
|
|
318
|
+
# 3.8.11 this returned (None, "", "") and the operator saw only a
|
|
319
|
+
# generic "did not confirm ready" line from the parent process.
|
|
320
|
+
return None, "", "", (
|
|
321
|
+
f"could not load cross-encoder model {name!r} "
|
|
322
|
+
f"(backend={backend or 'pytorch'}): " + "; ".join(tier_errors)
|
|
323
|
+
)
|
|
263
324
|
|
|
264
325
|
|
|
265
326
|
def _respond(data: dict) -> None:
|
|
@@ -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
|