@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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveDefaultNamespaceRoot,
|
|
12
12
|
resolveNamespaceStorageRoot,
|
|
13
13
|
} from "./storage.js";
|
|
14
|
+
import { withHeldFileLock } from "../utils/serialize-mutations.js";
|
|
14
15
|
|
|
15
16
|
function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
|
|
16
17
|
return {
|
|
@@ -2171,8 +2172,16 @@ test("registerConfiguredNamespaces skips an unsafe configured name without abort
|
|
|
2171
2172
|
|
|
2172
2173
|
// ── Round 7 (codex P2 — NBsGP): two catalog instances in the SAME process
|
|
2173
2174
|
// sharing a memoryDir must not treat each other's rebuild lock as self-held. A
|
|
2174
|
-
// touch on instance B must DROP its append while
|
|
2175
|
-
// instead of skipping the wait (same PID) and appending into
|
|
2175
|
+
// touch on instance B must DROP its append while a foreign lock is held,
|
|
2176
|
+
// instead of skipping the wait (same PID) and appending into the holder's
|
|
2177
|
+
// window.
|
|
2178
|
+
//
|
|
2179
|
+
// Issue #1524 note: the catalog now delegates to the shared withHeldFileLock
|
|
2180
|
+
// utility, which generates a per-CALL owner uuid (stronger than the previous
|
|
2181
|
+
// per-instance lockOwnerId). ANY foreign lock — including one written by
|
|
2182
|
+
// another call on the SAME instance — is therefore not self-held. The test
|
|
2183
|
+
// seeds a foreign lock with an arbitrary uuid + a heartbeat that keeps it
|
|
2184
|
+
// fresh, then asserts a touch on a fresh instance waits then DROPS.
|
|
2176
2185
|
test("a same-process second instance does not treat another instance's lock as self-held", async () => {
|
|
2177
2186
|
const memoryDir = await mkMemoryDir();
|
|
2178
2187
|
try {
|
|
@@ -2183,10 +2192,12 @@ test("a same-process second instance does not treat another instance's lock as s
|
|
|
2183
2192
|
await mkdir(stateDir, { recursive: true });
|
|
2184
2193
|
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2185
2194
|
|
|
2186
|
-
//
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2195
|
+
// A foreign held lock: a different PID + a real UUID owner id (so it is
|
|
2196
|
+
// NEVER mistaken for self by any catalog instance in any process) + a fresh
|
|
2197
|
+
// mtime. A heartbeat keeps it fresh so it is never broken as stale during
|
|
2198
|
+
// the touch's bounded wait.
|
|
2199
|
+
const foreignOwner = "00000000-0000-4000-8000-000000000000";
|
|
2200
|
+
await writeFile(lockPath, `999999 ${foreignOwner} ${new Date().toISOString()}\n`, "utf8");
|
|
2190
2201
|
const hb = setInterval(() => {
|
|
2191
2202
|
const now = new Date();
|
|
2192
2203
|
utimes(lockPath, now, now).catch(() => undefined);
|
|
@@ -2194,17 +2205,17 @@ test("a same-process second instance does not treat another instance's lock as s
|
|
|
2194
2205
|
hb.unref?.();
|
|
2195
2206
|
|
|
2196
2207
|
try {
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
const
|
|
2208
|
+
// The catalog instance must NOT consider the foreign lock self-held; its
|
|
2209
|
+
// touch waits then DROPS on timeout (no append).
|
|
2210
|
+
const instance = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2200
2211
|
const started = Date.now();
|
|
2201
|
-
await
|
|
2212
|
+
await instance.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
2202
2213
|
const waited = Date.now() - started;
|
|
2203
|
-
assert.ok(waited >= 4_000, "
|
|
2214
|
+
assert.ok(waited >= 4_000, "the catalog must wait on the foreign lock, not skip it as self");
|
|
2204
2215
|
assert.equal(
|
|
2205
|
-
await
|
|
2216
|
+
await instance.getNamespaceRecord(ns),
|
|
2206
2217
|
null,
|
|
2207
|
-
"
|
|
2218
|
+
"the touch must DROP while the foreign lock is held (no overwrite race)",
|
|
2208
2219
|
);
|
|
2209
2220
|
} finally {
|
|
2210
2221
|
clearInterval(hb);
|
|
@@ -2609,6 +2620,98 @@ test("a dropped resolve registration (hook returns false) is retried on a later
|
|
|
2609
2620
|
}
|
|
2610
2621
|
});
|
|
2611
2622
|
|
|
2623
|
+
// ── In-flight dedup under a DROPPED hook (cursor Medium 06f58a7c, codex P2).
|
|
2624
|
+
// The serializer strictly orders queued tasks, but ordering alone does not
|
|
2625
|
+
// collapse a burst when the hook returns `false` (dropped registration, e.g. a
|
|
2626
|
+
// rebuild-lock timeout): `notifiedResolved` stays unset, so every queued sibling
|
|
2627
|
+
// task passes its re-check and re-invokes the hook — N serial lock waits. The
|
|
2628
|
+
// `inFlightResolveHooks` marker (set synchronously before queueing) collapses the
|
|
2629
|
+
// burst to a single hook invocation; the drop stays retryable on a LATER
|
|
2630
|
+
// storageFor(). This test PROVE-FAILS without the marker (calls === N) and PASSES
|
|
2631
|
+
// with it (calls === 1).
|
|
2632
|
+
test("a burst of concurrent storageFor() with a DROPPED hook fires it ONCE (not once per queued task)", async () => {
|
|
2633
|
+
const memoryDir = await mkMemoryDir();
|
|
2634
|
+
try {
|
|
2635
|
+
let calls = 0;
|
|
2636
|
+
// Gated hook that DROPS (returns false) once released. This is the real
|
|
2637
|
+
// scenario the reviewers flagged: the catalog's onResolve waits on the
|
|
2638
|
+
// rebuild lock (slow), times out, and returns false. While that hook is
|
|
2639
|
+
// in-flight, a burst of cache hits must collapse to the one in-flight
|
|
2640
|
+
// registration instead of each queueing its own serial lock wait.
|
|
2641
|
+
let release!: () => void;
|
|
2642
|
+
const gate = new Promise<void>((r) => {
|
|
2643
|
+
release = r;
|
|
2644
|
+
});
|
|
2645
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
2646
|
+
onResolve: async () => {
|
|
2647
|
+
calls += 1;
|
|
2648
|
+
await gate;
|
|
2649
|
+
return false; // dropped (rebuild-lock timeout analogue)
|
|
2650
|
+
},
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
// A burst of concurrent cache hits while the hook is IN-FLIGHT. Without the
|
|
2654
|
+
// in-flight dedup marker each enqueued task would re-run the dropped hook
|
|
2655
|
+
// once the first settles (N serial lock waits); with it they collapse.
|
|
2656
|
+
const N = 8;
|
|
2657
|
+
await Promise.all(Array.from({ length: N }, () => router.storageFor("project-origin-burst-drop")));
|
|
2658
|
+
// Let the in-flight hook settle as DROPPED.
|
|
2659
|
+
release();
|
|
2660
|
+
await router.whenResolveHooksSettled();
|
|
2661
|
+
assert.equal(calls, 1, "a dropped hook fires ONCE under a burst, not once per queued task");
|
|
2662
|
+
|
|
2663
|
+
// The drop must remain retryable: a later storageFor() re-fires the hook.
|
|
2664
|
+
await router.storageFor("project-origin-burst-drop");
|
|
2665
|
+
await router.whenResolveHooksSettled();
|
|
2666
|
+
assert.equal(calls, 2, "a dropped registration is retried on the next storageFor()");
|
|
2667
|
+
} finally {
|
|
2668
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2669
|
+
}
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
// ── Composite-key in-flight dedup (cursor Medium, codex P2): the in-flight
|
|
2673
|
+
// marker must be keyed by (namespace, storageDir), NOT namespace alone. A
|
|
2674
|
+
// CHANGED storageDir (migration/realignment) for the same namespace while
|
|
2675
|
+
// another dir's hook is pending must still get its OWN hook invocation — it is
|
|
2676
|
+
// not collapsed onto the old dir's pending registration. A namespace-only key
|
|
2677
|
+
// would silently drop the new-dir notification.
|
|
2678
|
+
test("a CHANGED storageDir for the same namespace is not collapsed onto a pending hook", async () => {
|
|
2679
|
+
const memoryDir = await mkMemoryDir();
|
|
2680
|
+
try {
|
|
2681
|
+
const seen: string[] = [];
|
|
2682
|
+
let release!: () => void;
|
|
2683
|
+
const gate = new Promise<void>((r) => {
|
|
2684
|
+
release = r;
|
|
2685
|
+
});
|
|
2686
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
2687
|
+
onResolve: async (_ns, dir) => {
|
|
2688
|
+
seen.push(dir);
|
|
2689
|
+
await gate;
|
|
2690
|
+
},
|
|
2691
|
+
});
|
|
2692
|
+
// notifyResolved is private; reach it via the same cast pattern other tests
|
|
2693
|
+
// use for router internals, so we can drive two distinct dirs directly.
|
|
2694
|
+
const internals = router as unknown as {
|
|
2695
|
+
notifyResolved(namespace: string, storageDir: string): void;
|
|
2696
|
+
};
|
|
2697
|
+
const dirA = path.join(memoryDir, "dir-a");
|
|
2698
|
+
const dirB = path.join(memoryDir, "dir-b");
|
|
2699
|
+
// dirA's hook is IN-FLIGHT (gated). dirB is a DIFFERENT dir for the same
|
|
2700
|
+
// namespace — it must NOT be collapsed onto dirA's pending registration.
|
|
2701
|
+
internals.notifyResolved("project-origin-dir-change", dirA);
|
|
2702
|
+
internals.notifyResolved("project-origin-dir-change", dirB);
|
|
2703
|
+
release();
|
|
2704
|
+
await router.whenResolveHooksSettled();
|
|
2705
|
+
assert.deepEqual(
|
|
2706
|
+
seen.sort(),
|
|
2707
|
+
[dirA, dirB].sort(),
|
|
2708
|
+
"both distinct dirs fire their own hook; the new dir is not collapsed onto the pending one",
|
|
2709
|
+
);
|
|
2710
|
+
} finally {
|
|
2711
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2612
2715
|
// ── Round 7 (codex P2 — NDxiS): a configured non-default namespace must be seeded
|
|
2613
2716
|
// with the ROUTER-resolved root, not a blanket tokenized dir. When a legacy raw
|
|
2614
2717
|
// root (`namespaces/<rawname>`) already exists, the router serves it, so the
|
|
@@ -2777,8 +2880,35 @@ class SeamCatalog extends NamespaceCatalog {
|
|
|
2777
2880
|
(this as unknown as { onBeforeBreakStaleUnlinkForTest?: () => Promise<void> }).onBeforeBreakStaleUnlinkForTest =
|
|
2778
2881
|
fn;
|
|
2779
2882
|
}
|
|
2883
|
+
/**
|
|
2884
|
+
* Drive the catalog's break-stale path through the shared util (issue #1524
|
|
2885
|
+
* adoption). The catalog no longer owns a private breakStaleRebuildLock; the
|
|
2886
|
+
* util's breakStaleLock fires inside its acquire loop, which is what
|
|
2887
|
+
* withHeldCatalogLock now invokes. We trigger that path with a SHORT maxWaitMs
|
|
2888
|
+
* so a surviving replacement lock is observed quickly (the production
|
|
2889
|
+
* REBUILD_LOCK_MAX_WAIT_MS would force a 5s wait on the NG7Bg-replacement
|
|
2890
|
+
* case). The seam is forwarded exactly as production does.
|
|
2891
|
+
*/
|
|
2780
2892
|
async callBreakStaleRebuildLock(): Promise<void> {
|
|
2781
|
-
|
|
2893
|
+
const seam = (this as unknown as { onBeforeBreakStaleUnlinkForTest?: () => Promise<void> })
|
|
2894
|
+
.onBeforeBreakStaleUnlinkForTest;
|
|
2895
|
+
// Match the catalog's lock config (stale/heartbeat/poll); only maxWaitMs
|
|
2896
|
+
// is shortened for test speed. The break-stale invariant does not depend
|
|
2897
|
+
// on maxWaitMs — the seam fires inside breakStaleLock regardless.
|
|
2898
|
+
await withHeldFileLock(
|
|
2899
|
+
(this as unknown as { rebuildLockPath: string }).rebuildLockPath,
|
|
2900
|
+
{
|
|
2901
|
+
staleMs: 30_000,
|
|
2902
|
+
maxWaitMs: 200,
|
|
2903
|
+
pollMs: 10,
|
|
2904
|
+
heartbeatMs: 10_000,
|
|
2905
|
+
onBeforeBreakStaleUnlinkForTest: seam,
|
|
2906
|
+
},
|
|
2907
|
+
async () => {
|
|
2908
|
+
// No-op: we only need the acquire loop to invoke breakStaleLock so the
|
|
2909
|
+
// seam fires and the replacement/stale-lock invariant is exercised.
|
|
2910
|
+
},
|
|
2911
|
+
);
|
|
2782
2912
|
}
|
|
2783
2913
|
}
|
|
2784
2914
|
|
|
@@ -3354,3 +3484,81 @@ test("listNamespaces prefers configured token owners over stale literal token al
|
|
|
3354
3484
|
await rm(memoryDir, { recursive: true, force: true });
|
|
3355
3485
|
}
|
|
3356
3486
|
});
|
|
3487
|
+
|
|
3488
|
+
// ── Issue #1524 adoption prove-fail: catalog mutations route through the
|
|
3489
|
+
// shared MutationSerializer (instance-scoped `criticalSection`). The defect
|
|
3490
|
+
// class is a naive bare-.then(fn) chain that silently drops subsequent sections
|
|
3491
|
+
// after a rejection — exactly the poison-chain bug the shared util prevents.
|
|
3492
|
+
// We force the FIRST serialized section to reject, then assert the SECOND
|
|
3493
|
+
// section STILL runs (its record lands in the catalog). Pre-fix (a poison
|
|
3494
|
+
// chain) the second section would be skipped.
|
|
3495
|
+
test("catalog queueCritical recovers after a prior section rejects (issue #1524 poison-chain prove-fail)", async () => {
|
|
3496
|
+
const memoryDir = await mkMemoryDir();
|
|
3497
|
+
try {
|
|
3498
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3499
|
+
// Reach the private serializer to inject a failing section ahead of a real
|
|
3500
|
+
// one. Both target the SAME key ("catalog") so they share a chain.
|
|
3501
|
+
const serializer = (catalog as unknown as {
|
|
3502
|
+
criticalSection: {
|
|
3503
|
+
serialize<T>(key: string, task: () => Promise<T>): Promise<T>;
|
|
3504
|
+
};
|
|
3505
|
+
}).criticalSection;
|
|
3506
|
+
|
|
3507
|
+
let secondRan = false;
|
|
3508
|
+
const [, second] = await Promise.allSettled([
|
|
3509
|
+
serializer.serialize("catalog", async () => {
|
|
3510
|
+
throw new Error("intentional first-section failure");
|
|
3511
|
+
}),
|
|
3512
|
+
serializer.serialize("catalog", async () => {
|
|
3513
|
+
secondRan = true;
|
|
3514
|
+
}),
|
|
3515
|
+
]);
|
|
3516
|
+
assert.equal(second.status, "fulfilled", "second section settled (ran or skipped?)");
|
|
3517
|
+
assert.equal(secondRan, true, "second section MUST run after the first rejected (chain recovered)");
|
|
3518
|
+
// And a real catalog op still works through the same chain after the failure.
|
|
3519
|
+
await catalog.markWrite("project-origin-poison-recovery", { discoveredBy: "write" });
|
|
3520
|
+
const record = await catalog.getNamespaceRecord("project-origin-poison-recovery");
|
|
3521
|
+
assert.ok(record, "the catalog is fully usable after a rejected section (chain not poisoned)");
|
|
3522
|
+
} finally {
|
|
3523
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
// ── Issue #1524 adoption prove-fail: NamespaceStorageRouter resolve-hooks
|
|
3528
|
+
// route through the shared MutationSerializer (`resolveSerializer`). Same
|
|
3529
|
+
// defect class — a poison chain would skip the second hook after the first
|
|
3530
|
+
// rejected. We fire two notifications for the SAME namespace; the first hook
|
|
3531
|
+
// rejects, the second MUST still run.
|
|
3532
|
+
test("router resolve-hook serializer recovers after a prior hook rejects (issue #1524 poison-chain prove-fail)", async () => {
|
|
3533
|
+
const memoryDir = await mkMemoryDir();
|
|
3534
|
+
try {
|
|
3535
|
+
let secondCalls = 0;
|
|
3536
|
+
let firstCalls = 0;
|
|
3537
|
+
let rejectNext = true;
|
|
3538
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
3539
|
+
onResolve: async () => {
|
|
3540
|
+
if (rejectNext) {
|
|
3541
|
+
firstCalls += 1;
|
|
3542
|
+
rejectNext = false;
|
|
3543
|
+
throw new Error("intentional first-hook failure");
|
|
3544
|
+
}
|
|
3545
|
+
secondCalls += 1;
|
|
3546
|
+
},
|
|
3547
|
+
});
|
|
3548
|
+
// Two storageFor calls for the same namespace. The first triggers the hook
|
|
3549
|
+
// (which rejects); the second queues behind it through the serializer.
|
|
3550
|
+
// Both calls themselves must resolve (the rejection is best-effort inside
|
|
3551
|
+
// the hook wrapper, never surfaced to the storage caller).
|
|
3552
|
+
await router.storageFor("project-origin-router-poison");
|
|
3553
|
+
await router.whenResolveHooksSettled();
|
|
3554
|
+
await router.storageFor("project-origin-router-poison");
|
|
3555
|
+
await router.whenResolveHooksSettled();
|
|
3556
|
+
assert.ok(firstCalls >= 1, "the first (rejecting) hook fired");
|
|
3557
|
+
assert.ok(
|
|
3558
|
+
secondCalls >= 1,
|
|
3559
|
+
"the second hook MUST fire after the first rejected (serializer recovered, not poisoned)",
|
|
3560
|
+
);
|
|
3561
|
+
} finally {
|
|
3562
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3563
|
+
}
|
|
3564
|
+
});
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import type { Dirent } from "node:fs";
|
|
4
3
|
import {
|
|
5
4
|
appendFile,
|
|
6
5
|
lstat,
|
|
7
6
|
mkdir,
|
|
8
|
-
open,
|
|
9
7
|
readdir,
|
|
10
8
|
readFile,
|
|
11
9
|
realpath,
|
|
12
10
|
rename,
|
|
13
11
|
stat,
|
|
14
|
-
unlink,
|
|
15
|
-
utimes,
|
|
16
12
|
writeFile,
|
|
17
13
|
} from "node:fs/promises";
|
|
18
14
|
import type { PluginConfig } from "../types.js";
|
|
15
|
+
import {
|
|
16
|
+
MutationSerializer,
|
|
17
|
+
withHeldFileLock,
|
|
18
|
+
} from "../utils/serialize-mutations.js";
|
|
19
19
|
import { isSafeRouteNamespace } from "../routing/engine.js";
|
|
20
20
|
import { namespaceIdentityFromToken, namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
|
|
21
21
|
import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
|
|
@@ -430,15 +430,16 @@ export class NamespaceCatalog {
|
|
|
430
430
|
private readonly stateDir: string;
|
|
431
431
|
private readonly catalogPath: string;
|
|
432
432
|
private readonly rebuildLockPath: string;
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
433
|
+
// In-process serialization for catalog mutations (issue #1524 adoption).
|
|
434
|
+
// Replaces the bespoke `writeChain` field: every touch/rebuild runs through
|
|
435
|
+
// this serializer so a single failed section never poisons subsequent ones
|
|
436
|
+
// (CLAUDE.md rule #40 — recovery is the util's contract, not re-implemented
|
|
437
|
+
// here). Cross-process identity (the lock file's owner-uuid) is now per-CALL
|
|
438
|
+
// inside the shared util, which is STRONGER than the previous per-instance
|
|
439
|
+
// `lockOwnerId` — two calls on the SAME instance get different ids, so
|
|
440
|
+
// neither mistakes the other's lock for self-held (round 6, codex P2 — NBsGP
|
|
441
|
+
// invariant preserved and tightened).
|
|
442
|
+
private readonly criticalSection = new MutationSerializer();
|
|
442
443
|
// Test-only seam (round 7 — NEZkA): fires inside a touch's HELD-lock critical
|
|
443
444
|
// section, after the lock is acquired but BEFORE the read→merge→append. A
|
|
444
445
|
// deterministic concurrency test installs a hook here to widen the (otherwise
|
|
@@ -1826,24 +1827,23 @@ export class NamespaceCatalog {
|
|
|
1826
1827
|
* their respective critical sections — closing the check-then-append gap where a
|
|
1827
1828
|
* polled-only touch could append into a rebuild's load→rename window.
|
|
1828
1829
|
*
|
|
1829
|
-
*
|
|
1830
|
-
* `
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1830
|
+
* Issue #1524 adoption: this is now a thin delegation to the shared
|
|
1831
|
+
* `withHeldFileLock` utility. The acquire loop, mtime heartbeat, stale-break
|
|
1832
|
+
* (NG7Bg replacement-safe), and ownership-checked release (NCzT6) all live in
|
|
1833
|
+
* ONE place — the util — so this module no longer re-implements them. The
|
|
1834
|
+
* catalog's `REBUILD_LOCK_*` constants and the `onBeforeBreakStaleUnlinkForTest`
|
|
1835
|
+
* seam flow straight through. The util generates a per-CALL owner uuid, which
|
|
1836
|
+
* is stricter than the previous per-instance `lockOwnerId` (two calls on the
|
|
1837
|
+
* same instance get different ids, so neither mistakes the other's lock as
|
|
1838
|
+
* self-held — the NBsGP invariant, preserved and tightened).
|
|
1833
1839
|
*
|
|
1834
1840
|
* IN-PROCESS SAFETY: every caller invokes this from inside (or wrapping) the
|
|
1835
1841
|
* per-process `queueCritical` chain, which serializes all catalog mutations in
|
|
1836
|
-
* THIS process. So within one process only one
|
|
1837
|
-
* acquisition at a time — the file lock is never
|
|
1838
|
-
* the lock is acquired and released within a
|
|
1839
|
-
* lock adds only the missing CROSS-process
|
|
1840
|
-
*
|
|
1841
|
-
* HEARTBEAT (round 5, cursor/codex Medium/P2): while WE hold the lock a timer
|
|
1842
|
-
* refreshes its mtime every `REBUILD_LOCK_HEARTBEAT_MS`, so a legitimately long
|
|
1843
|
-
* holder (> `REBUILD_LOCK_STALE_MS`) is not treated as a crashed holder and
|
|
1844
|
-
* unlinked by another process — which would let overlapping windows lose
|
|
1845
|
-
* appends. Heartbeat failures are swallowed; the timer is always cleared in
|
|
1846
|
-
* `finally`.
|
|
1842
|
+
* THIS process (now via `MutationSerializer`). So within one process only one
|
|
1843
|
+
* logical holder attempts OS-lock acquisition at a time — the file lock is never
|
|
1844
|
+
* self-contended in-process, and the lock is acquired and released within a
|
|
1845
|
+
* single in-process turn. The file lock adds only the missing CROSS-process
|
|
1846
|
+
* exclusion.
|
|
1847
1847
|
*
|
|
1848
1848
|
* ACQUISITION RESULT (round 6, codex P2 — NBPmY): `fn` receives whether WE
|
|
1849
1849
|
* actually hold the lock. When acquisition TIMED OUT (another holder is active),
|
|
@@ -1852,152 +1852,20 @@ export class NamespaceCatalog {
|
|
|
1852
1852
|
* caller uses `acquired` to run compute-only (rebuild) or DROP the append
|
|
1853
1853
|
* (touch) when unlocked.
|
|
1854
1854
|
*/
|
|
1855
|
-
private
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
} finally {
|
|
1870
|
-
if (heartbeat) clearInterval(heartbeat);
|
|
1871
|
-
if (acquired) {
|
|
1872
|
-
try {
|
|
1873
|
-
// Release ONLY the lock still owned by THIS instance (round 6, codex
|
|
1874
|
-
// P2 — NCzT6). If this rebuild paused long enough that another process
|
|
1875
|
-
// treated our lock as stale, unlinked it, and acquired a REPLACEMENT,
|
|
1876
|
-
// an unconditional unlink here would delete that other holder's active
|
|
1877
|
-
// lock — letting writers/another rebuild proceed during its load/rename
|
|
1878
|
-
// window and recreating the lost-append race. Verify ownership first.
|
|
1879
|
-
if (await this.rebuildLockHeldBySelf()) {
|
|
1880
|
-
await unlink(this.rebuildLockPath);
|
|
1881
|
-
}
|
|
1882
|
-
} catch {
|
|
1883
|
-
// Best-effort release; a stale lock will be broken on next rebuild.
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
/** Try to acquire the rebuild lock; returns true if WE created it. */
|
|
1890
|
-
private async acquireRebuildLock(): Promise<boolean> {
|
|
1891
|
-
const deadline = Date.now() + REBUILD_LOCK_MAX_WAIT_MS;
|
|
1892
|
-
await mkdir(this.stateDir, { recursive: true });
|
|
1893
|
-
for (;;) {
|
|
1894
|
-
try {
|
|
1895
|
-
const handle = await open(this.rebuildLockPath, "wx");
|
|
1896
|
-
try {
|
|
1897
|
-
// Record PID, this instance's owner id, and a timestamp. The owner id
|
|
1898
|
-
// distinguishes same-process instances (NBsGP).
|
|
1899
|
-
await handle.writeFile(
|
|
1900
|
-
`${process.pid} ${this.lockOwnerId} ${new Date().toISOString()}\n`,
|
|
1901
|
-
"utf8",
|
|
1902
|
-
);
|
|
1903
|
-
} catch {
|
|
1904
|
-
// Ignore write failures — the exclusive create already gave us the lock.
|
|
1905
|
-
} finally {
|
|
1906
|
-
await handle.close();
|
|
1907
|
-
}
|
|
1908
|
-
return true;
|
|
1909
|
-
} catch (err) {
|
|
1910
|
-
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
|
|
1911
|
-
// Unexpected FS error — proceed best-effort without the lock.
|
|
1912
|
-
return false;
|
|
1913
|
-
}
|
|
1914
|
-
// Lock exists: break it if stale, otherwise wait briefly.
|
|
1915
|
-
await this.breakStaleRebuildLock();
|
|
1916
|
-
if (Date.now() >= deadline) return false;
|
|
1917
|
-
await new Promise((r) => setTimeout(r, REBUILD_LOCK_POLL_MS));
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
/**
|
|
1923
|
-
* Remove the lock file if its mtime is older than the stale threshold.
|
|
1924
|
-
*
|
|
1925
|
-
* REPLACEMENT-SAFE (NG7Bg, codex P2): a plain `stat` → `unlink` has a TOCTOU
|
|
1926
|
-
* window — two processes can both observe the SAME stale lock; one removes it and
|
|
1927
|
-
* creates a FRESH lock, and the other's later `unlink` then deletes that fresh
|
|
1928
|
-
* holder's ACTIVE lock based on the stale identity it read earlier, leaving the
|
|
1929
|
-
* fresh holder running its critical section with no visible lock and reopening the
|
|
1930
|
-
* lost-update race the mutex prevents. We therefore capture the lock's IDENTITY
|
|
1931
|
-
* (its full content line: `<pid> <owner-uuid> <iso>`) when we judge it stale, then
|
|
1932
|
-
* RE-READ immediately before unlinking and only remove it when the content is
|
|
1933
|
-
* byte-identical AND still stale. A replacement lock has a different owner id /
|
|
1934
|
-
* timestamp, so its content differs and we leave it untouched. We never unlink a
|
|
1935
|
-
* lock whose mtime is now fresh (a heartbeat refreshed it) or whose identity
|
|
1936
|
-
* changed (a replacement was created). This is best-effort: any mismatch/vanish
|
|
1937
|
-
* simply skips the break and the caller polls again.
|
|
1938
|
-
*/
|
|
1939
|
-
private async breakStaleRebuildLock(): Promise<void> {
|
|
1940
|
-
let staleIdentity: string;
|
|
1941
|
-
try {
|
|
1942
|
-
const info = await stat(this.rebuildLockPath);
|
|
1943
|
-
if (Date.now() - info.mtimeMs <= REBUILD_LOCK_STALE_MS) {
|
|
1944
|
-
// Not stale (e.g. a live holder's heartbeat keeps it fresh) — leave it.
|
|
1945
|
-
return;
|
|
1946
|
-
}
|
|
1947
|
-
// Capture the exact identity we judged stale, so we can confirm it has not
|
|
1948
|
-
// been replaced before we unlink.
|
|
1949
|
-
staleIdentity = await readFile(this.rebuildLockPath, "utf8");
|
|
1950
|
-
} catch {
|
|
1951
|
-
// Lock vanished (released by holder) or stat/read failed — nothing to do.
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1954
|
-
// Test-only seam: simulate a replacement lock being created in the race window
|
|
1955
|
-
// between the staleness judgment and the unlink (NG7Bg). No-op in production.
|
|
1956
|
-
if (this.onBeforeBreakStaleUnlinkForTest) {
|
|
1957
|
-
await this.onBeforeBreakStaleUnlinkForTest();
|
|
1958
|
-
}
|
|
1959
|
-
try {
|
|
1960
|
-
// Re-validate immediately before unlinking: the lock must still carry the
|
|
1961
|
-
// SAME identity AND still be stale. If a replacement lock was created in the
|
|
1962
|
-
// window (different owner/timestamp) or a heartbeat refreshed the mtime, do
|
|
1963
|
-
// NOT unlink — that would delete another process's ACTIVE lock.
|
|
1964
|
-
const current = await readFile(this.rebuildLockPath, "utf8");
|
|
1965
|
-
if (current !== staleIdentity) return; // replaced — leave the fresh lock
|
|
1966
|
-
const recheck = await stat(this.rebuildLockPath);
|
|
1967
|
-
if (Date.now() - recheck.mtimeMs <= REBUILD_LOCK_STALE_MS) return; // refreshed
|
|
1968
|
-
await unlink(this.rebuildLockPath).catch(() => undefined);
|
|
1969
|
-
} catch {
|
|
1970
|
-
// The lock changed/vanished between checks — another process handled it.
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
/**
|
|
1975
|
-
* Whether the rebuild lock file was written by THIS instance (round 6, codex
|
|
1976
|
-
* P2 — NBsGP). Matches the per-instance owner id, NOT just `process.pid`: two
|
|
1977
|
-
* NamespaceCatalog instances in the same process share a PID, so a PID-only
|
|
1978
|
-
* check would wrongly treat instance A's lock as self-held by instance B and
|
|
1979
|
-
* let B's touch skip the wait and append into A's rebuild window. Falls back to
|
|
1980
|
-
* the legacy PID-only form for lock files written before owner ids existed.
|
|
1981
|
-
*/
|
|
1982
|
-
private async rebuildLockHeldBySelf(): Promise<boolean> {
|
|
1983
|
-
try {
|
|
1984
|
-
const body = await readFile(this.rebuildLockPath, "utf8");
|
|
1985
|
-
const parts = body.trim().split(/\s+/);
|
|
1986
|
-
const pid = Number.parseInt(parts[0] ?? "", 10);
|
|
1987
|
-
const ownerId = parts[1];
|
|
1988
|
-
// New format: "<pid> <uuid> <iso>". A UUID at parts[1] uniquely identifies
|
|
1989
|
-
// the writing INSTANCE; only the same instance is self. The strict UUID
|
|
1990
|
-
// shape avoids mistaking a legacy "<pid> <iso>" timestamp (also hyphenated)
|
|
1991
|
-
// for an owner id.
|
|
1992
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1993
|
-
if (ownerId && UUID_RE.test(ownerId)) {
|
|
1994
|
-
return ownerId === this.lockOwnerId;
|
|
1995
|
-
}
|
|
1996
|
-
// Legacy format: "<pid> <iso>" (no owner id). Best-effort PID match.
|
|
1997
|
-
return Number.isFinite(pid) && pid === process.pid;
|
|
1998
|
-
} catch {
|
|
1999
|
-
return false;
|
|
2000
|
-
}
|
|
1855
|
+
private withHeldCatalogLock<T>(fn: (acquired: boolean) => Promise<T>): Promise<T> {
|
|
1856
|
+
return withHeldFileLock(
|
|
1857
|
+
this.rebuildLockPath,
|
|
1858
|
+
{
|
|
1859
|
+
staleMs: REBUILD_LOCK_STALE_MS,
|
|
1860
|
+
maxWaitMs: REBUILD_LOCK_MAX_WAIT_MS,
|
|
1861
|
+
pollMs: REBUILD_LOCK_POLL_MS,
|
|
1862
|
+
heartbeatMs: REBUILD_LOCK_HEARTBEAT_MS,
|
|
1863
|
+
// NG7Bg seam: fires inside the util's breakStaleLock after it judges the
|
|
1864
|
+
// lock stale and captures its identity, before the atomic rename+verify.
|
|
1865
|
+
onBeforeBreakStaleUnlinkForTest: this.onBeforeBreakStaleUnlinkForTest,
|
|
1866
|
+
},
|
|
1867
|
+
fn,
|
|
1868
|
+
);
|
|
2001
1869
|
}
|
|
2002
1870
|
|
|
2003
1871
|
/**
|
|
@@ -2071,21 +1939,20 @@ export class NamespaceCatalog {
|
|
|
2071
1939
|
|
|
2072
1940
|
/**
|
|
2073
1941
|
* Serialize an arbitrary read-modify-write critical section through the single
|
|
2074
|
-
*
|
|
2075
|
-
* runs through this so they are mutually exclusive: a touch always
|
|
2076
|
-
* latest persisted state before appending, and a rebuild rewrite
|
|
2077
|
-
* interleave with a touch's append.
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
1942
|
+
* per-instance chain. Every catalog mutation (touch read+merge+append, full
|
|
1943
|
+
* rewrite) runs through this so they are mutually exclusive: a touch always
|
|
1944
|
+
* reads the latest persisted state before appending, and a rebuild rewrite
|
|
1945
|
+
* cannot interleave with a touch's append.
|
|
1946
|
+
*
|
|
1947
|
+
* Issue #1524 adoption: delegates to the shared `MutationSerializer` (stored
|
|
1948
|
+
* as `criticalSection`). The util owns the rejection-recovery invariant
|
|
1949
|
+
* (CLAUDE.md rule #40 — one failed section never poisons subsequent ones, but
|
|
1950
|
+
* the failing section's error still surfaces to ITS awaited promise) and the
|
|
1951
|
+
* no-unbounded-growth cleanup. The key is constant: the catalog has ONE
|
|
1952
|
+
* logical mutation queue (touches and rebuilds mutually exclude in-process).
|
|
2080
1953
|
*/
|
|
2081
1954
|
private queueCritical<T>(fn: () => Promise<T>): Promise<T> {
|
|
2082
|
-
|
|
2083
|
-
// Keep the chain alive after a rejection so later sections still run.
|
|
2084
|
-
this.writeChain = run.then(
|
|
2085
|
-
() => undefined,
|
|
2086
|
-
() => undefined,
|
|
2087
|
-
);
|
|
2088
|
-
return run;
|
|
1955
|
+
return this.criticalSection.serialize("catalog", fn);
|
|
2089
1956
|
}
|
|
2090
1957
|
|
|
2091
1958
|
/**
|