@remnic/core 9.3.700 → 9.3.701

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 (119) hide show
  1. package/dist/access-boundary.d.ts +3 -3
  2. package/dist/access-boundary.js +7 -7
  3. package/dist/access-cli.js +21 -21
  4. package/dist/access-http.d.ts +3 -3
  5. package/dist/access-http.js +10 -10
  6. package/dist/access-mcp.d.ts +3 -3
  7. package/dist/access-mcp.js +9 -9
  8. package/dist/access-operations.d.ts +3 -3
  9. package/dist/access-operations.js +8 -8
  10. package/dist/{access-service-Cte3ol0W.d.ts → access-service-CGVWK6lZ.d.ts} +1 -1
  11. package/dist/access-service.d.ts +3 -3
  12. package/dist/access-service.js +6 -6
  13. package/dist/access-surface-catalog.d.ts +3 -3
  14. package/dist/bootstrap.d.ts +2 -2
  15. package/dist/briefing.js +4 -4
  16. package/dist/{catalog-DN1PzThs.d.ts → catalog-DBIghceA.d.ts} +26 -52
  17. package/dist/causal-consolidation.js +5 -5
  18. package/dist/{chunk-GYVVQYA3.js → chunk-27LQPUMZ.js} +3 -3
  19. package/dist/{chunk-XJNBEDFE.js → chunk-3FAMU5TX.js} +31 -74
  20. package/dist/chunk-3FAMU5TX.js.map +1 -0
  21. package/dist/{chunk-PCZR32VL.js → chunk-3JJWNZTT.js} +2 -2
  22. package/dist/{chunk-GA5A6MJH.js → chunk-4HIAWLA2.js} +14 -14
  23. package/dist/{chunk-SEWF2O74.js → chunk-6W2D6FGG.js} +2 -2
  24. package/dist/{chunk-CHM274U6.js → chunk-7TAQEPLE.js} +2 -2
  25. package/dist/{chunk-G5PKTQ5J.js → chunk-DEDQXIDL.js} +2 -2
  26. package/dist/{chunk-3E5WRQNQ.js → chunk-FUCJAZ25.js} +4 -4
  27. package/dist/{chunk-6JDGADXK.js → chunk-HF4N43Q7.js} +2 -2
  28. package/dist/{chunk-NHBEO3F3.js → chunk-HXHKLVAS.js} +14 -14
  29. package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
  30. package/dist/{chunk-SMIVW7XC.js → chunk-ISLJ5WIM.js} +2 -2
  31. package/dist/{chunk-HDLC75NX.js → chunk-IYOPIG3E.js} +2 -2
  32. package/dist/{chunk-DR2JTSLZ.js → chunk-JKOKX3PS.js} +62 -163
  33. package/dist/chunk-JKOKX3PS.js.map +1 -0
  34. package/dist/{chunk-2NWHLAXX.js → chunk-JO3E5VGS.js} +2 -2
  35. package/dist/{chunk-CCOXIDRM.js → chunk-KF4TXW7Z.js} +4 -4
  36. package/dist/{chunk-JX3YZVII.js → chunk-KS7WQ4BZ.js} +3 -3
  37. package/dist/{chunk-YPR7DOPD.js → chunk-LTJAMRGI.js} +4 -4
  38. package/dist/{chunk-YPR7DOPD.js.map → chunk-LTJAMRGI.js.map} +1 -1
  39. package/dist/{chunk-ZDK2IW5F.js → chunk-MNU5G4TK.js} +2 -2
  40. package/dist/{chunk-PQG4T5V3.js → chunk-ODTWHSY2.js} +49 -45
  41. package/dist/chunk-ODTWHSY2.js.map +1 -0
  42. package/dist/{chunk-YMTGXDN6.js → chunk-OLOYQZFB.js} +4 -4
  43. package/dist/{chunk-JKW5XSWC.js → chunk-QP37KL5H.js} +2 -2
  44. package/dist/{chunk-X74FJSW7.js → chunk-SFMRLXIV.js} +2 -2
  45. package/dist/{chunk-RJ2THZ4H.js → chunk-TFVVONWD.js} +2 -2
  46. package/dist/{chunk-XY4WJTEX.js → chunk-WFEZUGU5.js} +2 -2
  47. package/dist/{chunk-O54DY26V.js → chunk-XTIRCSIH.js} +2 -2
  48. package/dist/{chunk-EOBJRBLC.js → chunk-YO4MBK3I.js} +2 -2
  49. package/dist/{chunk-YXIFA36P.js → chunk-ZT7B64BE.js} +2 -2
  50. package/dist/{chunk-33L6XHU2.js → chunk-ZYNMX6IU.js} +5 -5
  51. package/dist/{cli--yVN9yEV.d.ts → cli-D3XeenwN.d.ts} +2 -2
  52. package/dist/cli.d.ts +4 -4
  53. package/dist/cli.js +21 -21
  54. package/dist/compounding/engine.js +4 -4
  55. package/dist/connectors/codex-materialize-runner.js +4 -4
  56. package/dist/connectors/index.js +4 -4
  57. package/dist/entity-retrieval.js +4 -4
  58. package/dist/explicit-capture.d.ts +2 -2
  59. package/dist/{forget-BEXG5PQC.js → forget-6SOIPUMQ.js} +3 -3
  60. package/dist/index.d.ts +5 -5
  61. package/dist/index.js +29 -29
  62. package/dist/maintenance/memory-governance.js +4 -4
  63. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  64. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  65. package/dist/mcp-memory-inspector-app.d.ts +3 -3
  66. package/dist/namespaces/migrate.d.ts +1 -1
  67. package/dist/namespaces/migrate.js +5 -5
  68. package/dist/namespaces/storage.d.ts +13 -2
  69. package/dist/namespaces/storage.js +4 -4
  70. package/dist/operator-toolkit.js +9 -9
  71. package/dist/orchestration/maintenance.d.ts +1 -1
  72. package/dist/orchestration/maintenance.js +6 -6
  73. package/dist/{orchestrator-CJI4xdqV.d.ts → orchestrator-BzMCZlKn.d.ts} +1 -1
  74. package/dist/orchestrator.d.ts +2 -2
  75. package/dist/orchestrator.js +17 -17
  76. package/dist/semantic-consolidation.js +5 -5
  77. package/dist/semantic-rule-promotion.js +4 -4
  78. package/dist/semantic-rule-verifier.js +4 -4
  79. package/dist/storage.js +3 -3
  80. package/dist/summarizer.js +3 -2
  81. package/dist/summary-snapshot.js +2 -1
  82. package/dist/utils/serialize-mutations.js +1 -1
  83. package/dist/verified-recall.js +4 -4
  84. package/package.json +2 -2
  85. package/src/namespaces/catalog.test.ts +222 -14
  86. package/src/namespaces/catalog.ts +54 -187
  87. package/src/namespaces/storage.ts +87 -80
  88. package/src/summary-snapshot.test.ts +63 -1
  89. package/src/summary-snapshot.ts +61 -80
  90. package/src/utils/serialize-mutations.ts +10 -6
  91. package/dist/chunk-DR2JTSLZ.js.map +0 -1
  92. package/dist/chunk-PQG4T5V3.js.map +0 -1
  93. package/dist/chunk-XJNBEDFE.js.map +0 -1
  94. /package/dist/{chunk-GYVVQYA3.js.map → chunk-27LQPUMZ.js.map} +0 -0
  95. /package/dist/{chunk-PCZR32VL.js.map → chunk-3JJWNZTT.js.map} +0 -0
  96. /package/dist/{chunk-GA5A6MJH.js.map → chunk-4HIAWLA2.js.map} +0 -0
  97. /package/dist/{chunk-SEWF2O74.js.map → chunk-6W2D6FGG.js.map} +0 -0
  98. /package/dist/{chunk-CHM274U6.js.map → chunk-7TAQEPLE.js.map} +0 -0
  99. /package/dist/{chunk-G5PKTQ5J.js.map → chunk-DEDQXIDL.js.map} +0 -0
  100. /package/dist/{chunk-3E5WRQNQ.js.map → chunk-FUCJAZ25.js.map} +0 -0
  101. /package/dist/{chunk-6JDGADXK.js.map → chunk-HF4N43Q7.js.map} +0 -0
  102. /package/dist/{chunk-NHBEO3F3.js.map → chunk-HXHKLVAS.js.map} +0 -0
  103. /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
  104. /package/dist/{chunk-SMIVW7XC.js.map → chunk-ISLJ5WIM.js.map} +0 -0
  105. /package/dist/{chunk-HDLC75NX.js.map → chunk-IYOPIG3E.js.map} +0 -0
  106. /package/dist/{chunk-2NWHLAXX.js.map → chunk-JO3E5VGS.js.map} +0 -0
  107. /package/dist/{chunk-CCOXIDRM.js.map → chunk-KF4TXW7Z.js.map} +0 -0
  108. /package/dist/{chunk-JX3YZVII.js.map → chunk-KS7WQ4BZ.js.map} +0 -0
  109. /package/dist/{chunk-ZDK2IW5F.js.map → chunk-MNU5G4TK.js.map} +0 -0
  110. /package/dist/{chunk-YMTGXDN6.js.map → chunk-OLOYQZFB.js.map} +0 -0
  111. /package/dist/{chunk-JKW5XSWC.js.map → chunk-QP37KL5H.js.map} +0 -0
  112. /package/dist/{chunk-X74FJSW7.js.map → chunk-SFMRLXIV.js.map} +0 -0
  113. /package/dist/{chunk-RJ2THZ4H.js.map → chunk-TFVVONWD.js.map} +0 -0
  114. /package/dist/{chunk-XY4WJTEX.js.map → chunk-WFEZUGU5.js.map} +0 -0
  115. /package/dist/{chunk-O54DY26V.js.map → chunk-XTIRCSIH.js.map} +0 -0
  116. /package/dist/{chunk-EOBJRBLC.js.map → chunk-YO4MBK3I.js.map} +0 -0
  117. /package/dist/{chunk-YXIFA36P.js.map → chunk-ZT7B64BE.js.map} +0 -0
  118. /package/dist/{chunk-33L6XHU2.js.map → chunk-ZYNMX6IU.js.map} +0 -0
  119. /package/dist/{forget-BEXG5PQC.js.map → forget-6SOIPUMQ.js.map} +0 -0
@@ -6,6 +6,7 @@ import type { PluginConfig } from "../types.js";
6
6
  import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
7
7
  import { namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
8
8
  import type { NamespaceCatalog } from "./catalog.js";
9
+ import { MutationSerializer } from "../utils/serialize-mutations.js";
9
10
 
10
11
  async function exists(p: string): Promise<boolean> {
11
12
  try {
@@ -209,21 +210,22 @@ export class NamespaceStorageRouter {
209
210
  // rebuilds. We fire the hook only when the (namespace, storageDir) pair is new
210
211
  // or its dir changed, so a steady-state cache hit is a no-op for the catalog.
211
212
  private readonly notifiedResolved = new Map<string, string>();
212
- // In-flight resolve-hook dedup (NFJV-, codex P2). The catalog's `onResolve`
213
- // hook is ASYNC (it returns `registerResolved(...)`), so `notifiedResolved` is
214
- // only set after the hook's promise SETTLES. Without tracking the in-flight
215
- // window, a burst of `storageFor()` cache hits for the SAME namespace before
216
- // the first registration finishes would each pass the `notifiedResolved` guard
217
- // and fire their OWN `onResolve` queueing N duplicate catalog touches + lock
218
- // acquisitions despite the once-per-namespace intent. We therefore record the
219
- // (namespace storageDir) being registered BEFORE awaiting the hook so a
220
- // concurrent call for the same pair skips firing. On SUCCESS the pair is
221
- // promoted to `notifiedResolved` (future calls skip permanently); on `false`
222
- // (dropped touch e.g. rebuild-lock timeout) OR rejection the in-flight marker
223
- // is CLEARED so a later `storageFor()` can RETRY the dropped registration. The
224
- // entry is always removed when the promise settles, so the map cannot grow
225
- // unbounded (one transient entry per concurrently-resolving namespace).
226
- private readonly inFlightResolved = new Map<string, string>();
213
+ // Instance-scoped serializer for the resolve hook (issue #1524 adoption).
214
+ // Replaces the bespoke `inFlightResolved` marker-then-clear pattern: the
215
+ // serializer's per-key chain strictly orders concurrent notifications for the
216
+ // SAME namespace, and the task re-checks `notifiedResolved` once it runs, so a
217
+ // burst of cache hits before the first hook settles collapses to a single
218
+ // hook invocation (the once-per-namespace intent). Recovery is preserved
219
+ // one rejected hook never poisons subsequent notifications for that key.
220
+ private readonly resolveSerializer = new MutationSerializer();
221
+ // (namespace, storageDir) pairs whose resolve hook is currently pending. Set SYNCHRONOUSLY in
222
+ // notifyResolved (before queueing) so a burst of concurrent storageFor()
223
+ // cache hits collapses onto the one in-flight hook instead of each enqueuing
224
+ // its own task. Cleared when the queued hook settles. Without this, a dropped
225
+ // hook (returns false on a rebuild-lock timeout) leaves notifiedResolved
226
+ // unset, so every queued sibling task would re-run the hook — N serial lock
227
+ // waits (cursor Medium 06f58a7c, codex P2).
228
+ private readonly inFlightResolveHooks = new Set<string>();
227
229
  // Tracks every in-flight resolve-hook promise so callers can deterministically
228
230
  // await the fire-and-forget registrations that `storageFor()` kicks off (see
229
231
  // `whenResolveHooksSettled`). Entries are removed as each hook settles, so the
@@ -308,77 +310,82 @@ export class NamespaceStorageRouter {
308
310
  /**
309
311
  * Fire the resolve hook defensively. A hook failure (e.g. a catalog write
310
312
  * error) MUST NOT crash storage resolution — see CLAUDE.md gotcha #13.
313
+ *
314
+ * Issue #1524 adoption: hook invocations for the SAME namespace are now
315
+ * strictly ordered through the shared `MutationSerializer` rather than the
316
+ * bespoke `inFlightResolved` marker-then-clear pattern. The serializer
317
+ * guarantees one in-flight hook per namespace; a concurrent burst of
318
+ * `storageFor()` cache hits collapses to a single hook invocation because
319
+ * each queued task re-checks `notifiedResolved` before invoking the hook. A
320
+ * dropped (`false`) or rejected hook leaves `notifiedResolved` unset so the
321
+ * next `storageFor()` retries — the serializer recovers from the rejection,
322
+ * so a failed hook never poisons later notifications.
311
323
  */
312
324
  private notifyResolved(namespace: string, storageDir: string): void {
313
325
  const hook = this.hooks.onResolve;
314
326
  if (!hook) return;
315
- // Skip when we've already SUCCESSFULLY notified this exact (namespace,
316
- // storageDir) a steady-state cache hit must not re-append to the catalog
317
- // log (NCNL2). A changed dir (rare: migration/realignment) still re-fires
318
- // once. We mark the pair as notified ONLY AFTER the hook succeeds, and CLEAR
319
- // it on failure, so a dropped registration (e.g. rebuild-lock timeout) is
320
- // RETRIED on the next cache hit instead of being suppressed forever (round 6,
321
- // cursor Medium — ND3EJ).
327
+ // Permanent dedup: skip once we've SUCCESSFULLY notified this exact
328
+ // (namespace, storageDir). A changed dir (rare: migration/realignment)
329
+ // still re-fires once. The mark is set ONLY AFTER the hook succeeds, so a
330
+ // dropped registration (e.g. rebuild-lock timeout) is RETRIED on the next
331
+ // cache hit instead of being suppressed forever (round 6, cursor Medium —
332
+ // ND3EJ).
322
333
  if (this.notifiedResolved.get(namespace) === storageDir) return;
323
- // In-flight dedup (NFJV-, codex P2): if a registration for this exact
324
- // (namespace, storageDir) is already AWAITING its async hook, do not fire a
325
- // second one. Without this, concurrent cache-hit bursts before the first
326
- // append settles each pass the `notifiedResolved` guard above and queue
327
- // duplicate catalog touches/lock acquisitions. A pair with a DIFFERENT
328
- // in-flight dir (rare mid-migration realignment) still fires once.
329
- if (this.inFlightResolved.get(namespace) === storageDir) return;
330
- try {
331
- // Handle BOTH synchronous throws and asynchronous rejections (round 6,
332
- // codex P2 NDo8C). The hook may be `async`; its rejected promise would
333
- // bypass this try/catch and, where unhandled rejections are fatal, crash
334
- // storage resolution. Mark the dedup pair as notified ONLY when the hook
335
- // resolves to a PERSISTED result (round 6, codex P2 — NEFoX): a result of
336
- // `false` means the registration was dropped/no-op (e.g. rebuild-lock
337
- // timeout), so we must NOT suppress its retry. `void`/`undefined` is treated
338
- // as success for legacy hooks. On rejection we leave it un-notified to retry.
339
- //
340
- // Record the in-flight marker BEFORE awaiting so concurrent calls for the
341
- // same pair skip (NFJV-). It is always cleared once the promise settles, so
342
- // the map holds at most one transient entry per concurrently-resolving
343
- // namespace and cannot grow unbounded.
344
- this.inFlightResolved.set(namespace, storageDir);
345
- const hookResult = Promise.resolve(hook(namespace, storageDir));
346
- // Track the in-flight promise so `whenResolveHooksSettled()` can await it.
347
- this.pendingResolveHooks.add(hookResult);
348
- hookResult.then(
349
- (persisted) => {
350
- // Clear the in-flight marker ONLY if it is still ours (a newer resolve
351
- // for a different dir may have replaced it).
352
- if (this.inFlightResolved.get(namespace) === storageDir) {
353
- this.inFlightResolved.delete(namespace);
354
- }
355
- if (persisted !== false) {
356
- this.notifiedResolved.set(namespace, storageDir);
357
- }
358
- // On `false` (dropped touch) we intentionally do NOT mark notified, so
359
- // a later `storageFor()` retries the registration. Clearing the
360
- // in-flight marker above is what re-enables that retry.
361
- this.pendingResolveHooks.delete(hookResult);
362
- },
363
- () => {
364
- // Registration failed clear in-flight AND do NOT mark as notified, so
365
- // it is retried on the next cache hit.
366
- if (this.inFlightResolved.get(namespace) === storageDir) {
367
- this.inFlightResolved.delete(namespace);
368
- }
369
- if (this.notifiedResolved.get(namespace) === storageDir) {
370
- this.notifiedResolved.delete(namespace);
371
- }
372
- this.pendingResolveHooks.delete(hookResult);
373
- },
374
- );
375
- } catch {
376
- // Synchronous throw: clear any in-flight marker we just set and leave the
377
- // pair un-notified so a later resolve retries.
378
- if (this.inFlightResolved.get(namespace) === storageDir) {
379
- this.inFlightResolved.delete(namespace);
334
+ // In-flight dedup (cursor Medium 06f58a7c, codex P2): if a hook for THIS
335
+ // namespace is already pending, collapse this call onto it instead of
336
+ // enqueueing another task. The serializer strictly orders queued tasks, but
337
+ // ordering alone is not enough — when the first hook returns `false`
338
+ // (dropped touch, e.g. rebuild-lock timeout), `notifiedResolved` stays
339
+ // unset, so each queued sibling task would pass its re-check and re-run the
340
+ // hook. With the real catalog hook each retry can spend the full lock wait,
341
+ // so N cache hits during a rebuild leave N serial background lock attempts.
342
+ // Collapsing here means at most ONE hook runs per in-flight registration;
343
+ // its result (set `notifiedResolved` on success, leave unset on drop)
344
+ // decides whether a LATER `storageFor()` retries collapsing loses
345
+ // nothing because the drop is already retried on the next cache hit.
346
+ // Keyed by the composite (namespace, storageDir) so a CHANGED dir
347
+ // (migration/realignment) for the same namespace still gets its own hook —
348
+ // it is NOT collapsed onto the old dir's pending registration (cursor
349
+ // Medium, codex P2).
350
+ const inFlightKey = namespace + "\u0000" + storageDir;
351
+ if (this.inFlightResolveHooks.has(inFlightKey)) return;
352
+ this.inFlightResolveHooks.add(inFlightKey);
353
+ // Queue through the serializer. Concurrent calls for the same namespace
354
+ // strictly order here; the 2nd call's task runs only after the 1st's hook
355
+ // settles, by which point `notifiedResolved` is either set (no-op) or still
356
+ // unset (retry). The returned promise is tracked for
357
+ // `whenResolveHooksSettled()`. Rejections from the task body are caught so
358
+ // they never reach the caller (best-effort hook contract); the serializer's
359
+ // recovered tail still lets subsequent notifications run.
360
+ const task = async (): Promise<void> => {
361
+ // Re-check after queueing: a prior task in this same chain may have just
362
+ // marked the pair as notified.
363
+ if (this.notifiedResolved.get(namespace) === storageDir) return;
364
+ try {
365
+ // Hook may be sync or async; Promise.resolve normalizes both. A result
366
+ // of `false` means the registration was dropped/no-op (e.g. rebuild-lock
367
+ // timeout) — leave notifiedResolved UNSET so the next storageFor retries
368
+ // (round 6, codex P2 — NEFoX). `void`/`undefined` is success for legacy
369
+ // hooks. A rejection leaves notifiedResolved unset for the same reason.
370
+ const persisted = await Promise.resolve(hook(namespace, storageDir));
371
+ if (persisted !== false) {
372
+ this.notifiedResolved.set(namespace, storageDir);
373
+ }
374
+ } catch {
375
+ // Best-effort: a hook failure MUST NOT crash storage resolution. Leave
376
+ // notifiedResolved unset so a later storageFor retries the registration.
380
377
  }
381
- }
378
+ };
379
+ const queued = this.resolveSerializer.serialize(namespace, task);
380
+ this.pendingResolveHooks.add(queued);
381
+ // Clear the in-flight marker when the hook settles so a subsequent
382
+ // storageFor() can retry after a drop, or short-circuit via notifiedResolved
383
+ // after a success.
384
+ const cleanup = (): void => {
385
+ this.inFlightResolveHooks.delete(inFlightKey);
386
+ this.pendingResolveHooks.delete(queued);
387
+ };
388
+ void queued.then(cleanup, cleanup);
382
389
  }
383
390
 
384
391
  /**
@@ -2,7 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { mkdtemp, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { mkdtemp, mkdir, open, readFile, rm, unlink, writeFile } from "node:fs/promises";
6
6
  import { HourlySummarizer } from "./summarizer.js";
7
7
  import {
8
8
  readSummarySnapshot,
@@ -756,3 +756,65 @@ test("hourly transcript lookup reads encoded transcript channel directories", as
756
756
  ["encoded transcript"],
757
757
  );
758
758
  });
759
+
760
+ // ── Issue #1524 adoption prove-fail: summary-snapshot upserts must run through
761
+ // the shared serializeMutations chain (rejection-recovering). The defect class
762
+ // is a naive bare-.then(fn) chain that silently drops subsequent tasks after a
763
+ // rejection: if upsert A rejects (e.g. transient I/O error) and the chain does
764
+ // not recover, upsert B queued behind it never runs — its hour goes missing
765
+ // from the snapshot. We seed the failure by rejecting the first upsert (a
766
+ // hostile lock directory that throws on mkdir), then upsert B must STILL land
767
+ // its hour. Pre-fix (a poison chain) B would be skipped.
768
+ test("upsertSummarySnapshot recovers after a prior upsert rejects (issue #1524 poison-chain prove-fail)", async () => {
769
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-summary-poison-chain-"));
770
+ const sessionKey = "session-poison";
771
+ try {
772
+ // The lock directory for sessionKey is <root>/state/summaries/<encoded>.lock.
773
+ // Pre-create a FILE at the summaries root so the upsert's mkdir(dirname)
774
+ // inside writeSummarySnapshot throws ENOTDIR — a deterministic rejection
775
+ // that does not depend on filesystem timing. Both upserts target the SAME
776
+ // sessionKey so they serialize through the same chain.
777
+ const summariesRoot = path.join(memoryDir, "state", "summaries");
778
+ await mkdir(path.dirname(summariesRoot), { recursive: true });
779
+ await writeFile(summariesRoot, "not-a-dir", "utf-8");
780
+
781
+ const summaryA = {
782
+ hour: "2026-03-26T08:00:00.000Z",
783
+ sessionKey,
784
+ bullets: ["A"],
785
+ turnCount: 1,
786
+ generatedAt: "2026-03-26T08:15:00.000Z",
787
+ };
788
+ const summaryB = {
789
+ hour: "2026-03-26T09:00:00.000Z",
790
+ sessionKey,
791
+ bullets: ["B"],
792
+ turnCount: 1,
793
+ generatedAt: "2026-03-26T09:15:00.000Z",
794
+ };
795
+
796
+ // Upsert A rejects (hostile summaries root). We MUST catch — the contract
797
+ // is that the chain recovers, not that the failing op silently succeeds.
798
+ await upsertSummarySnapshot(memoryDir, summaryA).then(
799
+ () => assert.fail("upsert A should have rejected on the hostile summaries root"),
800
+ () => undefined,
801
+ );
802
+
803
+ // Restore a usable summaries root so upsert B can actually write.
804
+ await unlink(summariesRoot);
805
+ await mkdir(summariesRoot, { recursive: true });
806
+
807
+ // Upsert B MUST still run despite A's rejection. With a poison chain B
808
+ // would be skipped (no snapshot, no B-hour bullet).
809
+ await upsertSummarySnapshot(memoryDir, summaryB);
810
+
811
+ const snapshot = await readSummarySnapshot(memoryDir, sessionKey);
812
+ assert.ok(snapshot, "upsert B landed after upsert A rejected (chain recovered)");
813
+ assert.ok(
814
+ snapshot.some((s) => s.bullets.includes("B")),
815
+ "upsert B's hour survived — the chain was not poisoned by A's rejection",
816
+ );
817
+ } finally {
818
+ await rm(memoryDir, { recursive: true, force: true });
819
+ }
820
+ });
@@ -1,18 +1,14 @@
1
- import {
2
- mkdir,
3
- open,
4
- readFile,
5
- stat,
6
- unlink,
7
- utimes,
8
- writeFile,
9
- } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
2
  import path from "node:path";
11
3
  import { z } from "zod";
12
4
  import {
13
5
  encodeStoragePathSegment,
14
6
  resolvePathInsideStorageRoot,
15
7
  } from "./storage-paths.js";
8
+ import {
9
+ serializeMutations,
10
+ withHeldFileLock,
11
+ } from "./utils/serialize-mutations.js";
16
12
  import type { HourlySummary } from "./types.js";
17
13
 
18
14
  const summarySnapshotSchemaVersion = 1;
@@ -34,7 +30,9 @@ const SummarySnapshotSchema = z.object({
34
30
 
35
31
  type SummarySnapshot = z.infer<typeof SummarySnapshotSchema>;
36
32
 
37
- const summarySnapshotUpserts = new Map<string, Promise<void>>();
33
+ // Lock timings for the cross-process summary-snapshot file lock. These are
34
+ // passed straight through to the shared `withHeldFileLock` utility (issue
35
+ // #1524 adoption); the previous bespoke lock used the same numbers.
38
36
  const summarySnapshotLockTimeoutMs = 5_000;
39
37
  const summarySnapshotLockStaleMs = 30_000;
40
38
  const summarySnapshotLockHeartbeatMs = Math.max(
@@ -137,31 +135,65 @@ export async function writeSummarySnapshot(
137
135
  await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
138
136
  }
139
137
 
138
+ // ── Concurrency primitives (issue #1524 adoption) ─────────────────────────
139
+ //
140
+ // Summary-snapshot upserts were previously guarded by TWO bespoke serializers:
141
+ // 1. an in-process `summarySnapshotUpserts` map keyed by sessionKey that
142
+ // chained each upsert off the prior one's release (mirroring
143
+ // `serializeMutations`), and
144
+ // 2. an on-disk `withExclusiveSummarySnapshotFileLock` that re-implemented
145
+ // `open(path, "wx")` acquire, mtime heartbeat, stale-break, and
146
+ // ownership-checked release.
147
+ // Both now delegate to the shared utility so there is ONE home for each
148
+ // primitive (`serializeMutations` for in-process, `withHeldFileLock` for
149
+ // cross-process). The behavior contract is preserved:
150
+ // - in-process upserts for the SAME sessionKey are strictly serialized
151
+ // (read-merge-write cannot interleave);
152
+ // - a cross-process holder blocks up to `summarySnapshotLockTimeoutMs`, then
153
+ // the upsert FAILS (a snapshot is advisory; we never race a read-merge-write
154
+ // unlocked). To preserve this strictness on top of a best-effort utility,
155
+ // the work callback throws when `acquired === false`.
156
+
140
157
  async function withSummarySnapshotLock<T>(
141
158
  memoryDir: string,
142
159
  sessionKey: string,
143
160
  work: () => Promise<T>,
144
161
  ): Promise<T> {
145
- const previous = summarySnapshotUpserts.get(sessionKey) ?? Promise.resolve();
146
- let release!: () => void;
147
- const current = new Promise<void>((resolve) => {
148
- release = resolve;
149
- });
150
- const chained = previous.then(() => current);
151
- summarySnapshotUpserts.set(sessionKey, chained);
152
-
153
- await previous;
154
- try {
155
- return await withExclusiveSummarySnapshotFileLock(
162
+ // In-process serialization (one upsert per sessionKey at a time). The
163
+ // serializer recovers from rejection, so a failed upsert never poisons the
164
+ // next one matching the previous chain's `.then(noop, noop)` recovery.
165
+ return serializeMutations(`summary-snapshot:${sessionKey}`, () =>
166
+ // Cross-process mutex via the shared utility. The lock path, stale
167
+ // threshold, bounded wait, and heartbeat cadence all flow through; the
168
+ // utility's replacement-safe stale break (NG7Bg) and ownership-checked
169
+ // release are stronger than the bare `unlink` this module used before.
170
+ withHeldFileLock(
156
171
  summarySnapshotLockPath(memoryDir, sessionKey),
157
- work,
158
- );
159
- } finally {
160
- release();
161
- if (summarySnapshotUpserts.get(sessionKey) === chained) {
162
- summarySnapshotUpserts.delete(sessionKey);
163
- }
164
- }
172
+ {
173
+ staleMs: summarySnapshotLockStaleMs,
174
+ maxWaitMs: summarySnapshotLockTimeoutMs,
175
+ heartbeatMs: summarySnapshotLockHeartbeatMs,
176
+ },
177
+ async (acquired) => {
178
+ // Strict-fail when the lock could not be acquired: the upsert is a
179
+ // read-merge-write, so a best-effort unlocked run would clobber a
180
+ // concurrent writer. The utility's `acquired === false` covers BOTH a
181
+ // genuine contention timeout AND a filesystem acquire failure (lock-dir
182
+ // mkdir/open/permission errors — the advisory lock is best-effort, so
183
+ // the util degrades rather than throwing). The bespoke lock this
184
+ // replaced propagated fs errors verbatim and reserved the timeout
185
+ // message for contention; we no longer claim "timed out" for an fs
186
+ // failure (cursor Low 25143f4f) — the message names both causes so
187
+ // upstream fail-open (runHourly) is unchanged but debugging is honest.
188
+ if (!acquired) {
189
+ throw new Error(
190
+ "could not acquire summary snapshot lock (contention timeout or filesystem error)",
191
+ );
192
+ }
193
+ return work();
194
+ },
195
+ ),
196
+ );
165
197
  }
166
198
 
167
199
  export async function upsertSummarySnapshot(
@@ -185,54 +217,3 @@ export async function upsertSummarySnapshot(
185
217
  await writeSummarySnapshot(memoryDir, summary.sessionKey, next);
186
218
  });
187
219
  }
188
-
189
- async function withExclusiveSummarySnapshotFileLock<T>(
190
- lockPath: string,
191
- callback: () => Promise<T>,
192
- ): Promise<T> {
193
- await mkdir(path.dirname(lockPath), { recursive: true });
194
- const startedAt = Date.now();
195
-
196
- while (true) {
197
- try {
198
- const handle = await open(lockPath, "wx");
199
- let heartbeat: NodeJS.Timeout | null = null;
200
- if (summarySnapshotLockHeartbeatMs > 0) {
201
- heartbeat = setInterval(() => {
202
- void utimes(lockPath, new Date(), new Date()).catch(() => undefined);
203
- }, summarySnapshotLockHeartbeatMs);
204
- heartbeat.unref?.();
205
- }
206
- try {
207
- return await callback();
208
- } finally {
209
- if (heartbeat) clearInterval(heartbeat);
210
- await handle.close().catch(() => undefined);
211
- await unlink(lockPath).catch(() => undefined);
212
- }
213
- } catch (error) {
214
- if (!isAlreadyExistsError(error)) throw error;
215
- try {
216
- const lockStat = await stat(lockPath);
217
- if (Date.now() - lockStat.mtimeMs > summarySnapshotLockStaleMs) {
218
- await unlink(lockPath).catch(() => undefined);
219
- continue;
220
- }
221
- } catch {
222
- continue;
223
- }
224
- if (Date.now() - startedAt > summarySnapshotLockTimeoutMs) {
225
- throw new Error("timed out acquiring summary snapshot lock");
226
- }
227
- await sleep(10);
228
- }
229
- }
230
- }
231
-
232
- function isAlreadyExistsError(error: unknown): boolean {
233
- return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
234
- }
235
-
236
- function sleep(ms: number): Promise<void> {
237
- return new Promise((resolve) => setTimeout(resolve, ms));
238
- }
@@ -670,10 +670,14 @@ async function lockHeldBySelf(held: HeldLock): Promise<boolean> {
670
670
  }
671
671
 
672
672
  function sleep(ms: number): Promise<void> {
673
- const { promise, resolve } = Promise.withResolvers<void>();
674
- // NOT unref'd: this polls inside an awaited acquire loop, so the caller's
675
- // await chain keeps the loop alive; unref would let Node exit mid-poll when
676
- // nothing else is pending (the heartbeat interval IS unref'd separately).
677
- setTimeout(resolve, ms);
678
- return promise;
673
+ // Manual deferred instead of Promise.withResolvers (ES2024) — plugin-openclaw's
674
+ // standalone tsconfig targets ES2022 lib and this module is reachable from its
675
+ // type graph, so withResolvers would TS2550 there (same fix as
676
+ // extraction-faithfulness.ts:467).
677
+ return new Promise<void>((resolve) => {
678
+ // NOT unref'd: this polls inside an awaited acquire loop, so the caller's
679
+ // await chain keeps the loop alive; unref would let Node exit mid-poll when
680
+ // nothing else is pending (the heartbeat interval IS unref'd separately).
681
+ setTimeout(resolve, ms);
682
+ });
679
683
  }