@remnic/core 9.3.699 → 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 +5 -5
- package/dist/access-boundary.js +14 -10
- package/dist/access-cli.js +30 -28
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +5 -5
- package/dist/access-http.js +18 -14
- package/dist/access-mcp.d.ts +5 -5
- package/dist/access-mcp.js +17 -13
- package/dist/access-operations.d.ts +5 -5
- package/dist/access-operations.js +16 -12
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-DftqtNUy.d.ts → access-service-CGVWK6lZ.d.ts} +2 -2
- package/dist/access-service.d.ts +5 -5
- package/dist/access-service.js +13 -9
- package/dist/access-surface-catalog.d.ts +5 -5
- package/dist/action-confidence.d.ts +1 -1
- package/dist/action-confidence.js +2 -2
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +4 -4
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +7 -3
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +1 -1
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{catalog-D7YDNNF7.d.ts → catalog-DBIghceA.d.ts} +27 -53
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +8 -4
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/{chunk-E6GOVHHJ.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-3JJWNZTT.js +446 -0
- package/dist/chunk-3JJWNZTT.js.map +1 -0
- package/dist/{chunk-JYVD2XZ4.js → chunk-4HIAWLA2.js} +58 -25
- package/dist/chunk-4HIAWLA2.js.map +1 -0
- package/dist/{chunk-A62RAIBN.js → chunk-6W2D6FGG.js} +2 -2
- package/dist/{chunk-COEZR6F5.js → chunk-7TAQEPLE.js} +2 -2
- package/dist/{chunk-TIVZ4MCG.js → chunk-D75JXBV4.js} +3 -3
- package/dist/{chunk-YY2HGKWR.js → chunk-DEDQXIDL.js} +2 -2
- package/dist/{chunk-XFG3PVZE.js → chunk-FUCJAZ25.js} +8 -8
- package/dist/{chunk-EZWQZYLK.js → chunk-HF4N43Q7.js} +2 -2
- package/dist/{chunk-HZ5DRA4Q.js → chunk-HXHKLVAS.js} +20 -20
- package/dist/{chunk-AC5LO7IU.js → chunk-I4WBNM3M.js} +7 -1
- package/dist/chunk-I4WBNM3M.js.map +1 -0
- package/dist/{chunk-QVTSLRKT.js → chunk-IJEZMWKA.js} +3 -3
- package/dist/{chunk-ZXUOAFUG.js → chunk-IKNQAGBV.js} +1 -1
- package/dist/chunk-IKNQAGBV.js.map +1 -0
- package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
- package/dist/{chunk-2ULWWAQH.js → chunk-ISLJ5WIM.js} +2 -2
- package/dist/{chunk-2KJG6ZZK.js → chunk-IYOPIG3E.js} +2 -2
- package/dist/{chunk-M4DQWUKX.js → chunk-JKOKX3PS.js} +62 -163
- package/dist/chunk-JKOKX3PS.js.map +1 -0
- package/dist/{chunk-SJQ4HY3E.js → chunk-JO3E5VGS.js} +2 -2
- package/dist/{chunk-3LSSFCB4.js → chunk-K4DWSPMW.js} +11 -1
- package/dist/chunk-K4DWSPMW.js.map +1 -0
- package/dist/{chunk-IZ5E6ZTK.js → chunk-KF4TXW7Z.js} +6 -6
- package/dist/{chunk-CQ4PGFMC.js → chunk-KS7WQ4BZ.js} +4 -4
- package/dist/chunk-LTJAMRGI.js +286 -0
- package/dist/chunk-LTJAMRGI.js.map +1 -0
- package/dist/{chunk-PJIHCOGV.js → chunk-MNU5G4TK.js} +2 -2
- package/dist/{chunk-2K64VH66.js → chunk-ODTWHSY2.js} +69 -45
- package/dist/chunk-ODTWHSY2.js.map +1 -0
- package/dist/{chunk-LIWU4QI6.js → chunk-OLOYQZFB.js} +5 -5
- package/dist/{chunk-MRFVMSTR.js → chunk-OMNTQPZO.js} +20 -1
- package/dist/{chunk-MRFVMSTR.js.map → chunk-OMNTQPZO.js.map} +1 -1
- package/dist/{chunk-JI6HWBYL.js → chunk-Q4O3ET6F.js} +2 -2
- package/dist/{chunk-N55RJT4N.js → chunk-QP37KL5H.js} +2 -2
- package/dist/{chunk-LPIWEKZ3.js → chunk-SDPDU2PM.js} +3 -3
- package/dist/{chunk-RIC5U67B.js → chunk-SFMRLXIV.js} +232 -4
- package/dist/chunk-SFMRLXIV.js.map +1 -0
- package/dist/{chunk-RTN2BLZM.js → chunk-T5QAZIBO.js} +2 -2
- package/dist/{chunk-ANZLT74L.js → chunk-TFVVONWD.js} +2 -2
- package/dist/{chunk-SIDSEXUG.js → chunk-WFEZUGU5.js} +2 -2
- package/dist/{chunk-FSEQXHEZ.js → chunk-XTIRCSIH.js} +2 -2
- package/dist/{chunk-XL5RSHZP.js → chunk-YO4MBK3I.js} +2 -2
- package/dist/{chunk-AH2JUU6X.js → chunk-Z2YFLXHU.js} +2 -2
- package/dist/{chunk-MRX6S22R.js → chunk-ZT7B64BE.js} +2 -2
- package/dist/{chunk-UDDSC6PO.js → chunk-ZYNMX6IU.js} +54 -6
- package/dist/chunk-ZYNMX6IU.js.map +1 -0
- package/dist/{cli-DUMkkdLl.d.ts → cli-D3XeenwN.d.ts} +3 -3
- package/dist/cli.d.ts +6 -6
- package/dist/cli.js +32 -28
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +7 -3
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +7 -3
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +7 -3
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +1 -1
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +7 -3
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +6 -6
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/{forget-PLR6J5DN.js → forget-6SOIPUMQ.js} +32 -1
- package/dist/forget-6SOIPUMQ.js.map +1 -0
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +21 -10
- package/dist/index.js +65 -43
- package/dist/index.js.map +1 -1
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +7 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -3
- package/dist/maintenance/rebuild-memory-projection.js +8 -4
- package/dist/mcp-memory-inspector-app.d.ts +5 -5
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-provenance.js +1 -1
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +2 -2
- package/dist/namespaces/migrate.js +8 -4
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/storage.d.ts +32 -3
- package/dist/namespaces/storage.js +7 -3
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +19 -2
- package/dist/operator-toolkit.js +17 -11
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +9 -5
- package/dist/{orchestrator-Dv8JQu3-.d.ts → orchestrator-BzMCZlKn.d.ts} +3 -3
- package/dist/orchestrator.d.ts +4 -4
- package/dist/orchestrator.js +22 -20
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +4 -4
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +5 -5
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +4 -4
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +3 -3
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +1 -1
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/schemas.d.ts +28 -28
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/index.d.ts +1 -1
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-CgREi9E0.d.ts → semantic-consolidation-BtUfv-AL.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +8 -4
- package/dist/semantic-rule-promotion.js +7 -3
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +7 -3
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/storage.d.ts +306 -2
- package/dist/storage.js +6 -2
- package/dist/summarizer.d.ts +1 -1
- package/dist/summarizer.js +3 -2
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/summary-snapshot.js +2 -1
- package/dist/temporal-supersession.d.ts +1 -1
- package/dist/temporal-supersession.js +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/{types-CeZYSC-E.d.ts → types-PuiPZ9iE.d.ts} +34 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/utils/serialize-mutations.js +5 -280
- package/dist/utils/serialize-mutations.js.map +1 -1
- package/dist/verified-recall.js +7 -3
- package/package.json +2 -2
- package/src/config.ts +10 -0
- package/src/lifecycle/tombstones.test.ts +355 -0
- package/src/lifecycle/tombstones.ts +807 -0
- package/src/maintenance/forget.ts +41 -0
- package/src/maintenance/pattern-reinforcement.ts +47 -0
- package/src/memory-provenance.ts +12 -0
- package/src/namespaces/catalog.test.ts +222 -14
- package/src/namespaces/catalog.ts +54 -187
- package/src/namespaces/storage.ts +120 -80
- package/src/operator-toolkit.ts +77 -0
- package/src/orchestrator.ts +16 -0
- package/src/review/index.ts +48 -3
- package/src/storage.ts +329 -2
- package/src/summary-snapshot.test.ts +63 -1
- package/src/summary-snapshot.ts +61 -80
- package/src/temporal-supersession.ts +38 -0
- package/src/types.ts +34 -0
- package/src/utils/serialize-mutations.ts +10 -6
- package/dist/chunk-2K64VH66.js.map +0 -1
- package/dist/chunk-3LSSFCB4.js.map +0 -1
- package/dist/chunk-AC5LO7IU.js.map +0 -1
- package/dist/chunk-JYVD2XZ4.js.map +0 -1
- package/dist/chunk-M4DQWUKX.js.map +0 -1
- package/dist/chunk-RIC5U67B.js.map +0 -1
- package/dist/chunk-UDDSC6PO.js.map +0 -1
- package/dist/chunk-XJNBEDFE.js.map +0 -1
- package/dist/chunk-ZXUOAFUG.js.map +0 -1
- package/dist/forget-PLR6J5DN.js.map +0 -1
- /package/dist/{chunk-E6GOVHHJ.js.map → chunk-27LQPUMZ.js.map} +0 -0
- /package/dist/{chunk-A62RAIBN.js.map → chunk-6W2D6FGG.js.map} +0 -0
- /package/dist/{chunk-COEZR6F5.js.map → chunk-7TAQEPLE.js.map} +0 -0
- /package/dist/{chunk-TIVZ4MCG.js.map → chunk-D75JXBV4.js.map} +0 -0
- /package/dist/{chunk-YY2HGKWR.js.map → chunk-DEDQXIDL.js.map} +0 -0
- /package/dist/{chunk-XFG3PVZE.js.map → chunk-FUCJAZ25.js.map} +0 -0
- /package/dist/{chunk-EZWQZYLK.js.map → chunk-HF4N43Q7.js.map} +0 -0
- /package/dist/{chunk-HZ5DRA4Q.js.map → chunk-HXHKLVAS.js.map} +0 -0
- /package/dist/{chunk-QVTSLRKT.js.map → chunk-IJEZMWKA.js.map} +0 -0
- /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
- /package/dist/{chunk-2ULWWAQH.js.map → chunk-ISLJ5WIM.js.map} +0 -0
- /package/dist/{chunk-2KJG6ZZK.js.map → chunk-IYOPIG3E.js.map} +0 -0
- /package/dist/{chunk-SJQ4HY3E.js.map → chunk-JO3E5VGS.js.map} +0 -0
- /package/dist/{chunk-IZ5E6ZTK.js.map → chunk-KF4TXW7Z.js.map} +0 -0
- /package/dist/{chunk-CQ4PGFMC.js.map → chunk-KS7WQ4BZ.js.map} +0 -0
- /package/dist/{chunk-PJIHCOGV.js.map → chunk-MNU5G4TK.js.map} +0 -0
- /package/dist/{chunk-LIWU4QI6.js.map → chunk-OLOYQZFB.js.map} +0 -0
- /package/dist/{chunk-JI6HWBYL.js.map → chunk-Q4O3ET6F.js.map} +0 -0
- /package/dist/{chunk-N55RJT4N.js.map → chunk-QP37KL5H.js.map} +0 -0
- /package/dist/{chunk-LPIWEKZ3.js.map → chunk-SDPDU2PM.js.map} +0 -0
- /package/dist/{chunk-RTN2BLZM.js.map → chunk-T5QAZIBO.js.map} +0 -0
- /package/dist/{chunk-ANZLT74L.js.map → chunk-TFVVONWD.js.map} +0 -0
- /package/dist/{chunk-SIDSEXUG.js.map → chunk-WFEZUGU5.js.map} +0 -0
- /package/dist/{chunk-FSEQXHEZ.js.map → chunk-XTIRCSIH.js.map} +0 -0
- /package/dist/{chunk-XL5RSHZP.js.map → chunk-YO4MBK3I.js.map} +0 -0
- /package/dist/{chunk-AH2JUU6X.js.map → chunk-Z2YFLXHU.js.map} +0 -0
- /package/dist/{chunk-MRX6S22R.js.map → chunk-ZT7B64BE.js.map} +0 -0
|
@@ -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
|
/**
|
|
@@ -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
|
|
@@ -297,6 +299,9 @@ export class NamespaceStorageRouter {
|
|
|
297
299
|
// #1522: install the post-write catalog touch at the chokepoint — every
|
|
298
300
|
// successful write on this StorageManager records the namespace touch.
|
|
299
301
|
this.bindCatalogWriteHook(sm, ns);
|
|
302
|
+
// #1579: apply the tombstone non-resurrection config so every namespace
|
|
303
|
+
// storage enforces the invariant at its own writeMemory chokepoint.
|
|
304
|
+
this.applyTombstonesConfig(sm, ns);
|
|
300
305
|
this.cache.set(ns, sm);
|
|
301
306
|
this.notifyResolved(ns, root);
|
|
302
307
|
return sm;
|
|
@@ -305,77 +310,82 @@ export class NamespaceStorageRouter {
|
|
|
305
310
|
/**
|
|
306
311
|
* Fire the resolve hook defensively. A hook failure (e.g. a catalog write
|
|
307
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.
|
|
308
323
|
*/
|
|
309
324
|
private notifyResolved(namespace: string, storageDir: string): void {
|
|
310
325
|
const hook = this.hooks.onResolve;
|
|
311
326
|
if (!hook) return;
|
|
312
|
-
//
|
|
313
|
-
// storageDir)
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// 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).
|
|
319
333
|
if (this.notifiedResolved.get(namespace) === storageDir) return;
|
|
320
|
-
// In-flight dedup (
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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
|
-
this.
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (this.inFlightResolved.get(namespace) === storageDir) {
|
|
364
|
-
this.inFlightResolved.delete(namespace);
|
|
365
|
-
}
|
|
366
|
-
if (this.notifiedResolved.get(namespace) === storageDir) {
|
|
367
|
-
this.notifiedResolved.delete(namespace);
|
|
368
|
-
}
|
|
369
|
-
this.pendingResolveHooks.delete(hookResult);
|
|
370
|
-
},
|
|
371
|
-
);
|
|
372
|
-
} catch {
|
|
373
|
-
// Synchronous throw: clear any in-flight marker we just set and leave the
|
|
374
|
-
// pair un-notified so a later resolve retries.
|
|
375
|
-
if (this.inFlightResolved.get(namespace) === storageDir) {
|
|
376
|
-
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.
|
|
377
377
|
}
|
|
378
|
-
}
|
|
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);
|
|
379
389
|
}
|
|
380
390
|
|
|
381
391
|
/**
|
|
@@ -387,6 +397,36 @@ export class NamespaceStorageRouter {
|
|
|
387
397
|
sm.onCatalogWrite = () => this.touchCatalogWrite(namespace, sm.dir);
|
|
388
398
|
}
|
|
389
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Install the tombstone config on an externally-constructed StorageManager
|
|
402
|
+
* (issue #1579). Mirrors `bindCatalogWriteHook` — used for the legacy
|
|
403
|
+
* default-namespace storage that bypasses the router. Router-created
|
|
404
|
+
* storages are wired inline in `storageFor()` via `applyTombstonesConfig`.
|
|
405
|
+
*/
|
|
406
|
+
bindTombstonesConfig(
|
|
407
|
+
sm: StorageManager,
|
|
408
|
+
namespace: string,
|
|
409
|
+
config: { enabled: boolean; semanticMatch: boolean; semanticThreshold: number },
|
|
410
|
+
): void {
|
|
411
|
+
this.tombstonesGlobalConfig = { ...config };
|
|
412
|
+
sm.setTombstonesConfig({ ...config, namespace });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private tombstonesGlobalConfig: {
|
|
416
|
+
enabled: boolean;
|
|
417
|
+
semanticMatch: boolean;
|
|
418
|
+
semanticThreshold: number;
|
|
419
|
+
} = { enabled: false, semanticMatch: false, semanticThreshold: 0.9 };
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Apply the tombstone config to a router-created StorageManager. Called
|
|
423
|
+
* inline in `storageFor()` so every namespace storage enforces the
|
|
424
|
+
* non-resurrection invariant, namespace-scoped (rule 42).
|
|
425
|
+
*/
|
|
426
|
+
private applyTombstonesConfig(sm: StorageManager, namespace: string): void {
|
|
427
|
+
sm.setTombstonesConfig({ ...this.tombstonesGlobalConfig, namespace });
|
|
428
|
+
}
|
|
429
|
+
|
|
390
430
|
/**
|
|
391
431
|
* Post-write catalog touch (issue #1522 chokepoint). Called by every
|
|
392
432
|
* StorageManager's post-write hook AFTER a successful write. Best-effort
|
package/src/operator-toolkit.ts
CHANGED
|
@@ -1326,6 +1326,12 @@ export async function runOperatorDoctor(options: OperatorDoctorOptions): Promise
|
|
|
1326
1326
|
details: namespaceMaintenanceHealth,
|
|
1327
1327
|
});
|
|
1328
1328
|
|
|
1329
|
+
// Tombstone non-resurrection invariant (issue #1579). Reports active
|
|
1330
|
+
// tombstone count, last append, corrupted-line count, and rebuild
|
|
1331
|
+
// staleness so operators can verify the invariant is enforced and know
|
|
1332
|
+
// when a rebuild is warranted. Informational: never errors on its own.
|
|
1333
|
+
checks.push(await summarizeTombstoneStatus(options.orchestrator.storage));
|
|
1334
|
+
|
|
1329
1335
|
const summary = checks.reduce(
|
|
1330
1336
|
(acc, check) => {
|
|
1331
1337
|
acc[check.status] += 1;
|
|
@@ -1871,6 +1877,77 @@ export async function summarizeObservationThroughput(
|
|
|
1871
1877
|
}
|
|
1872
1878
|
}
|
|
1873
1879
|
|
|
1880
|
+
/**
|
|
1881
|
+
* Tombstone non-resurrection invariant status for `remnic doctor`
|
|
1882
|
+
* (issue #1579).
|
|
1883
|
+
*
|
|
1884
|
+
* Reports:
|
|
1885
|
+
* - active tombstone count (excluding revoked)
|
|
1886
|
+
* - revoked count (re-allowed via the review queue)
|
|
1887
|
+
* - last append timestamp
|
|
1888
|
+
* - corrupted-line count (skipped, not crashed — rule 34/18)
|
|
1889
|
+
* - rebuild staleness (whether the in-memory index loaded cleanly)
|
|
1890
|
+
*
|
|
1891
|
+
* Always informational: a disabled or empty tombstone store is the expected
|
|
1892
|
+
* cold-install state and is never an error. A non-zero corrupted-line count
|
|
1893
|
+
* surfaces as `warn` so operators know a rebuild (`remnic doctor
|
|
1894
|
+
* --rebuild-tombstones`) will repair the log.
|
|
1895
|
+
*/
|
|
1896
|
+
export async function summarizeTombstoneStatus(
|
|
1897
|
+
storage: StorageManager,
|
|
1898
|
+
): Promise<OperatorDoctorCheck> {
|
|
1899
|
+
try {
|
|
1900
|
+
const stats = await storage.getTombstoneStats();
|
|
1901
|
+
if (stats === null) {
|
|
1902
|
+
return {
|
|
1903
|
+
key: "tombstones",
|
|
1904
|
+
status: "ok",
|
|
1905
|
+
summary: "Tombstone non-resurrection invariant is disabled (tombstonesEnabled=false).",
|
|
1906
|
+
details: { enabled: false },
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
const corrupted = stats.corruptedLines > 0;
|
|
1910
|
+
const parts = [
|
|
1911
|
+
`${stats.count} active tombstone(s)`,
|
|
1912
|
+
...(stats.revoked > 0 ? [`${stats.revoked} revoked`] : []),
|
|
1913
|
+
];
|
|
1914
|
+
if (stats.lastAppendAt) {
|
|
1915
|
+
parts.push(`last append ${stats.lastAppendAt}`);
|
|
1916
|
+
}
|
|
1917
|
+
if (corrupted) {
|
|
1918
|
+
parts.push(`${stats.corruptedLines} corrupted line(s) skipped`);
|
|
1919
|
+
}
|
|
1920
|
+
return {
|
|
1921
|
+
key: "tombstones",
|
|
1922
|
+
status: corrupted ? "warn" : "ok",
|
|
1923
|
+
summary:
|
|
1924
|
+
`Tombstone invariant enabled. ${parts.join(", ")}. ` +
|
|
1925
|
+
(corrupted
|
|
1926
|
+
? "Run `remnic doctor --rebuild-tombstones` to repair the log."
|
|
1927
|
+
: "Re-observation of retired facts is blocked at the storage chokepoint."),
|
|
1928
|
+
remediation: corrupted
|
|
1929
|
+
? "Rebuild the tombstone log from retired memories on disk."
|
|
1930
|
+
: undefined,
|
|
1931
|
+
details: {
|
|
1932
|
+
enabled: true,
|
|
1933
|
+
count: stats.count,
|
|
1934
|
+
revoked: stats.revoked,
|
|
1935
|
+
lastAppendAt: stats.lastAppendAt,
|
|
1936
|
+
corruptedLines: stats.corruptedLines,
|
|
1937
|
+
loaded: stats.loaded,
|
|
1938
|
+
},
|
|
1939
|
+
};
|
|
1940
|
+
} catch (err) {
|
|
1941
|
+
return {
|
|
1942
|
+
key: "tombstones",
|
|
1943
|
+
status: "warn",
|
|
1944
|
+
summary: "Could not read tombstone store stats.",
|
|
1945
|
+
remediation: "Ensure the memory state directory is readable and rerun `remnic doctor`.",
|
|
1946
|
+
details: { error: String(err) },
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1874
1951
|
export async function summarizeConsolidationProvenance(
|
|
1875
1952
|
storage: StorageManager,
|
|
1876
1953
|
config: Pick<PluginConfig, "memoryDir"> & { versioningSidecarDir?: string },
|