@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.
- package/dist/access-boundary.d.ts +3 -3
- package/dist/access-boundary.js +7 -7
- package/dist/access-cli.js +21 -21
- package/dist/access-http.d.ts +3 -3
- package/dist/access-http.js +10 -10
- package/dist/access-mcp.d.ts +3 -3
- package/dist/access-mcp.js +9 -9
- package/dist/access-operations.d.ts +3 -3
- package/dist/access-operations.js +8 -8
- package/dist/{access-service-Cte3ol0W.d.ts → access-service-CGVWK6lZ.d.ts} +1 -1
- package/dist/access-service.d.ts +3 -3
- package/dist/access-service.js +6 -6
- package/dist/access-surface-catalog.d.ts +3 -3
- package/dist/bootstrap.d.ts +2 -2
- package/dist/briefing.js +4 -4
- package/dist/{catalog-DN1PzThs.d.ts → catalog-DBIghceA.d.ts} +26 -52
- package/dist/causal-consolidation.js +5 -5
- package/dist/{chunk-GYVVQYA3.js → chunk-27LQPUMZ.js} +3 -3
- package/dist/{chunk-XJNBEDFE.js → chunk-3FAMU5TX.js} +31 -74
- package/dist/chunk-3FAMU5TX.js.map +1 -0
- package/dist/{chunk-PCZR32VL.js → chunk-3JJWNZTT.js} +2 -2
- package/dist/{chunk-GA5A6MJH.js → chunk-4HIAWLA2.js} +14 -14
- package/dist/{chunk-SEWF2O74.js → chunk-6W2D6FGG.js} +2 -2
- package/dist/{chunk-CHM274U6.js → chunk-7TAQEPLE.js} +2 -2
- package/dist/{chunk-G5PKTQ5J.js → chunk-DEDQXIDL.js} +2 -2
- package/dist/{chunk-3E5WRQNQ.js → chunk-FUCJAZ25.js} +4 -4
- package/dist/{chunk-6JDGADXK.js → chunk-HF4N43Q7.js} +2 -2
- package/dist/{chunk-NHBEO3F3.js → chunk-HXHKLVAS.js} +14 -14
- package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
- package/dist/{chunk-SMIVW7XC.js → chunk-ISLJ5WIM.js} +2 -2
- package/dist/{chunk-HDLC75NX.js → chunk-IYOPIG3E.js} +2 -2
- package/dist/{chunk-DR2JTSLZ.js → chunk-JKOKX3PS.js} +62 -163
- package/dist/chunk-JKOKX3PS.js.map +1 -0
- package/dist/{chunk-2NWHLAXX.js → chunk-JO3E5VGS.js} +2 -2
- package/dist/{chunk-CCOXIDRM.js → chunk-KF4TXW7Z.js} +4 -4
- package/dist/{chunk-JX3YZVII.js → chunk-KS7WQ4BZ.js} +3 -3
- package/dist/{chunk-YPR7DOPD.js → chunk-LTJAMRGI.js} +4 -4
- package/dist/{chunk-YPR7DOPD.js.map → chunk-LTJAMRGI.js.map} +1 -1
- package/dist/{chunk-ZDK2IW5F.js → chunk-MNU5G4TK.js} +2 -2
- package/dist/{chunk-PQG4T5V3.js → chunk-ODTWHSY2.js} +49 -45
- package/dist/chunk-ODTWHSY2.js.map +1 -0
- package/dist/{chunk-YMTGXDN6.js → chunk-OLOYQZFB.js} +4 -4
- package/dist/{chunk-JKW5XSWC.js → chunk-QP37KL5H.js} +2 -2
- package/dist/{chunk-X74FJSW7.js → chunk-SFMRLXIV.js} +2 -2
- package/dist/{chunk-RJ2THZ4H.js → chunk-TFVVONWD.js} +2 -2
- package/dist/{chunk-XY4WJTEX.js → chunk-WFEZUGU5.js} +2 -2
- package/dist/{chunk-O54DY26V.js → chunk-XTIRCSIH.js} +2 -2
- package/dist/{chunk-EOBJRBLC.js → chunk-YO4MBK3I.js} +2 -2
- package/dist/{chunk-YXIFA36P.js → chunk-ZT7B64BE.js} +2 -2
- package/dist/{chunk-33L6XHU2.js → chunk-ZYNMX6IU.js} +5 -5
- package/dist/{cli--yVN9yEV.d.ts → cli-D3XeenwN.d.ts} +2 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +21 -21
- package/dist/compounding/engine.js +4 -4
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/index.js +4 -4
- package/dist/entity-retrieval.js +4 -4
- package/dist/explicit-capture.d.ts +2 -2
- package/dist/{forget-BEXG5PQC.js → forget-6SOIPUMQ.js} +3 -3
- package/dist/index.d.ts +5 -5
- package/dist/index.js +29 -29
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +3 -3
- package/dist/namespaces/migrate.d.ts +1 -1
- package/dist/namespaces/migrate.js +5 -5
- package/dist/namespaces/storage.d.ts +13 -2
- package/dist/namespaces/storage.js +4 -4
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestration/maintenance.d.ts +1 -1
- package/dist/orchestration/maintenance.js +6 -6
- package/dist/{orchestrator-CJI4xdqV.d.ts → orchestrator-BzMCZlKn.d.ts} +1 -1
- package/dist/orchestrator.d.ts +2 -2
- package/dist/orchestrator.js +17 -17
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/storage.js +3 -3
- package/dist/summarizer.js +3 -2
- package/dist/summary-snapshot.js +2 -1
- package/dist/utils/serialize-mutations.js +1 -1
- package/dist/verified-recall.js +4 -4
- package/package.json +2 -2
- package/src/namespaces/catalog.test.ts +222 -14
- package/src/namespaces/catalog.ts +54 -187
- package/src/namespaces/storage.ts +87 -80
- package/src/summary-snapshot.test.ts +63 -1
- package/src/summary-snapshot.ts +61 -80
- package/src/utils/serialize-mutations.ts +10 -6
- package/dist/chunk-DR2JTSLZ.js.map +0 -1
- package/dist/chunk-PQG4T5V3.js.map +0 -1
- package/dist/chunk-XJNBEDFE.js.map +0 -1
- /package/dist/{chunk-GYVVQYA3.js.map → chunk-27LQPUMZ.js.map} +0 -0
- /package/dist/{chunk-PCZR32VL.js.map → chunk-3JJWNZTT.js.map} +0 -0
- /package/dist/{chunk-GA5A6MJH.js.map → chunk-4HIAWLA2.js.map} +0 -0
- /package/dist/{chunk-SEWF2O74.js.map → chunk-6W2D6FGG.js.map} +0 -0
- /package/dist/{chunk-CHM274U6.js.map → chunk-7TAQEPLE.js.map} +0 -0
- /package/dist/{chunk-G5PKTQ5J.js.map → chunk-DEDQXIDL.js.map} +0 -0
- /package/dist/{chunk-3E5WRQNQ.js.map → chunk-FUCJAZ25.js.map} +0 -0
- /package/dist/{chunk-6JDGADXK.js.map → chunk-HF4N43Q7.js.map} +0 -0
- /package/dist/{chunk-NHBEO3F3.js.map → chunk-HXHKLVAS.js.map} +0 -0
- /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
- /package/dist/{chunk-SMIVW7XC.js.map → chunk-ISLJ5WIM.js.map} +0 -0
- /package/dist/{chunk-HDLC75NX.js.map → chunk-IYOPIG3E.js.map} +0 -0
- /package/dist/{chunk-2NWHLAXX.js.map → chunk-JO3E5VGS.js.map} +0 -0
- /package/dist/{chunk-CCOXIDRM.js.map → chunk-KF4TXW7Z.js.map} +0 -0
- /package/dist/{chunk-JX3YZVII.js.map → chunk-KS7WQ4BZ.js.map} +0 -0
- /package/dist/{chunk-ZDK2IW5F.js.map → chunk-MNU5G4TK.js.map} +0 -0
- /package/dist/{chunk-YMTGXDN6.js.map → chunk-OLOYQZFB.js.map} +0 -0
- /package/dist/{chunk-JKW5XSWC.js.map → chunk-QP37KL5H.js.map} +0 -0
- /package/dist/{chunk-X74FJSW7.js.map → chunk-SFMRLXIV.js.map} +0 -0
- /package/dist/{chunk-RJ2THZ4H.js.map → chunk-TFVVONWD.js.map} +0 -0
- /package/dist/{chunk-XY4WJTEX.js.map → chunk-WFEZUGU5.js.map} +0 -0
- /package/dist/{chunk-O54DY26V.js.map → chunk-XTIRCSIH.js.map} +0 -0
- /package/dist/{chunk-EOBJRBLC.js.map → chunk-YO4MBK3I.js.map} +0 -0
- /package/dist/{chunk-YXIFA36P.js.map → chunk-ZT7B64BE.js.map} +0 -0
- /package/dist/{chunk-33L6XHU2.js.map → chunk-ZYNMX6IU.js.map} +0 -0
- /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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
// the first
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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
|
-
//
|
|
316
|
-
// storageDir)
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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 (
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
+
});
|
package/src/summary-snapshot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
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
|
}
|