@dzhechkov/harness-core 0.8.30 → 0.8.31

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 (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. package/src/vector-tier.ts +45 -4
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The agentdb snapshot lock + reindex-in-progress marker (feature `agentdb-snapshot-lock`,
3
+ * FR-1..FR-4; fix-round AM-1..AM-4/AM-7 after Codex review Grade D).
4
+ *
5
+ * WHY. Snapshot creation (in `reindexAgentdbRows`), snapshot rotation (`rotatePreReindexSnapshots`,
6
+ * including `dz brain snapshots --prune`) and snapshot restore (`restoreSqliteSnapshot` via
7
+ * `reindexAgentdbRows`'s rollback) are three writers of ONE directory with no mutual exclusion: a
8
+ * concurrent `rotate --keep 0` can delete the very snapshot family a live reindex is relying on as
9
+ * its undo point, with only a 10-minute grace period standing in the way.
10
+ *
11
+ * {@link withAgentdbSnapshotLock} is a thin, dbFile-addressed wrapper over `withNamedLockSync`: the
12
+ * lock lives at `<dirname(dbFile)>/.dz/locks/agentdb-snapshot.lock` — a pure function of the
13
+ * database's OWN directory, never of the caller's cwd, so a project store and the home brain each
14
+ * get their own lock (AC-5) regardless of where `dz` happens to be invoked from. AM-7: it defaults
15
+ * `staleMs` to {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} (5 minutes) rather than named-lock's ordinary
16
+ * 30-second default — MEASURED (lead, this fix round): `VACUUM INTO` on the owner's hub store
17
+ * (8.45 MB) took 96 ms, so 5 minutes leaves roughly 3000x headroom while staying inside
18
+ * named-lock's own 600 000 ms environment-override ceiling. A caller doing something unusually
19
+ * large may still override via `opts.staleMs`.
20
+ *
21
+ * The critical section this lock guards must stay SHORT and SYNCHRONOUS — file operations only
22
+ * (`VACUUM INTO` / copies / unlink / restore). MEASURED (named-lock.ts round-2, reused verbatim
23
+ * here): a 6s synchronous body under a 2s stale threshold let a THIRD process break in while the
24
+ * second was still inside its own critical section — a long body doesn't just block callers, it
25
+ * breaks the lock's own guarantee. The actual re-embedding (network/CPU-bound `EmbeddingService`
26
+ * calls, row writes) therefore runs OUTSIDE this lock — see `reindexAgentdbRows`.
27
+ *
28
+ * {@link writeReindexMarker} / {@link clearReindexMarker} / {@link readLiveReindexMarkers} implement
29
+ * the SECOND protection this feature adds — a "reindex is currently in flight" marker file beside
30
+ * the database, so a family a live reindex just created is protected from rotation for the
31
+ * DURATION of the reindex, not merely for the few milliseconds the snapshot lock itself is held
32
+ * (the embedding phase can run for a while, unlocked, per FR-2). The marker's liveness is judged by
33
+ * `startedAt` (a wall-clock age, {@link REINDEX_MARKER_TTL_MS}), never by `pid` — a pid is recorded
34
+ * for operator debugging only; a live pid on a different host, or a recycled pid, proves nothing
35
+ * ("pid is not authority over liveness" — the same lesson `store-lock.ts`/`named-lock.ts` already
36
+ * encode for lock staleness). A marker older than the TTL is treated as abandoned: it protects
37
+ * nothing, and rotation removes it, recording why in the report's `notes` — that is an honest
38
+ * observation about a stale marker, never a scan error.
39
+ *
40
+ * AM-1 (fix-round): {@link writeReindexMarker} creates the marker file EXCLUSIVELY
41
+ * (`openSync(path, 'wx')`) and stamps it with an ownership `token` (16 random hex chars). A LIVE
42
+ * marker already at that path (another reindex genuinely in flight) refuses this call outright —
43
+ * `{ ok: false, error }`, no snapshot ever taken for the refused call. A marker at or past the TTL
44
+ * is treated as abandoned and replaced. {@link clearReindexMarker} is compare-and-delete: it removes
45
+ * the marker only when the caller's `token` matches the one on disk, so a process can never tear
46
+ * down a marker it does not own (`{ cleared: false, reason }` otherwise). The narrow race this
47
+ * leaves — two `writeReindexMarker` calls racing the exclusive-create step itself — cannot happen
48
+ * in practice: every caller in this codebase invokes it from INSIDE the very snapshot lock this
49
+ * module also provides (see `reindexAgentdbRows`), so at most one writer is ever in this function
50
+ * at a time.
51
+ *
52
+ * @packageDocumentation
53
+ */
54
+
55
+ import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
56
+ import { randomBytes } from 'node:crypto';
57
+ import { basename, dirname } from 'node:path';
58
+
59
+ import { withNamedLockSync } from './named-lock.js';
60
+ import type { StoreLockOptions } from './store-lock.js';
61
+
62
+ /** A marker older than this is abandoned — its family is no longer protected (FR-3). `pid` is
63
+ * recorded for operator debugging only and is NEVER consulted to decide liveness. */
64
+ export const REINDEX_MARKER_TTL_MS = 60 * 60 * 1000;
65
+
66
+ /** AM-7: MEASURED (lead, this fix round) — `VACUUM INTO` on the hub's 8.45 MB agentdb store took
67
+ * 96 ms. 5 minutes is ~3000x that, and stays inside named-lock's 600 000 ms override ceiling. A
68
+ * base whose snapshot genuinely needs longer than this needs external coordination, not a bigger
69
+ * default — see the README. */
70
+ export const AGENTDB_SNAPSHOT_LOCK_STALE_MS = 300_000;
71
+
72
+ /** The reindex-in-progress marker written beside the database while a reindex is running. */
73
+ export interface ReindexMarker {
74
+ /** The `<ms>` timestamp of THIS reindex's pre-reindex snapshot family — the value rotation must
75
+ * protect while the marker is live. AM-4: `null` when the reindex's `backupPath` does not match
76
+ * the standard `<db>.pre-reindex-<ms>.bak` family shape (a non-standard target names no family,
77
+ * so it is never a rotation candidate in the first place). */
78
+ readonly ms: number | null;
79
+ /** Operator-debugging only — never consulted for liveness (pid is not authority). */
80
+ readonly pid: number;
81
+ /** Wall-clock start time (`Date.now()`); liveness = `now - startedAt < REINDEX_MARKER_TTL_MS`. */
82
+ readonly startedAt: number;
83
+ /** AM-1: ownership token (16 random hex chars) — {@link clearReindexMarker} removes the marker
84
+ * only when the caller presents this exact token back. */
85
+ readonly token: string;
86
+ /** AM-4: the reindex's actual backup path, always recorded — the only way to identify the
87
+ * snapshot a `ms: null` marker is protecting (an operator debugging a stuck non-standard target
88
+ * has nothing else to go on). */
89
+ readonly backupPath?: string;
90
+ /** Lead edit after re-review (Codex D): set by {@link markReindexMarkerRecoveryRequired} when a
91
+ * rollback FAILED. Such a marker never expires — its family stays protected and every new reindex
92
+ * of this store is refused until an operator restores the snapshot and removes the marker. */
93
+ readonly requiresRecovery?: boolean;
94
+ readonly recoveryNote?: string;
95
+ }
96
+
97
+ /** The marker's path — always beside the database, never in a separate directory. */
98
+ export function reindexMarkerPath(dbFile: string): string {
99
+ return `${dbFile}.reindex-inprogress.json`;
100
+ }
101
+
102
+ /**
103
+ * AM-4: recompute a marker's `ms` from the ACTUAL backup path's filename — decoupled from whatever
104
+ * internal `ms` variable a caller used to build a DEFAULT `backupPath`. A caller-supplied
105
+ * `opts.backupPath` that does not end in the standard `.pre-reindex-<digits>.bak` shape (or whose
106
+ * digits overflow a safe integer) names no family at all, so it maps to `null` — such a target is
107
+ * never a rotation candidate, and the marker must not claim to protect one.
108
+ */
109
+ export function msFromBackupPath(backupPath: string): number | null {
110
+ const match = /\.pre-reindex-(\d+)\.bak$/.exec(basename(backupPath));
111
+ if (match === null) return null;
112
+ const ms = Number(match[1]);
113
+ return Number.isSafeInteger(ms) ? ms : null;
114
+ }
115
+
116
+ /**
117
+ * Run `fn` while holding the agentdb snapshot lock for `dbFile` — a pure function of the
118
+ * database's own directory (AC-5), never of `process.cwd()`. Propagates `NamedLockTimeoutError`
119
+ * (FR-4): a caller that cannot acquire the lock by the deadline must report the failure explicitly,
120
+ * never proceed unlocked and never silently skip its work. AM-7: `staleMs` defaults to
121
+ * {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} unless the caller names its own.
122
+ */
123
+ export function withAgentdbSnapshotLock<T>(
124
+ dbFile: string,
125
+ fn: () => T,
126
+ opts: StoreLockOptions = {},
127
+ ): T {
128
+ return withNamedLockSync(dirname(dbFile), 'agentdb-snapshot', fn, {
129
+ staleMs: AGENTDB_SNAPSHOT_LOCK_STALE_MS,
130
+ ...opts,
131
+ });
132
+ }
133
+
134
+ /** Read + best-effort-parse the marker at `path`; `undefined` on any missing/unreadable/corrupt
135
+ * field — callers treat that identically to "no marker" or "not provably live", never as live. */
136
+ function tryReadStartedAt(path: string): number | undefined {
137
+ try {
138
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<ReindexMarker>;
139
+ return typeof parsed.startedAt === 'number' && Number.isFinite(parsed.startedAt) ? parsed.startedAt : undefined;
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * AM-1: write the reindex-in-progress marker EXCLUSIVELY. A marker already at this path that is
147
+ * still LIVE (younger than {@link REINDEX_MARKER_TTL_MS}) refuses this write outright —
148
+ * `{ ok: false, error }` naming the marker path, with no snapshot ever attempted by the caller for
149
+ * this call (the caller checks `ok` BEFORE touching the database — see `reindexAgentdbRows`). A
150
+ * marker that is missing, unreadable/corrupt, or past the TTL is replaced. The written marker
151
+ * carries a fresh ownership `token`; only {@link clearReindexMarker} presenting that SAME token may
152
+ * remove it.
153
+ */
154
+ export function writeReindexMarker(
155
+ dbFile: string,
156
+ marker: { readonly ms: number | null; readonly pid: number; readonly startedAt: number; readonly backupPath?: string },
157
+ ): { ok: true; token: string } | { ok: false; error: string } {
158
+ const path = reindexMarkerPath(dbFile);
159
+ const token = randomBytes(8).toString('hex'); // 16 hex chars
160
+ const full: ReindexMarker = { ...marker, token };
161
+ const content = `${JSON.stringify(full)}\n`;
162
+
163
+ const tryCreate = (): boolean => {
164
+ let fd: number;
165
+ try {
166
+ fd = openSync(path, 'wx'); // exclusive create — EEXIST if a marker is already there
167
+ } catch (err) {
168
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
169
+ throw err;
170
+ }
171
+ try {
172
+ writeFileSync(fd, content);
173
+ } finally {
174
+ closeSync(fd);
175
+ }
176
+ return true;
177
+ };
178
+
179
+ if (tryCreate()) return { ok: true, token };
180
+
181
+ const existing = tryReadMarker(path);
182
+ if (existing?.requiresRecovery === true) {
183
+ return { ok: false, error: `recovery required: a previous reindex of this store failed to roll back — restore ${existing.backupPath ?? 'its snapshot'} manually, then remove marker ${path}` };
184
+ }
185
+ const existingStartedAt = tryReadStartedAt(path);
186
+ const live = existingStartedAt !== undefined && Date.now() - existingStartedAt < REINDEX_MARKER_TTL_MS;
187
+ if (live) {
188
+ return { ok: false, error: `reindex already in progress (marker ${path})` };
189
+ }
190
+ // Stale, missing-field, or unparseable — treated as abandoned and replaced.
191
+ rmSync(path, { force: true });
192
+ if (!tryCreate()) {
193
+ // Practically unreachable (nothing else writes this path outside this module's own lock), but
194
+ // never silently claim success over an unexplained race.
195
+ return { ok: false, error: `reindex already in progress (marker ${path})` };
196
+ }
197
+ return { ok: true, token };
198
+ }
199
+
200
+ function tryReadMarker(path: string): Partial<ReindexMarker> | undefined {
201
+ try {
202
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
203
+ return parsed !== null && typeof parsed === 'object' ? (parsed as Partial<ReindexMarker>) : undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Lead edit after re-review (Codex D, finding 1): a marker left behind by a FAILED rollback must not
211
+ * quietly expire after the TTL — the snapshot it names may be the only good copy of the store. The
212
+ * owner (token) rewrites the marker atomically (tmp + rename) with `requiresRecovery: true`; from
213
+ * then on {@link readLiveReindexMarkers} protects its family without expiry and
214
+ * {@link writeReindexMarker} refuses every new reindex until an operator removes the marker.
215
+ * Call it under the snapshot lock, like every other marker mutation.
216
+ */
217
+ export function markReindexMarkerRecoveryRequired(
218
+ dbFile: string,
219
+ token: string,
220
+ recoveryNote: string,
221
+ ): { ok: true } | { ok: false; reason: string } {
222
+ const path = reindexMarkerPath(dbFile);
223
+ const existing = tryReadMarker(path);
224
+ if (existing === undefined) return { ok: false, reason: `marker at ${path} is missing or unreadable` };
225
+ if (existing.token !== token) return { ok: false, reason: `marker at ${path} is owned by a different token` };
226
+ const full = { ...existing, requiresRecovery: true, recoveryNote };
227
+ const tmp = `${path}.${process.pid}.tmp`;
228
+ try {
229
+ writeFileSync(tmp, `${JSON.stringify(full)}\n`);
230
+ renameSync(tmp, path);
231
+ return { ok: true };
232
+ } catch (err) {
233
+ try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
234
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
235
+ }
236
+ }
237
+
238
+ /**
239
+ * AM-1: compare-and-delete. Removes the marker ONLY when its `token` matches the one on disk — a
240
+ * caller can never tear down a marker it does not own. Absence of the marker file is treated as an
241
+ * already-cleared success (idempotent). Called from `reindexAgentdbRows`'s cleanup on every path
242
+ * that does NOT leave a failed rollback behind (AM-3) — "absence of a receipt is not success" cuts
243
+ * the other way here too: a marker left behind after its owner finished would falsely protect a
244
+ * family forever (until the TTL), so ownership-checked removal must still run unconditionally on
245
+ * every path that is safe to clear.
246
+ */
247
+ export function clearReindexMarker(
248
+ dbFile: string,
249
+ token: string,
250
+ ): { cleared: true } | { cleared: false; reason: string } {
251
+ const path = reindexMarkerPath(dbFile);
252
+ let raw: string;
253
+ try {
254
+ raw = readFileSync(path, 'utf8');
255
+ } catch (err) {
256
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { cleared: true };
257
+ return { cleared: false, reason: `cannot read marker: ${err instanceof Error ? err.message : String(err)}` };
258
+ }
259
+ let parsed: unknown;
260
+ try {
261
+ parsed = JSON.parse(raw);
262
+ } catch {
263
+ return { cleared: false, reason: 'marker is not owned by this token (unparseable marker content)' };
264
+ }
265
+ const owner = parsed !== null && typeof parsed === 'object' ? (parsed as { token?: unknown }).token : undefined;
266
+ if (owner !== token) {
267
+ return { cleared: false, reason: `marker at ${path} is owned by a different token` };
268
+ }
269
+ try {
270
+ rmSync(path, { force: true });
271
+ return { cleared: true };
272
+ } catch (err) {
273
+ return { cleared: false, reason: `removal failed: ${err instanceof Error ? err.message : String(err)}` };
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read the marker beside `dbFile` and classify it: a marker younger than
279
+ * {@link REINDEX_MARKER_TTL_MS} protects its `ms` (FR-3) — but ONLY when `ms` is an actual number
280
+ * (AM-4: a `ms: null` marker names no family, so it protects nothing, live or not). A marker at or
281
+ * past the TTL is abandoned — it is removed here (rotation must not act on stale-but-still-present
282
+ * state on its NEXT call). AM-6: the note reports "ignored and removed" ONLY once `rmSync` actually
283
+ * succeeded; a removal failure (e.g. a permission error) is reported as "ignored, removal failed:
284
+ * <err>" — never silently claimed as removed. No marker, or a marker this function cannot parse
285
+ * (corrupt / missing fields), yields `{ protectedMs: [], notes: [] }` — never a throw: a malformed
286
+ * marker must not abort rotation, and it also cannot honestly claim to protect anything.
287
+ */
288
+ export function readLiveReindexMarkers(
289
+ dbFile: string,
290
+ now: number = Date.now(),
291
+ ): { protectedMs: number[]; notes: string[] } {
292
+ const path = reindexMarkerPath(dbFile);
293
+ let raw: string;
294
+ try {
295
+ raw = readFileSync(path, 'utf8');
296
+ } catch {
297
+ return { protectedMs: [], notes: [] };
298
+ }
299
+ let parsed: unknown;
300
+ try {
301
+ parsed = JSON.parse(raw);
302
+ } catch {
303
+ return { protectedMs: [], notes: [] };
304
+ }
305
+ if (parsed === null || typeof parsed !== 'object') return { protectedMs: [], notes: [] };
306
+ const { ms, startedAt } = parsed as Partial<ReindexMarker>;
307
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
308
+ return { protectedMs: [], notes: [] };
309
+ }
310
+ const hasFamily = typeof ms === 'number' && Number.isFinite(ms);
311
+ if ((parsed as Partial<ReindexMarker>).requiresRecovery === true) {
312
+ const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
313
+ return { protectedMs: hasFamily ? [ms] : [], notes: [`recovery-required reindex marker (${label}) protects its family without expiry — restore the snapshot manually, then remove ${path}`] };
314
+ }
315
+ if (now - startedAt < REINDEX_MARKER_TTL_MS) {
316
+ return { protectedMs: hasFamily ? [ms] : [], notes: [] };
317
+ }
318
+ // AM-6: only claim "removed" once `rmSync` actually succeeded.
319
+ let removalError: string | undefined;
320
+ try {
321
+ rmSync(path, { force: true });
322
+ } catch (err) {
323
+ removalError = err instanceof Error ? err.message : String(err);
324
+ }
325
+ const label = hasFamily ? `ms=${ms}` : 'ms=(none — non-standard backupPath)';
326
+ const note = removalError === undefined
327
+ ? `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored and removed`
328
+ : `stale reindex marker (${label}, startedAt=${new Date(startedAt).toISOString()}) ignored, removal failed: ${removalError}`;
329
+ return { protectedMs: [], notes: [note] };
330
+ }