@remnic/core 9.3.679 → 9.3.680

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 (38) hide show
  1. package/dist/access-cli.js +4 -4
  2. package/dist/access-http.js +7 -7
  3. package/dist/access-mcp.js +6 -6
  4. package/dist/access-schema.js +3 -3
  5. package/dist/access-service.js +4 -4
  6. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  7. package/dist/{chunk-K2JYO6QV.js → chunk-5TEYIXMP.js} +3 -3
  8. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  9. package/dist/{chunk-ARV3AUOM.js → chunk-DL6H3D7S.js} +2 -2
  10. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  11. package/dist/{chunk-UNZLU2MX.js → chunk-DWQPM67F.js} +4 -4
  12. package/dist/{chunk-UDJLF3BO.js → chunk-JI6HWBYL.js} +2 -2
  13. package/dist/{chunk-4PPMUNV5.js → chunk-OBM7EVFU.js} +3 -3
  14. package/dist/{chunk-KQAFEZQX.js → chunk-VDX2J7OX.js} +2 -2
  15. package/dist/{chunk-PCGCQTU6.js → chunk-W67ZZDHO.js} +10 -10
  16. package/dist/cli.js +11 -11
  17. package/dist/contradiction/index.js +4 -4
  18. package/dist/index.js +15 -15
  19. package/dist/transfer/backup.js +2 -2
  20. package/dist/transfer/capsule-export.js +2 -2
  21. package/dist/transfer/capsule-import.js +2 -2
  22. package/dist/transfer/types.d.ts +6 -6
  23. package/dist/utils/serialize-mutations.d.ts +122 -0
  24. package/dist/utils/serialize-mutations.js +287 -0
  25. package/dist/utils/serialize-mutations.js.map +1 -0
  26. package/package.json +12 -2
  27. package/src/utils/serialize-mutations.test.ts +1047 -0
  28. package/src/utils/serialize-mutations.ts +679 -0
  29. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  30. /package/dist/{chunk-K2JYO6QV.js.map → chunk-5TEYIXMP.js.map} +0 -0
  31. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  32. /package/dist/{chunk-ARV3AUOM.js.map → chunk-DL6H3D7S.js.map} +0 -0
  33. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  34. /package/dist/{chunk-UNZLU2MX.js.map → chunk-DWQPM67F.js.map} +0 -0
  35. /package/dist/{chunk-UDJLF3BO.js.map → chunk-JI6HWBYL.js.map} +0 -0
  36. /package/dist/{chunk-4PPMUNV5.js.map → chunk-OBM7EVFU.js.map} +0 -0
  37. /package/dist/{chunk-KQAFEZQX.js.map → chunk-VDX2J7OX.js.map} +0 -0
  38. /package/dist/{chunk-PCGCQTU6.js.map → chunk-W67ZZDHO.js.map} +0 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Instance-scoped keyed serializer. Holds the per-key chain map so that all
3
+ * tasks queued under the same key on the SAME serializer run strictly in order.
4
+ *
5
+ * The map is instance-scoped (not module-level) so tests can construct a fresh
6
+ * serializer per case and avoid cross-test contamination, and so adopters that
7
+ * want isolation (e.g. one serializer per storage root) can have it. The free
8
+ * {@link serializeMutations} export delegates to a single shared default
9
+ * instance for callers that want process-wide serialization.
10
+ */
11
+ declare class MutationSerializer {
12
+ private readonly chains;
13
+ /**
14
+ * Run `task` strictly after every other task already queued under `key` on
15
+ * this serializer has settled.
16
+ *
17
+ * Rejection recovery (rule #40, mirroring the catalog's `queueCritical`):
18
+ * if a prior task rejects, later tasks STILL RUN, while the rejecting task's
19
+ * error is surfaced to ITS OWN caller. Concretely, the recovered tail is
20
+ * `run.then(noop, noop)` — never a bare `.then(fn)`, which would let one
21
+ * failure kill every queued task behind it.
22
+ *
23
+ * No unbounded growth: when a chain's last task settles and no newer task
24
+ * chained onto it, its entry is deleted (the storage router's
25
+ * `inFlightResolved` marker-then-clear discipline).
26
+ */
27
+ serialize<T>(key: string, task: () => Promise<T>): Promise<T>;
28
+ /**
29
+ * Test-only: the number of keys with a not-yet-cleaned chain. Used to assert
30
+ * the no-unbounded-growth invariant. Not part of the public contract.
31
+ */
32
+ pendingKeysForTest(): number;
33
+ }
34
+ /**
35
+ * Free-function entry point (issue #1524 signature). Serializes `task` against
36
+ * every other task queued under `key` across the whole process, via a shared
37
+ * default {@link MutationSerializer}. For isolated/testable serialization,
38
+ * construct a `MutationSerializer` directly.
39
+ */
40
+ declare function serializeMutations<T>(key: string, task: () => Promise<T>): Promise<T>;
41
+ /** Options for {@link withHeldFileLock}. */
42
+ interface HeldFileLockOptions {
43
+ /**
44
+ * A lock whose mtime is older than this (in ms) is treated as a crashed
45
+ * holder and broken. Required — there is no safe default, since the right
46
+ * value depends on how long the guarded critical section can legitimately
47
+ * run.
48
+ */
49
+ readonly staleMs: number;
50
+ /**
51
+ * Bounded acquisition: give up trying to acquire a busy lock after this long
52
+ * (ms) and invoke `task(false)` best-effort WITHOUT holding the lock, rather
53
+ * than blocking forever or crashing the primary op. Default 5000ms (matches
54
+ * the namespace catalog's `REBUILD_LOCK_MAX_WAIT_MS`).
55
+ */
56
+ readonly maxWaitMs?: number;
57
+ /**
58
+ * Poll interval (ms) while waiting for a busy lock to clear. Default 50ms.
59
+ */
60
+ readonly pollMs?: number;
61
+ /**
62
+ * While WE hold the lock, refresh its mtime on this cadence (ms) so a
63
+ * legitimately long task is not mistaken for a crashed holder and broken out
64
+ * from under. Default `floor(staleMs / 3)` (at least 100ms), mirroring the
65
+ * catalog heartbeat ratio. Must be comfortably below `staleMs`.
66
+ */
67
+ readonly heartbeatMs?: number;
68
+ /**
69
+ * Test seam (NG7Bg, #1506 round 28): fires AFTER a lock is judged stale and
70
+ * BEFORE the re-verify + unlink, simulating a replacement lock being created
71
+ * in the race window. No-op in production.
72
+ */
73
+ readonly onBeforeBreakStaleUnlinkForTest?: () => Promise<void> | void;
74
+ /**
75
+ * Test seam (codex P2): fires AFTER the release rename moves the lock to a
76
+ * trash path and BEFORE the ownership re-verify/restore — simulating a third
77
+ * contender acquiring the (now-empty) lockPath in the race window. No-op in
78
+ * production. Used to prove the pre-check prevents the rename entirely.
79
+ */
80
+ readonly onAfterReleaseRenameForTest?: () => Promise<void> | void;
81
+ /**
82
+ * Best-effort hook for non-fatal lock warnings (heartbeat refresh failure,
83
+ * release-time ownership check failure). Never throws into the caller. If
84
+ * omitted, warnings are swallowed (the lock is advisory; release/heartbeat
85
+ * failures must never crash the guarded op).
86
+ */
87
+ readonly onLockWarning?: (message: string, err: unknown) => void;
88
+ }
89
+ /**
90
+ * Run `task` under an exclusive on-disk lock at `lockPath`.
91
+ *
92
+ * Cross-process mutex via `open(lockPath, "wx")` (atomic exclusive create).
93
+ * While held, a heartbeat timer refreshes the lock's mtime so a legitimately
94
+ * long task is not mistaken for a crashed holder and broken out from under. A
95
+ * lock older than `opts.staleMs` is treated as stale and broken — but
96
+ * REPLACEMENT-SAFE (NG7Bg): we capture the stale lock's identity (full content
97
+ * line: `<pid> <owner-uuid> <iso>`) when judging it stale, then RE-READ and
98
+ * RE-STAT immediately before `unlink`, deleting only if byte-identical AND
99
+ * still stale. A replacement lock created in the window has a different owner
100
+ * id / timestamp, so its content differs and is left untouched.
101
+ *
102
+ * `task` receives `acquired: boolean` — `true` when we hold the lock, `false`
103
+ * when acquisition timed out (best-effort). The signature takes
104
+ * `(acquired) => Promise<T>` rather than the issue's sketched `() => Promise<T>`
105
+ * so this can be the SINGLE lock home (issue: "do NOT leave two lock
106
+ * implementations; pick one home"): the catalog's touch path needs to DROP on
107
+ * timeout, which requires knowing whether the lock was acquired. A caller that
108
+ * ignores the flag is still assignable (`() => Promise<T>` ⊆
109
+ * `(acquired: boolean) => Promise<T>` in TypeScript).
110
+ *
111
+ * Release is ownership-checked: we only `unlink` a lock whose content still
112
+ * identifies THIS acquirer (same owner id), so a replacement created after we
113
+ * stopped heartbeating is never destroyed — mirroring the catalog's
114
+ * `rebuildLockHeldBySelf`.
115
+ *
116
+ * ADOPTION NOTE: lock only the brief final read-merge-write window, never a
117
+ * long scan — a scan-length lock makes concurrent writers time out and
118
+ * silently drop work (catalog round 5, codex/cursor P2).
119
+ */
120
+ declare function withHeldFileLock<T>(lockPath: string, opts: HeldFileLockOptions, task: (acquired: boolean) => Promise<T>): Promise<T>;
121
+
122
+ export { type HeldFileLockOptions, MutationSerializer, serializeMutations, withHeldFileLock };
@@ -0,0 +1,287 @@
1
+ import "../chunk-PZ5AY32C.js";
2
+
3
+ // src/utils/serialize-mutations.ts
4
+ import { randomUUID } from "crypto";
5
+ import { link, mkdir, open, readFile, rename, stat, unlink, utimes } from "fs/promises";
6
+ import path from "path";
7
+ var MutationSerializer = class {
8
+ chains = /* @__PURE__ */ new Map();
9
+ /**
10
+ * Run `task` strictly after every other task already queued under `key` on
11
+ * this serializer has settled.
12
+ *
13
+ * Rejection recovery (rule #40, mirroring the catalog's `queueCritical`):
14
+ * if a prior task rejects, later tasks STILL RUN, while the rejecting task's
15
+ * error is surfaced to ITS OWN caller. Concretely, the recovered tail is
16
+ * `run.then(noop, noop)` — never a bare `.then(fn)`, which would let one
17
+ * failure kill every queued task behind it.
18
+ *
19
+ * No unbounded growth: when a chain's last task settles and no newer task
20
+ * chained onto it, its entry is deleted (the storage router's
21
+ * `inFlightResolved` marker-then-clear discipline).
22
+ */
23
+ serialize(key, task) {
24
+ if (typeof key !== "string" || key.length === 0) {
25
+ throw new TypeError("MutationSerializer.serialize: key must be a non-empty string");
26
+ }
27
+ if (typeof task !== "function") {
28
+ throw new TypeError("MutationSerializer.serialize: task must be a function returning a promise");
29
+ }
30
+ let entry = this.chains.get(key);
31
+ if (!entry) {
32
+ entry = { tail: Promise.resolve() };
33
+ this.chains.set(key, entry);
34
+ }
35
+ const run = entry.tail.then(task);
36
+ const recovered = run.then(settleNoop, settleNoop);
37
+ entry.tail = recovered;
38
+ void recovered.then(
39
+ () => {
40
+ if (entry && entry.tail === recovered) {
41
+ this.chains.delete(key);
42
+ }
43
+ },
44
+ () => void 0
45
+ );
46
+ return run;
47
+ }
48
+ /**
49
+ * Test-only: the number of keys with a not-yet-cleaned chain. Used to assert
50
+ * the no-unbounded-growth invariant. Not part of the public contract.
51
+ */
52
+ pendingKeysForTest() {
53
+ return this.chains.size;
54
+ }
55
+ };
56
+ function settleNoop() {
57
+ }
58
+ var defaultSerializer;
59
+ function serializeMutations(key, task) {
60
+ if (!defaultSerializer) defaultSerializer = new MutationSerializer();
61
+ return defaultSerializer.serialize(key, task);
62
+ }
63
+ var DEFAULT_MAX_WAIT_MS = 5e3;
64
+ var DEFAULT_POLL_MS = 50;
65
+ var MIN_HEARTBEAT_MS = 100;
66
+ var MAX_TIMER_DELAY_MS = 2147483647;
67
+ async function withHeldFileLock(lockPath, opts, task) {
68
+ if (typeof lockPath !== "string" || lockPath.length === 0) {
69
+ throw new TypeError("withHeldFileLock: lockPath must be a non-empty string");
70
+ }
71
+ if (typeof opts?.staleMs !== "number" || !Number.isFinite(opts.staleMs) || opts.staleMs <= 0) {
72
+ throw new TypeError(
73
+ `withHeldFileLock: opts.staleMs must be a positive finite number (valid range: > 0 ms, finite; got ${formatInvalidNumber(opts?.staleMs)}).`
74
+ );
75
+ }
76
+ const maxWaitMs = optionalPositiveMs(opts.maxWaitMs, "maxWaitMs", DEFAULT_MAX_WAIT_MS, MAX_TIMER_DELAY_MS);
77
+ const pollMs = optionalPositiveMs(opts.pollMs, "pollMs", DEFAULT_POLL_MS, MAX_TIMER_DELAY_MS);
78
+ const heartbeatMs = optionalPositiveMs(
79
+ opts.heartbeatMs,
80
+ "heartbeatMs",
81
+ Math.max(MIN_HEARTBEAT_MS, Math.floor(opts.staleMs / 3)),
82
+ MAX_TIMER_DELAY_MS
83
+ );
84
+ if (heartbeatMs >= opts.staleMs) {
85
+ throw new TypeError(
86
+ `withHeldFileLock: heartbeatMs (${heartbeatMs}) must be below staleMs (${opts.staleMs}) (valid range: > 0 and < staleMs ms) so at least one heartbeat lands per stale window.`
87
+ );
88
+ }
89
+ if (heartbeatMs > MAX_TIMER_DELAY_MS) {
90
+ throw new TypeError(
91
+ `withHeldFileLock: derived heartbeatMs (${heartbeatMs} = floor(staleMs/3)) exceeds Node's setTimeout ceiling (${MAX_TIMER_DELAY_MS} ms). Use an explicit opts.heartbeatMs at or below ${MAX_TIMER_DELAY_MS} ms.`
92
+ );
93
+ }
94
+ const rawWarn = opts.onLockWarning;
95
+ const warn = (message, err) => {
96
+ if (!rawWarn) return;
97
+ try {
98
+ rawWarn(message, err);
99
+ } catch {
100
+ }
101
+ };
102
+ const ownerId = randomUUID();
103
+ const lockDir = path.dirname(lockPath);
104
+ const held = await acquireLock(lockPath, lockDir, ownerId, opts, maxWaitMs, pollMs);
105
+ if (!held) {
106
+ return task(false);
107
+ }
108
+ const heartbeat = setInterval(() => {
109
+ lockHeldBySelf(held).then((ours) => {
110
+ if (!ours) return;
111
+ return utimes(held.path, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date());
112
+ }).catch((err) => {
113
+ warn("withHeldFileLock heartbeat refresh failed", err);
114
+ });
115
+ }, heartbeatMs);
116
+ heartbeat.unref?.();
117
+ try {
118
+ return await task(true);
119
+ } finally {
120
+ clearInterval(heartbeat);
121
+ await releaseLock(held, warn, opts.onAfterReleaseRenameForTest);
122
+ }
123
+ }
124
+ function optionalPositiveMs(value, name, fallback, maxMs) {
125
+ if (value === void 0) return fallback;
126
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
127
+ throw new TypeError(
128
+ `withHeldFileLock: opts.${name} must be a positive finite number (valid range: > 0 ms, finite; got ${formatInvalidNumber(value)}). Omit the option to use the default of ${fallback} ms.`
129
+ );
130
+ }
131
+ if (value > maxMs) {
132
+ throw new TypeError(
133
+ `withHeldFileLock: opts.${name} (${value} ms) exceeds the ${maxMs} ms ceiling (Node's setTimeout clamps larger delays to 1ms, turning a typo into tight polling). Omit the option to use the default of ${fallback} ms.`
134
+ );
135
+ }
136
+ return value;
137
+ }
138
+ function formatInvalidNumber(value) {
139
+ if (typeof value === "number") {
140
+ if (Number.isNaN(value)) return "NaN";
141
+ if (value === Infinity) return "+Infinity";
142
+ if (value === -Infinity) return "-Infinity";
143
+ return String(value);
144
+ }
145
+ return `${typeof value} ${JSON.stringify(value)}`;
146
+ }
147
+ async function acquireLock(lockPath, lockDir, ownerId, opts, maxWaitMs, pollMs) {
148
+ try {
149
+ await mkdir(lockDir, { recursive: true });
150
+ } catch {
151
+ return void 0;
152
+ }
153
+ const deadline = Date.now() + maxWaitMs;
154
+ for (; ; ) {
155
+ try {
156
+ const handle = await open(lockPath, "wx");
157
+ let wroteMeta = true;
158
+ try {
159
+ await handle.writeFile(`${process.pid} ${ownerId} ${(/* @__PURE__ */ new Date()).toISOString()}
160
+ `, "utf8");
161
+ } catch {
162
+ wroteMeta = false;
163
+ } finally {
164
+ try {
165
+ await handle.close();
166
+ } catch {
167
+ wroteMeta = false;
168
+ }
169
+ }
170
+ if (!wroteMeta) {
171
+ await unlink(lockPath).catch(() => void 0);
172
+ return void 0;
173
+ }
174
+ return { path: lockPath, ownerId };
175
+ } catch (err) {
176
+ if (err?.code !== "EEXIST") {
177
+ return void 0;
178
+ }
179
+ await breakStaleLock(lockPath, opts.staleMs, opts.onBeforeBreakStaleUnlinkForTest);
180
+ if (Date.now() >= deadline) return void 0;
181
+ await sleep(Math.min(pollMs, deadline - Date.now()));
182
+ }
183
+ }
184
+ }
185
+ async function breakStaleLock(lockPath, staleMs, onBeforeBreakStaleUnlinkForTest) {
186
+ let staleIdentity;
187
+ try {
188
+ const info = await stat(lockPath);
189
+ if (Date.now() - info.mtimeMs <= staleMs) {
190
+ return;
191
+ }
192
+ staleIdentity = await readFile(lockPath, "utf8");
193
+ } catch {
194
+ return;
195
+ }
196
+ if (onBeforeBreakStaleUnlinkForTest) {
197
+ await onBeforeBreakStaleUnlinkForTest();
198
+ }
199
+ try {
200
+ const current = await readFile(lockPath, "utf8");
201
+ if (current !== staleIdentity) return;
202
+ const recheck = await stat(lockPath);
203
+ if (Date.now() - recheck.mtimeMs <= staleMs) return;
204
+ const trashPath = `${lockPath}.breaking.${process.pid}.${Date.now()}`;
205
+ await rename(lockPath, trashPath);
206
+ try {
207
+ const moved = await readFile(trashPath, "utf8");
208
+ if (moved !== staleIdentity) {
209
+ try {
210
+ await link(trashPath, lockPath);
211
+ await unlink(trashPath).catch(() => void 0);
212
+ } catch {
213
+ }
214
+ } else {
215
+ const movedStat = await stat(trashPath);
216
+ if (Date.now() - movedStat.mtimeMs <= staleMs) {
217
+ try {
218
+ await link(trashPath, lockPath);
219
+ await unlink(trashPath).catch(() => void 0);
220
+ } catch {
221
+ }
222
+ } else {
223
+ await unlink(trashPath).catch(() => void 0);
224
+ }
225
+ }
226
+ } catch {
227
+ await unlink(trashPath).catch(() => void 0);
228
+ }
229
+ } catch {
230
+ }
231
+ }
232
+ async function releaseLock(held, warn, onAfterReleaseRenameForTest) {
233
+ try {
234
+ let precheck;
235
+ try {
236
+ precheck = await readFile(held.path, "utf8");
237
+ } catch {
238
+ return;
239
+ }
240
+ if (!precheck.includes(held.ownerId)) {
241
+ return;
242
+ }
243
+ const trashPath = `${held.path}.releasing.${process.pid}.${Date.now()}`;
244
+ await rename(held.path, trashPath);
245
+ if (onAfterReleaseRenameForTest) {
246
+ await onAfterReleaseRenameForTest();
247
+ }
248
+ try {
249
+ const moved = await readFile(trashPath, "utf8");
250
+ if (moved.includes(held.ownerId)) {
251
+ await unlink(trashPath).catch(() => void 0);
252
+ } else {
253
+ try {
254
+ await link(trashPath, held.path);
255
+ } catch {
256
+ return;
257
+ }
258
+ await unlink(trashPath).catch(() => void 0);
259
+ }
260
+ } catch {
261
+ await unlink(trashPath).catch(() => void 0);
262
+ }
263
+ } catch (err) {
264
+ warn("withHeldFileLock release failed", err);
265
+ }
266
+ }
267
+ async function lockHeldBySelf(held) {
268
+ try {
269
+ const body = await readFile(held.path, "utf8");
270
+ const parts = body.trim().split(/\s+/);
271
+ const fileOwner = parts[1];
272
+ return typeof fileOwner === "string" && fileOwner === held.ownerId;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+ function sleep(ms) {
278
+ const { promise, resolve } = Promise.withResolvers();
279
+ setTimeout(resolve, ms);
280
+ return promise;
281
+ }
282
+ export {
283
+ MutationSerializer,
284
+ serializeMutations,
285
+ withHeldFileLock
286
+ };
287
+ //# sourceMappingURL=serialize-mutations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/serialize-mutations.ts"],"sourcesContent":["// ---------------------------------------------------------------------------\n// Shared serialized-mutation utilities for TOCTOU hotspots (issue #1524).\n//\n// Two complementary primitives that the namespace catalog (`queueCritical` +\n// `withHeldCatalogLock`), the storage router's resolve-hook serialization, and\n// the summary-snapshot writer each re-implement today:\n//\n// 1. `serializeMutations(key, task)` — keyed IN-PROCESS async serialization\n// that recovers after a rejection (CLAUDE.md rule #40). One failed task\n// never poisons the tasks queued behind it; the failed task's error is\n// still surfaced to ITS caller.\n//\n// 2. `withHeldFileLock(lockPath, opts, task)` — a held CROSS-PROCESS file\n// lock with replacement-safe stale breaking (the NG7Bg invariant from\n// #1506 round 28) and ownership-checked release.\n//\n// This is the UTILITY module only. Per-issue PR split: one PR for the utility\n// + tests (this file), then one PR per adoption hotspot (catalog, router\n// provenance, summary snapshot). No adoptions live here.\n// ---------------------------------------------------------------------------\n\nimport { randomUUID } from \"node:crypto\";\nimport { link, mkdir, open, readFile, rename, stat, unlink, utimes } from \"node:fs/promises\";\nimport path from \"node:path\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 1. serializeMutations — keyed async serialization with rejection recovery\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * One entry in the per-key serialization map. `tail` is the recovered promise\n * the next queued task chains off of; it never rejects (both settle handlers\n * swallow), so a prior task's failure can never break subsequent ones.\n */\ninterface MutationChainEntry {\n tail: Promise<void>;\n}\n\n/**\n * Instance-scoped keyed serializer. Holds the per-key chain map so that all\n * tasks queued under the same key on the SAME serializer run strictly in order.\n *\n * The map is instance-scoped (not module-level) so tests can construct a fresh\n * serializer per case and avoid cross-test contamination, and so adopters that\n * want isolation (e.g. one serializer per storage root) can have it. The free\n * {@link serializeMutations} export delegates to a single shared default\n * instance for callers that want process-wide serialization.\n */\nexport class MutationSerializer {\n private readonly chains = new Map<string, MutationChainEntry>();\n\n /**\n * Run `task` strictly after every other task already queued under `key` on\n * this serializer has settled.\n *\n * Rejection recovery (rule #40, mirroring the catalog's `queueCritical`):\n * if a prior task rejects, later tasks STILL RUN, while the rejecting task's\n * error is surfaced to ITS OWN caller. Concretely, the recovered tail is\n * `run.then(noop, noop)` — never a bare `.then(fn)`, which would let one\n * failure kill every queued task behind it.\n *\n * No unbounded growth: when a chain's last task settles and no newer task\n * chained onto it, its entry is deleted (the storage router's\n * `inFlightResolved` marker-then-clear discipline).\n */\n serialize<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (typeof key !== \"string\" || key.length === 0) {\n throw new TypeError(\"MutationSerializer.serialize: key must be a non-empty string\");\n }\n if (typeof task !== \"function\") {\n throw new TypeError(\"MutationSerializer.serialize: task must be a function returning a promise\");\n }\n\n let entry = this.chains.get(key);\n if (!entry) {\n entry = { tail: Promise.resolve() };\n this.chains.set(key, entry);\n }\n\n // Chain this task off the prior tail. `tail.then(task)` runs task only once\n // the previous task has settled, preserving read-modify-write ordering.\n const run = entry.tail.then(task);\n\n // Recover the tail after a rejection so a failed task never poisons later\n // ones. Both handlers swallow; `run` still carries the original resolution\n // (or rejection) to THIS caller. This is the line a naive `.then(fn)`\n // implementation omits — see the \"naive poison chain\" prove-fail test.\n const recovered = run.then(settleNoop, settleNoop);\n entry.tail = recovered;\n\n // Self-cleaning: once our recovered tail settles, if no newer task chained\n // onto us the entry still points at `recovered` and is safe to delete. A\n // concurrent `serialize()` call enqueues synchronously and would have\n // replaced `entry.tail` BEFORE this microtask runs, so the identity check\n // is race-free (no newer task's entry can be wrongly removed).\n //\n // `recovered` cannot reject in correct operation (both handlers above\n // swallow) and the cleanup body cannot throw — but we attach a rejection\n // handler anyway so that IF the recovery invariant is ever broken, the\n // failure surfaces as a behavioral assertion (skipped tasks) rather than an\n // unhandled-rejection storm that masks which task failed. The handler is a\n // no-op: cleanup only runs on fulfillment.\n void recovered.then(\n () => {\n if (entry && entry.tail === recovered) {\n this.chains.delete(key);\n }\n },\n () => undefined,\n );\n\n return run;\n }\n\n /**\n * Test-only: the number of keys with a not-yet-cleaned chain. Used to assert\n * the no-unbounded-growth invariant. Not part of the public contract.\n */\n pendingKeysForTest(): number {\n return this.chains.size;\n }\n}\n\n/**\n * Recovery handler shared by both settle arms. Named (not inline\n * `() => undefined`) so the chain assignment stays self-documenting in stack\n * traces and the review-patterns poison-chain check can see the chain is\n * recovered, not bare `.then(fn)`.\n */\nfunction settleNoop(): void {\n /* swallow — the original resolution/rejection is carried by `run` */\n}\n\n/**\n * Process-wide default serializer backing the free {@link serializeMutations}\n * export. Lazy so it is only created when first used (tests that construct\n * their own `MutationSerializer` pay nothing).\n */\nlet defaultSerializer: MutationSerializer | undefined;\n\n/**\n * Free-function entry point (issue #1524 signature). Serializes `task` against\n * every other task queued under `key` across the whole process, via a shared\n * default {@link MutationSerializer}. For isolated/testable serialization,\n * construct a `MutationSerializer` directly.\n */\nexport function serializeMutations<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (!defaultSerializer) defaultSerializer = new MutationSerializer();\n return defaultSerializer.serialize(key, task);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 2. withHeldFileLock — cross-process held file lock with stale breaking\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Options for {@link withHeldFileLock}. */\nexport interface HeldFileLockOptions {\n /**\n * A lock whose mtime is older than this (in ms) is treated as a crashed\n * holder and broken. Required — there is no safe default, since the right\n * value depends on how long the guarded critical section can legitimately\n * run.\n */\n readonly staleMs: number;\n /**\n * Bounded acquisition: give up trying to acquire a busy lock after this long\n * (ms) and invoke `task(false)` best-effort WITHOUT holding the lock, rather\n * than blocking forever or crashing the primary op. Default 5000ms (matches\n * the namespace catalog's `REBUILD_LOCK_MAX_WAIT_MS`).\n */\n readonly maxWaitMs?: number;\n /**\n * Poll interval (ms) while waiting for a busy lock to clear. Default 50ms.\n */\n readonly pollMs?: number;\n /**\n * While WE hold the lock, refresh its mtime on this cadence (ms) so a\n * legitimately long task is not mistaken for a crashed holder and broken out\n * from under. Default `floor(staleMs / 3)` (at least 100ms), mirroring the\n * catalog heartbeat ratio. Must be comfortably below `staleMs`.\n */\n readonly heartbeatMs?: number;\n /**\n * Test seam (NG7Bg, #1506 round 28): fires AFTER a lock is judged stale and\n * BEFORE the re-verify + unlink, simulating a replacement lock being created\n * in the race window. No-op in production.\n */\n readonly onBeforeBreakStaleUnlinkForTest?: () => Promise<void> | void;\n /**\n * Test seam (codex P2): fires AFTER the release rename moves the lock to a\n * trash path and BEFORE the ownership re-verify/restore — simulating a third\n * contender acquiring the (now-empty) lockPath in the race window. No-op in\n * production. Used to prove the pre-check prevents the rename entirely.\n */\n readonly onAfterReleaseRenameForTest?: () => Promise<void> | void;\n /**\n * Best-effort hook for non-fatal lock warnings (heartbeat refresh failure,\n * release-time ownership check failure). Never throws into the caller. If\n * omitted, warnings are swallowed (the lock is advisory; release/heartbeat\n * failures must never crash the guarded op).\n */\n readonly onLockWarning?: (message: string, err: unknown) => void;\n}\n\n/** Default bounded acquisition wait, mirroring the catalog. */\nconst DEFAULT_MAX_WAIT_MS = 5_000;\n/** Default busy-lock poll interval, mirroring the catalog. */\nconst DEFAULT_POLL_MS = 50;\n/** Floor for the derived heartbeat cadence. */\nconst MIN_HEARTBEAT_MS = 100;\n/** Node's setTimeout/setInterval 32-bit signed-int ceiling (2^31 − 1 ms ≈ 24.8\n * days). Delays above this are silently clamped to 1ms by the Node timer, so\n * timer-backed options (pollMs, heartbeatMs) must be rejected at this boundary\n * (chatgpt-codex-connector P2). */\nconst MAX_TIMER_DELAY_MS = 2_147_483_647;\n\n/** Internal handle for a lock we successfully acquired. */\ninterface HeldLock {\n readonly path: string;\n readonly ownerId: string;\n}\n\n/**\n * Run `task` under an exclusive on-disk lock at `lockPath`.\n *\n * Cross-process mutex via `open(lockPath, \"wx\")` (atomic exclusive create).\n * While held, a heartbeat timer refreshes the lock's mtime so a legitimately\n * long task is not mistaken for a crashed holder and broken out from under. A\n * lock older than `opts.staleMs` is treated as stale and broken — but\n * REPLACEMENT-SAFE (NG7Bg): we capture the stale lock's identity (full content\n * line: `<pid> <owner-uuid> <iso>`) when judging it stale, then RE-READ and\n * RE-STAT immediately before `unlink`, deleting only if byte-identical AND\n * still stale. A replacement lock created in the window has a different owner\n * id / timestamp, so its content differs and is left untouched.\n *\n * `task` receives `acquired: boolean` — `true` when we hold the lock, `false`\n * when acquisition timed out (best-effort). The signature takes\n * `(acquired) => Promise<T>` rather than the issue's sketched `() => Promise<T>`\n * so this can be the SINGLE lock home (issue: \"do NOT leave two lock\n * implementations; pick one home\"): the catalog's touch path needs to DROP on\n * timeout, which requires knowing whether the lock was acquired. A caller that\n * ignores the flag is still assignable (`() => Promise<T>` ⊆\n * `(acquired: boolean) => Promise<T>` in TypeScript).\n *\n * Release is ownership-checked: we only `unlink` a lock whose content still\n * identifies THIS acquirer (same owner id), so a replacement created after we\n * stopped heartbeating is never destroyed — mirroring the catalog's\n * `rebuildLockHeldBySelf`.\n *\n * ADOPTION NOTE: lock only the brief final read-merge-write window, never a\n * long scan — a scan-length lock makes concurrent writers time out and\n * silently drop work (catalog round 5, codex/cursor P2).\n */\nexport async function withHeldFileLock<T>(\n lockPath: string,\n opts: HeldFileLockOptions,\n task: (acquired: boolean) => Promise<T>,\n): Promise<T> {\n if (typeof lockPath !== \"string\" || lockPath.length === 0) {\n throw new TypeError(\"withHeldFileLock: lockPath must be a non-empty string\");\n }\n if (typeof opts?.staleMs !== \"number\" || !Number.isFinite(opts.staleMs) || opts.staleMs <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.staleMs must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(opts?.staleMs)}).`,\n );\n }\n\n // Validate optional timings: a NaN/Infinity here is a real hazard (e.g.\n // `Date.now() + NaN` === NaN, so `Date.now() >= deadline` is always false and\n // the bounded acquire loop would wait forever instead of falling back to\n // best-effort). Reject invalid input rather than silently defaulting it\n // (codex P2 review). Omitting an option still picks its default.\n const maxWaitMs = optionalPositiveMs(opts.maxWaitMs, \"maxWaitMs\", DEFAULT_MAX_WAIT_MS, MAX_TIMER_DELAY_MS);\n const pollMs = optionalPositiveMs(opts.pollMs, \"pollMs\", DEFAULT_POLL_MS, MAX_TIMER_DELAY_MS);\n const heartbeatMs = optionalPositiveMs(\n opts.heartbeatMs,\n \"heartbeatMs\",\n Math.max(MIN_HEARTBEAT_MS, Math.floor(opts.staleMs / 3)),\n MAX_TIMER_DELAY_MS,\n );\n if (heartbeatMs >= opts.staleMs) {\n throw new TypeError(\n `withHeldFileLock: heartbeatMs (${heartbeatMs}) must be below staleMs (${opts.staleMs}) ` +\n `(valid range: > 0 and < staleMs ms) so at least one heartbeat lands per stale window.`,\n );\n }\n if (heartbeatMs > MAX_TIMER_DELAY_MS) {\n throw new TypeError(\n `withHeldFileLock: derived heartbeatMs (${heartbeatMs} = floor(staleMs/3)) exceeds ` +\n `Node's setTimeout ceiling (${MAX_TIMER_DELAY_MS} ms). Use an explicit opts.heartbeatMs ` +\n `at or below ${MAX_TIMER_DELAY_MS} ms.`,\n );\n }\n // Wrap the consumer's warning hook so a throwing callback never turns a\n // non-fatal advisory lock warning into an unhandled rejection (heartbeat\n // catch handler) or overrides the task's result (release path). The option\n // is documented as never throwing into the caller; enforce that here\n // (codex P2 review).\n const rawWarn = opts.onLockWarning;\n const warn = (message: string, err: unknown): void => {\n if (!rawWarn) return;\n try {\n rawWarn(message, err);\n } catch {\n /* swallow — a throwing advisory hook must not crash the guarded op */\n }\n };\n\n // Per-call owner identity. Two withHeldFileLock calls in the SAME process\n // get different ids, so neither mistakes the other's lock for its own\n // (stronger than the catalog's per-instance id, which is what we want for a\n // stateless utility).\n const ownerId = randomUUID();\n const lockDir = path.dirname(lockPath);\n\n const held = await acquireLock(lockPath, lockDir, ownerId, opts, maxWaitMs, pollMs);\n if (!held) {\n // Best-effort: run the task WITHOUT the lock. The caller decides what to\n // do (the catalog touch path will drop its append); we never crash the\n // primary op on contention.\n return task(false);\n }\n\n // Heartbeat: while WE hold the lock, refresh its mtime so age-based stale\n // detection sees an active holder and does not break us out from under\n // (catalog round 5). Failures are swallowed (advisory lock); the timer is\n // always cleared in the finally.\n //\n // OWNERSHIP CHECK (codex P2): if our event loop was paused long enough that\n // another process judged us stale, broke our lock, and created a replacement,\n // we must NOT refresh the replacement's mtime — that would keep a (possibly\n // crashed) replacement looking fresh. Verify lockHeldBySelf before each\n // utimes; if ownership is lost, stop heartbeating (our lock is gone).\n const heartbeat = setInterval(() => {\n lockHeldBySelf(held)\n .then((ours) => {\n if (!ours) return; // broken/replaced — stop refreshing\n return utimes(held.path, new Date(), new Date());\n })\n .catch((err: unknown) => {\n warn(\"withHeldFileLock heartbeat refresh failed\", err);\n });\n }, heartbeatMs);\n // Don't keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n try {\n return await task(true);\n } finally {\n clearInterval(heartbeat);\n await releaseLock(held, warn, opts.onAfterReleaseRenameForTest);\n }\n}\n\n/**\n * Resolve an optional millisecond timing option, REJECTING invalid values\n * (NaN, Infinity, non-positive, or above `maxMs`) rather than silently defaulting\n * them. A NaN or Infinity maxWaitMs would make the bounded acquire loop wait\n * forever (`Date.now() + NaN` is NaN); a non-positive poll/heartbeat makes no\n * sense. Timer-backed options (pollMs, heartbeatMs) are bounded to Node's\n * setTimeout ceiling (`MAX_TIMER_DELAY_MS`): a value above 2^31−1 is silently\n * clamped to 1ms by the timer, turning a typo into tight polling (codex P2).\n * Omitting the option (`undefined`) picks `fallback`. Non-number types are also\n * rejected (defensive against config/env coercion).\n */\nfunction optionalPositiveMs(\n value: number | undefined,\n name: \"maxWaitMs\" | \"pollMs\" | \"heartbeatMs\",\n fallback: number,\n maxMs: number,\n): number {\n if (value === undefined) return fallback;\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(value)}). ` +\n `Omit the option to use the default of ${fallback} ms.`,\n );\n }\n if (value > maxMs) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} (${value} ms) exceeds the ${maxMs} ms ` +\n `ceiling (Node's setTimeout clamps larger delays to 1ms, turning a ` +\n `typo into tight polling). Omit the option to use the default of ${fallback} ms.`,\n );\n }\n return value;\n}\n\n/**\n * Human-readable label for a rejected numeric input. Makes the error message\n * immediately actionable for NaN/Infinity (which print as \"NaN\"/\"Infinity\" via\n * String() but are easier to triage with an explicit sign), and surfaces the\n * actual type for non-number values (defensive against config/env coercion).\n */\nfunction formatInvalidNumber(value: unknown): string {\n if (typeof value === \"number\") {\n if (Number.isNaN(value)) return \"NaN\";\n if (value === Infinity) return \"+Infinity\";\n if (value === -Infinity) return \"-Infinity\";\n return String(value);\n }\n return `${typeof value} ${JSON.stringify(value)}`;\n}\n\n/**\n * Atomically create the lock file, looping until acquired/stale-broken/timeout.\n * Returns the held-lock handle on success, or `undefined` on bounded-timeout.\n * Unexpected FS errors proceed best-effort (return undefined) rather than\n * crashing the guarded op, matching the catalog.\n */\nasync function acquireLock(\n lockPath: string,\n lockDir: string,\n ownerId: string,\n opts: HeldFileLockOptions,\n maxWaitMs: number,\n pollMs: number,\n): Promise<HeldLock | undefined> {\n try {\n await mkdir(lockDir, { recursive: true });\n } catch {\n // Lock-directory setup failure (e.g. an intermediate path is a file, or\n // permissions deny mkdir) must NOT crash the guarded op — the advisory\n // lock contract is best-effort. Return undefined so task(false) runs\n // instead of rejecting (codex P2 review).\n return undefined;\n }\n const deadline = Date.now() + maxWaitMs;\n for (;;) {\n try {\n const handle = await open(lockPath, \"wx\");\n let wroteMeta = true;\n try {\n await handle.writeFile(`${process.pid} ${ownerId} ${new Date().toISOString()}\\n`, \"utf8\");\n } catch {\n // The metadata write failed; the lock file may be empty or partial.\n // Our ownership check on release would NOT find this ownerId, leaving\n // a malformed lock that lingers until stale and blocks other callers\n // out of the mutex (codex P2). Undo our exclusive create and report\n // acquisition failure so the caller runs best-effort instead.\n wroteMeta = false;\n } finally {\n try {\n await handle.close();\n } catch {\n // close() can report a deferred I/O error (e.g. write that appeared\n // to succeed but failed on flush). The lock file may be malformed —\n // treat it as a metadata-write failure so the cleanup path unlinks\n // the orphaned lock (codex P2 review).\n wroteMeta = false;\n }\n }\n if (!wroteMeta) {\n await unlink(lockPath).catch(() => undefined);\n return undefined;\n }\n return { path: lockPath, ownerId };\n } catch (err) {\n if ((err as NodeJS.ErrnoException | undefined)?.code !== \"EEXIST\") {\n // Unexpected FS error — proceed best-effort without the lock.\n return undefined;\n }\n // Lock exists: break it if stale, then poll. breakStaleLock is\n // replacement-safe (NG7Bg) and never throws.\n await breakStaleLock(lockPath, opts.staleMs, opts.onBeforeBreakStaleUnlinkForTest);\n if (Date.now() >= deadline) return undefined;\n // Cap the sleep to the remaining budget so a large pollMs cannot block\n // acquisition far past maxWaitMs (e.g. maxWaitMs=1000, pollMs=60000\n // would otherwise block ~60s instead of 1s — codex P2).\n await sleep(Math.min(pollMs, deadline - Date.now()));\n }\n }\n}\n\n/**\n * Replacement-safe stale-lock breaking (NG7Bg, #1506 round 28). Capture the\n * lock's identity when judging it stale, then ATOMICALLY rename it to a unique\n * trash path and verify the moved content matches. A replacement lock created\n * in the race window is either left untouched (different identity at\n * lockPath, so the rename moves the stale lock — not the replacement) or\n * restored (if the rename accidentally moves a replacement, the verify\n * detects the mismatch and renames it back).\n *\n * ATOMICITY (codex P2): `rename` is atomic on POSIX — only ONE contender can\n * successfully rename a given file. This eliminates the TOCTOU between the\n * identity/stat checks and the deletion that a bare `unlink` leaves open:\n * without rename, contender A could verify identity X, pause, then unlink\n * contender B's freshly acquired replacement Y. With rename, A moves whatever\n * is at lockPath, then checks: if it is X, A broke the stale lock; if it is\n * not X (a replacement appeared between A's last check and the rename), A\n * restores it.\n */\nasync function breakStaleLock(\n lockPath: string,\n staleMs: number,\n onBeforeBreakStaleUnlinkForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n let staleIdentity: string;\n try {\n const info = await stat(lockPath);\n if (Date.now() - info.mtimeMs <= staleMs) {\n // Not stale (a live holder's heartbeat keeps it fresh) — leave it.\n return;\n }\n staleIdentity = await readFile(lockPath, \"utf8\");\n } catch {\n // Lock vanished (released by holder) or stat/read failed — nothing to do.\n return;\n }\n // Test seam: simulate a replacement lock being created in the race window\n // between the staleness judgment and the atomic break. No-op in production.\n if (onBeforeBreakStaleUnlinkForTest) {\n await onBeforeBreakStaleUnlinkForTest();\n }\n try {\n // Re-validate immediately before breaking: the lock must still carry the\n // SAME identity AND still be stale.\n const current = await readFile(lockPath, \"utf8\");\n if (current !== staleIdentity) return; // replaced — leave the fresh lock\n const recheck = await stat(lockPath);\n if (Date.now() - recheck.mtimeMs <= staleMs) return; // heartbeat refreshed it\n\n // ATOMIC BREAK: rename is atomic on POSIX. Only one contender succeeds;\n // others get ENOENT (the file is already gone). After the rename, verify\n // the moved content: if it matches staleIdentity we broke the right lock;\n // if it does not, a replacement appeared in the window and we restore it.\n const trashPath = `${lockPath}.breaking.${process.pid}.${Date.now()}`;\n await rename(lockPath, trashPath);\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved !== staleIdentity) {\n // We accidentally moved a replacement lock (created between our last\n // check and the rename). Restore it so the replacement holder's lock\n // survives. Use link (not rename) to AVOID overwriting a fresh lock\n // that a third contender may have acquired at lockPath while the file\n // was in trash: link fails with EEXIST if lockPath exists, leaving\n // the third contender's lock intact (codex P2 review).\n try {\n await link(trashPath, lockPath);\n // link succeeded — remove the redundant trash hard link. The lock\n // now lives only at lockPath.\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists (a third contender acquired it). Do NOT\n // unlink the moved file — it may be a LIVE lock whose holder is\n // still in its critical section. Destroying it would leave the\n // holder running with no visible lock, breaking mutual exclusion\n // (codex P2). Leave it in trash as a breadcrumb; it is not at\n // lockPath so it does not block other contenders.\n }\n } else {\n // Content matches — but verify the moved file is STILL stale. The\n // original holder may have resumed and heartbeated between our\n // pre-rename stat() and the rename, refreshing the mtime. If so, the\n // holder is live: restore the lock instead of deleting it (codex P2).\n const movedStat = await stat(trashPath);\n if (Date.now() - movedStat.mtimeMs <= staleMs) {\n // Mtime was refreshed — the holder resumed. Restore the lock.\n try {\n await link(trashPath, lockPath);\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists — another contender acquired it. Do NOT\n // unlink the moved file (it may be a live lock). Leave it in trash.\n }\n } else {\n // Still stale — we broke the right lock. Clean up the trash.\n await unlink(trashPath).catch(() => undefined);\n }\n }\n } catch {\n // Could not read the trash file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // The lock changed/vanished between checks — another process handled it.\n }\n}\n\n/**\n * Release the lock ONLY if its content still identifies THIS acquirer (same\n * owner id). Two-stage ownership check:\n *\n * 1. PRE-CHECK (chatgpt-codex-connector P2): read lockPath BEFORE renaming.\n * If the lock is already a replacement (a contender broke our stale lock),\n * return WITHOUT renaming — renaming a replacement out of lockPath leaves\n * it empty, letting a third contender acquire while the replacement holder\n * is still active. The replacement is safe at lockPath; leave it alone.\n *\n * 2. ATOMIC CLAIM: if the pre-check saw our ownerId, rename lockPath→trash\n * (POSIX-atomic) and re-verify on the moved file. A replacement could\n * appear between the pre-check and the rename; if the moved file is no\n * longer ours, restore it via link (non-overwriting). This ties the\n * ownership check to the deletion so a bare readFile-then-unlink TOCTOU\n * cannot delete a fresh replacement (codex P2).\n */\nasync function releaseLock(\n held: HeldLock,\n warn: (message: string, err: unknown) => void,\n onAfterReleaseRenameForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n try {\n // PRE-CHECK (chatgpt-codex-connector P2): read lockPath before renaming. If\n // the lock is no longer ours, a contender broke our stale lock and created a\n // replacement. Return WITHOUT renaming — renaming the replacement out of\n // lockPath leaves it empty, so a third contender could acquire while the\n // replacement holder is still active. The replacement is safe at lockPath.\n let precheck: string;\n try {\n precheck = await readFile(held.path, \"utf8\");\n } catch {\n return; // lock vanished — nothing to release.\n }\n if (!precheck.includes(held.ownerId)) {\n return; // replacement lock — leave it untouched for its holder.\n }\n // It was ours when we read it. Atomically claim via rename, then re-verify\n // on the moved file: a replacement could appear between the pre-check read\n // above and this rename.\n const trashPath = `${held.path}.releasing.${process.pid}.${Date.now()}`;\n await rename(held.path, trashPath);\n // Test seam: simulate a third contender acquiring the now-empty lockPath\n // in the rename-to-restore window. No-op in production.\n if (onAfterReleaseRenameForTest) {\n await onAfterReleaseRenameForTest();\n }\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved.includes(held.ownerId)) {\n // Still our lock — safe to delete.\n await unlink(trashPath).catch(() => undefined);\n } else {\n // Not ours: a replacement appeared between the pre-check and the rename.\n // Restore it via link (non-overwriting — if lockPath already has a newer\n // lock, leave it).\n try {\n await link(trashPath, held.path);\n } catch {\n // lockPath already exists — a newer holder is active. Leave the\n // moved file in trash rather than destroying a live lock (codex P2).\n return;\n }\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // Could not read the moved file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch (err) {\n // Best-effort release; a stale lock will be broken on the next acquire.\n warn(\"withHeldFileLock release failed\", err);\n }\n}\n\n/**\n * Whether the lock file at `held.path` was written by THIS acquirer (same owner\n * id). Reads the content and matches the `<pid> <owner-uuid>` prefix; the iso\n * timestamp varies so it is not part of the identity check.\n */\nasync function lockHeldBySelf(held: HeldLock): Promise<boolean> {\n try {\n const body = await readFile(held.path, \"utf8\");\n const parts = body.trim().split(/\\s+/);\n const fileOwner = parts[1];\n return typeof fileOwner === \"string\" && fileOwner === held.ownerId;\n } catch {\n return false;\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n const { promise, resolve } = Promise.withResolvers<void>();\n // NOT unref'd: this polls inside an awaited acquire loop, so the caller's\n // await chain keeps the loop alive; unref would let Node exit mid-poll when\n // nothing else is pending (the heartbeat interval IS unref'd separately).\n setTimeout(resolve, ms);\n return promise;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.3.679",
3
+ "version": "9.3.680",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2786,6 +2786,16 @@
2786
2786
  "remnic-source": "./src/utility-telemetry.ts",
2787
2787
  "import": "./dist/utility-telemetry.js"
2788
2788
  },
2789
+ "./utils/serialize-mutations": {
2790
+ "types": "./dist/utils/serialize-mutations.d.ts",
2791
+ "remnic-source": "./src/utils/serialize-mutations.ts",
2792
+ "import": "./dist/utils/serialize-mutations.js"
2793
+ },
2794
+ "./utils/serialize-mutations.js": {
2795
+ "types": "./dist/utils/serialize-mutations.d.ts",
2796
+ "remnic-source": "./src/utils/serialize-mutations.ts",
2797
+ "import": "./dist/utils/serialize-mutations.js"
2798
+ },
2789
2799
  "./verified-recall": {
2790
2800
  "types": "./dist/verified-recall.d.ts",
2791
2801
  "remnic-source": "./src/verified-recall.ts",
@@ -2921,7 +2931,7 @@
2921
2931
  "core"
2922
2932
  ],
2923
2933
  "peerDependencies": {
2924
- "@remnic/coding-graph": "^9.3.679"
2934
+ "@remnic/coding-graph": "^9.3.680"
2925
2935
  },
2926
2936
  "peerDependenciesMeta": {
2927
2937
  "@remnic/coding-graph": {