@remnic/core 10.0.65 → 10.0.67
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-admin-ops-surface.js +16 -16
- package/dist/access-authorization-probe.js +17 -17
- package/dist/access-boundary.js +16 -16
- package/dist/access-cli.js +61 -61
- package/dist/access-extraction-force-flush.js +16 -16
- package/dist/access-http-briefing.js +17 -17
- package/dist/access-http-query.js +18 -18
- package/dist/access-http.js +33 -33
- package/dist/access-identity-continuity-surface.js +16 -16
- package/dist/access-lcm-surface.js +16 -16
- package/dist/access-mcp.js +26 -26
- package/dist/access-namespace-preflight.js +16 -16
- package/dist/access-observe-write-surface.js +16 -16
- package/dist/access-operations-batch.js +17 -17
- package/dist/access-operations.js +24 -24
- package/dist/access-recall-concurrency.js +16 -16
- package/dist/access-recall-diagnostics.js +2 -2
- package/dist/access-recall-response.js +16 -16
- package/dist/access-recall-surface.js +16 -16
- package/dist/access-service.js +16 -16
- package/dist/access-surface-catalog.js +18 -18
- package/dist/active-recall.js +3 -3
- package/dist/briefing.js +3 -3
- package/dist/buffer.js +2 -2
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-KISOMGY2.js → chunk-3RHU5MD7.js} +2 -2
- package/dist/{chunk-3LSTL6EY.js → chunk-4MPAKYZU.js} +2 -2
- package/dist/{chunk-OVL5AD3N.js → chunk-4NV7O76Q.js} +2 -2
- package/dist/{chunk-BH7NVSEC.js → chunk-4SOWMA55.js} +3 -3
- package/dist/{chunk-GROHOWR5.js → chunk-5MGKGEL7.js} +2 -2
- package/dist/{chunk-ZPLQ27MY.js → chunk-5UM4H64E.js} +2 -2
- package/dist/{chunk-2DNFRLLL.js → chunk-5Y6VA6X2.js} +2 -2
- package/dist/{chunk-GRAUQXQB.js → chunk-6WKWL53V.js} +13 -13
- package/dist/{chunk-HOR4RMUM.js → chunk-75DCOS6I.js} +24 -24
- package/dist/{chunk-25QK3LR2.js → chunk-7O2MC42R.js} +2 -2
- package/dist/{chunk-7ETWIBTO.js → chunk-ADYP7HXE.js} +3 -3
- package/dist/{chunk-FUDKGJDN.js → chunk-AVGDYYMA.js} +2 -2
- package/dist/{chunk-ECF627KC.js → chunk-AYJQ644F.js} +2 -2
- package/dist/{chunk-T22FBDG3.js → chunk-B3MWELZ3.js} +2 -2
- package/dist/{chunk-DRTSPRWL.js → chunk-B4DXREWC.js} +2 -2
- package/dist/{chunk-5UEGL5BF.js → chunk-CI3MAAIY.js} +2 -2
- package/dist/{chunk-7JYK7NNY.js → chunk-CTH2CQCB.js} +2 -2
- package/dist/{chunk-UVHXLVFJ.js → chunk-EURPSF4Y.js} +2 -2
- package/dist/{chunk-VPEA63FC.js → chunk-EWJZZDRG.js} +2 -2
- package/dist/{chunk-BOZQAUAJ.js → chunk-EXQJMFRF.js} +2 -2
- package/dist/{chunk-UH6ROXFJ.js → chunk-GGD2YDS6.js} +2 -2
- package/dist/{chunk-5JABRT5G.js → chunk-GHHQLSSY.js} +3 -3
- package/dist/{chunk-N4T5FW6U.js → chunk-GJ4HU6LK.js} +2 -2
- package/dist/{chunk-HRRAOTOQ.js → chunk-H5DIRCZP.js} +12 -12
- package/dist/{chunk-434E4BTO.js → chunk-HH73M65H.js} +3 -3
- package/dist/{chunk-LIGZO7XN.js → chunk-HPB5TF55.js} +2 -2
- package/dist/{chunk-E4QIX2OB.js → chunk-IAGVM7EY.js} +2 -2
- package/dist/{chunk-YJQJRXUI.js → chunk-IG3EZ3TF.js} +2 -2
- package/dist/{chunk-KNMSD2MQ.js → chunk-IVUVQPTQ.js} +2 -2
- package/dist/{chunk-XOFIJGB2.js → chunk-IZBIDPMY.js} +2 -2
- package/dist/{chunk-HFEGRMFG.js → chunk-JLGBJ4AX.js} +7 -7
- package/dist/{chunk-BBHKN2CN.js → chunk-JLMOH5JA.js} +2 -2
- package/dist/{chunk-TPCRDPUY.js → chunk-KZSGBWOJ.js} +2 -2
- package/dist/{chunk-SHHZWSSE.js → chunk-KZZ5EZ7O.js} +2 -2
- package/dist/{chunk-2ZSRACNK.js → chunk-LFYEQK45.js} +682 -549
- package/dist/chunk-LFYEQK45.js.map +1 -0
- package/dist/{chunk-FKGLJJVS.js → chunk-LN4X2NDA.js} +2 -2
- package/dist/{chunk-5ZHFPUBC.js → chunk-MOIJWWNA.js} +8 -8
- package/dist/{chunk-KO224R36.js → chunk-NCZGGGWI.js} +2 -2
- package/dist/{chunk-XVQVF2TK.js → chunk-NHWDTOBN.js} +269 -230
- package/dist/chunk-NHWDTOBN.js.map +1 -0
- package/dist/{chunk-7ZTGMEXF.js → chunk-O5ZINWW3.js} +2 -2
- package/dist/{chunk-WWH7GJ42.js → chunk-OCK4D5PC.js} +2 -2
- package/dist/{chunk-G6L3REL3.js → chunk-OPFVXM2D.js} +2 -2
- package/dist/{chunk-FR7PK7ED.js → chunk-P4TV3TQ2.js} +2 -2
- package/dist/{chunk-P2EM5LKH.js → chunk-PMDOGXA2.js} +2 -2
- package/dist/{chunk-NM2II57L.js → chunk-PRKNAIGZ.js} +2 -1
- package/dist/chunk-PRKNAIGZ.js.map +1 -0
- package/dist/{chunk-MRF5V5CD.js → chunk-QGFR73SN.js} +2 -2
- package/dist/{chunk-6D3IWYNY.js → chunk-R2BIVU2Z.js} +354 -292
- package/dist/chunk-R2BIVU2Z.js.map +1 -0
- package/dist/{chunk-HG3CG557.js → chunk-RLVI2M5Q.js} +2 -2
- package/dist/{chunk-HHL4MX3W.js → chunk-SXTVZ6JL.js} +2 -2
- package/dist/{chunk-CLSKGTAT.js → chunk-TECK5IVY.js} +2 -2
- package/dist/{chunk-SNS3DWG6.js → chunk-TVE7W67N.js} +2 -2
- package/dist/{chunk-KYSSUY66.js → chunk-U2CAGMUD.js} +2 -2
- package/dist/{chunk-XLCEFUAU.js → chunk-UVVFPCPX.js} +2 -2
- package/dist/{chunk-KQJUEQ2E.js → chunk-UW6LBFYN.js} +5 -5
- package/dist/{chunk-SEA56G6S.js → chunk-V4XE263I.js} +1 -1
- package/dist/chunk-V4XE263I.js.map +1 -0
- package/dist/{chunk-JL5HXL25.js → chunk-V6SBA5O7.js} +3 -3
- package/dist/{chunk-R7WYAXPO.js → chunk-VHSQU3RO.js} +81 -42
- package/dist/chunk-VHSQU3RO.js.map +1 -0
- package/dist/{chunk-B7VD36TI.js → chunk-WHROVQ5Q.js} +3 -3
- package/dist/{chunk-LDHR465K.js → chunk-Y3XPSGNF.js} +2 -2
- package/dist/{chunk-JPY3RMG2.js → chunk-ZORS5BG5.js} +4 -4
- package/dist/{chunk-KXY4HW2Y.js → chunk-ZXF3O7P4.js} +2 -2
- package/dist/cli.js +47 -47
- package/dist/coding/export-okf-codegraph.js +2 -2
- package/dist/compounding/engine.js +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -3
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/index.js +3 -3
- package/dist/convergence-refresh.js +5 -5
- package/dist/corpus-watermark.js +5 -5
- package/dist/entity-retrieval.js +3 -3
- package/dist/external-wiki-access.js +17 -17
- package/dist/extraction.js +6 -6
- package/dist/harmonic-retrieval.js +3 -3
- package/dist/index.js +61 -61
- package/dist/location/index.js +5 -5
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/memory-browse-operations.js +17 -17
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/storage.js +3 -3
- package/dist/operator-doctor-corpus.js +6 -6
- package/dist/operator-toolkit.js +12 -12
- package/dist/orchestration/buffer-drain-runner.js +61 -61
- package/dist/orchestration/consolidation-run.d.ts.map +1 -1
- package/dist/orchestration/extraction-drain.d.ts +4 -4
- package/dist/orchestration/extraction-drain.d.ts.map +1 -1
- package/dist/orchestration/extraction-persist-deps.d.ts +0 -2
- package/dist/orchestration/extraction-persist-deps.d.ts.map +1 -1
- package/dist/orchestration/extraction-persist.d.ts +10 -4
- package/dist/orchestration/extraction-persist.d.ts.map +1 -1
- package/dist/orchestration/extraction-queue-coordinator.d.ts +67 -33
- package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
- package/dist/orchestration/extraction-run-helpers.d.ts +3 -2
- package/dist/orchestration/extraction-run-helpers.d.ts.map +1 -1
- package/dist/orchestration/extraction-run.d.ts +4 -14
- package/dist/orchestration/extraction-run.d.ts.map +1 -1
- package/dist/orchestration/fact-dedup-window.d.ts +49 -0
- package/dist/orchestration/fact-dedup-window.d.ts.map +1 -0
- package/dist/orchestration/maintenance.js +13 -13
- package/dist/orchestration/provider-breaker.d.ts +58 -0
- package/dist/orchestration/provider-breaker.d.ts.map +1 -0
- package/dist/orchestrator.d.ts +2 -17
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +61 -61
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-navigate-access.js +2 -2
- package/dist/recall-navigate-disclosure.js +2 -2
- package/dist/recall-navigate.js +2 -2
- package/dist/recall-navigation-operations.js +17 -17
- package/dist/recall-navigation.js +16 -16
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.js +2 -2
- package/dist/resume-bundles.js +4 -4
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/source-agent-qualifier.js +5 -5
- package/dist/standup/build.js +3 -3
- package/dist/storage/profile-mutation.d.ts +25 -0
- package/dist/storage/profile-mutation.d.ts.map +1 -0
- package/dist/storage.d.ts +4 -6
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2 -2
- package/dist/support-passport/index.js +19 -19
- package/dist/threading.d.ts +21 -4
- package/dist/threading.d.ts.map +1 -1
- package/dist/threading.js +1 -1
- package/dist/transfer/export-okf.js +3 -3
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/verified-recall.js +3 -3
- package/dist/who-knows-cli.js +4 -4
- package/dist/who-knows.js +3 -3
- package/package.json +2 -2
- package/src/access-observe-replay-durability.test.ts +1 -1
- package/src/buffer-drain-handover.test.ts +3 -3
- package/src/buffer-drop-extraction-chunk.test.ts +0 -1
- package/src/config.test.ts +12 -0
- package/src/config.ts +2 -0
- package/src/connector-provenance-lifecycle.test.ts +5 -8
- package/src/fact-dedup-window.test.ts +116 -0
- package/src/faithfulness-graph-guard.test.ts +11 -6
- package/src/harmonic-construction.test.ts +16 -10
- package/src/orchestration/consolidation-profile-race.test.ts +178 -0
- package/src/orchestration/consolidation-run.ts +19 -7
- package/src/orchestration/extraction-drain.ts +10 -3
- package/src/orchestration/extraction-persist-deps.ts +0 -2
- package/src/orchestration/extraction-persist.ts +159 -148
- package/src/orchestration/extraction-queue-coordinator.ts +167 -56
- package/src/orchestration/extraction-run-helpers.ts +5 -4
- package/src/orchestration/extraction-run.test.ts +1 -2
- package/src/orchestration/extraction-run.ts +56 -99
- package/src/orchestration/fact-dedup-window.ts +122 -0
- package/src/orchestration/provider-breaker.test.ts +89 -0
- package/src/orchestration/provider-breaker.ts +158 -0
- package/src/orchestration/turn-ingestion.ts +5 -5
- package/src/orchestrator-extraction-queue.test.ts +179 -8
- package/src/orchestrator.ts +7 -23
- package/src/storage/profile-mutation.ts +119 -0
- package/src/storage-append-profile.test.ts +114 -0
- package/src/storage.ts +17 -48
- package/src/testing/orchestrator-lite.ts +1 -1
- package/src/threading.test.ts +153 -0
- package/src/threading.ts +95 -45
- package/src/types.ts +2 -0
- package/dist/chunk-2ZSRACNK.js.map +0 -1
- package/dist/chunk-6D3IWYNY.js.map +0 -1
- package/dist/chunk-NM2II57L.js.map +0 -1
- package/dist/chunk-R7WYAXPO.js.map +0 -1
- package/dist/chunk-SEA56G6S.js.map +0 -1
- package/dist/chunk-XVQVF2TK.js.map +0 -1
- /package/dist/{chunk-KISOMGY2.js.map → chunk-3RHU5MD7.js.map} +0 -0
- /package/dist/{chunk-3LSTL6EY.js.map → chunk-4MPAKYZU.js.map} +0 -0
- /package/dist/{chunk-OVL5AD3N.js.map → chunk-4NV7O76Q.js.map} +0 -0
- /package/dist/{chunk-BH7NVSEC.js.map → chunk-4SOWMA55.js.map} +0 -0
- /package/dist/{chunk-GROHOWR5.js.map → chunk-5MGKGEL7.js.map} +0 -0
- /package/dist/{chunk-ZPLQ27MY.js.map → chunk-5UM4H64E.js.map} +0 -0
- /package/dist/{chunk-2DNFRLLL.js.map → chunk-5Y6VA6X2.js.map} +0 -0
- /package/dist/{chunk-GRAUQXQB.js.map → chunk-6WKWL53V.js.map} +0 -0
- /package/dist/{chunk-HOR4RMUM.js.map → chunk-75DCOS6I.js.map} +0 -0
- /package/dist/{chunk-25QK3LR2.js.map → chunk-7O2MC42R.js.map} +0 -0
- /package/dist/{chunk-7ETWIBTO.js.map → chunk-ADYP7HXE.js.map} +0 -0
- /package/dist/{chunk-FUDKGJDN.js.map → chunk-AVGDYYMA.js.map} +0 -0
- /package/dist/{chunk-ECF627KC.js.map → chunk-AYJQ644F.js.map} +0 -0
- /package/dist/{chunk-T22FBDG3.js.map → chunk-B3MWELZ3.js.map} +0 -0
- /package/dist/{chunk-DRTSPRWL.js.map → chunk-B4DXREWC.js.map} +0 -0
- /package/dist/{chunk-5UEGL5BF.js.map → chunk-CI3MAAIY.js.map} +0 -0
- /package/dist/{chunk-7JYK7NNY.js.map → chunk-CTH2CQCB.js.map} +0 -0
- /package/dist/{chunk-UVHXLVFJ.js.map → chunk-EURPSF4Y.js.map} +0 -0
- /package/dist/{chunk-VPEA63FC.js.map → chunk-EWJZZDRG.js.map} +0 -0
- /package/dist/{chunk-BOZQAUAJ.js.map → chunk-EXQJMFRF.js.map} +0 -0
- /package/dist/{chunk-UH6ROXFJ.js.map → chunk-GGD2YDS6.js.map} +0 -0
- /package/dist/{chunk-5JABRT5G.js.map → chunk-GHHQLSSY.js.map} +0 -0
- /package/dist/{chunk-N4T5FW6U.js.map → chunk-GJ4HU6LK.js.map} +0 -0
- /package/dist/{chunk-HRRAOTOQ.js.map → chunk-H5DIRCZP.js.map} +0 -0
- /package/dist/{chunk-434E4BTO.js.map → chunk-HH73M65H.js.map} +0 -0
- /package/dist/{chunk-LIGZO7XN.js.map → chunk-HPB5TF55.js.map} +0 -0
- /package/dist/{chunk-E4QIX2OB.js.map → chunk-IAGVM7EY.js.map} +0 -0
- /package/dist/{chunk-YJQJRXUI.js.map → chunk-IG3EZ3TF.js.map} +0 -0
- /package/dist/{chunk-KNMSD2MQ.js.map → chunk-IVUVQPTQ.js.map} +0 -0
- /package/dist/{chunk-XOFIJGB2.js.map → chunk-IZBIDPMY.js.map} +0 -0
- /package/dist/{chunk-HFEGRMFG.js.map → chunk-JLGBJ4AX.js.map} +0 -0
- /package/dist/{chunk-BBHKN2CN.js.map → chunk-JLMOH5JA.js.map} +0 -0
- /package/dist/{chunk-TPCRDPUY.js.map → chunk-KZSGBWOJ.js.map} +0 -0
- /package/dist/{chunk-SHHZWSSE.js.map → chunk-KZZ5EZ7O.js.map} +0 -0
- /package/dist/{chunk-FKGLJJVS.js.map → chunk-LN4X2NDA.js.map} +0 -0
- /package/dist/{chunk-5ZHFPUBC.js.map → chunk-MOIJWWNA.js.map} +0 -0
- /package/dist/{chunk-KO224R36.js.map → chunk-NCZGGGWI.js.map} +0 -0
- /package/dist/{chunk-7ZTGMEXF.js.map → chunk-O5ZINWW3.js.map} +0 -0
- /package/dist/{chunk-WWH7GJ42.js.map → chunk-OCK4D5PC.js.map} +0 -0
- /package/dist/{chunk-G6L3REL3.js.map → chunk-OPFVXM2D.js.map} +0 -0
- /package/dist/{chunk-FR7PK7ED.js.map → chunk-P4TV3TQ2.js.map} +0 -0
- /package/dist/{chunk-P2EM5LKH.js.map → chunk-PMDOGXA2.js.map} +0 -0
- /package/dist/{chunk-MRF5V5CD.js.map → chunk-QGFR73SN.js.map} +0 -0
- /package/dist/{chunk-HG3CG557.js.map → chunk-RLVI2M5Q.js.map} +0 -0
- /package/dist/{chunk-HHL4MX3W.js.map → chunk-SXTVZ6JL.js.map} +0 -0
- /package/dist/{chunk-CLSKGTAT.js.map → chunk-TECK5IVY.js.map} +0 -0
- /package/dist/{chunk-SNS3DWG6.js.map → chunk-TVE7W67N.js.map} +0 -0
- /package/dist/{chunk-KYSSUY66.js.map → chunk-U2CAGMUD.js.map} +0 -0
- /package/dist/{chunk-XLCEFUAU.js.map → chunk-UVVFPCPX.js.map} +0 -0
- /package/dist/{chunk-KQJUEQ2E.js.map → chunk-UW6LBFYN.js.map} +0 -0
- /package/dist/{chunk-JL5HXL25.js.map → chunk-V6SBA5O7.js.map} +0 -0
- /package/dist/{chunk-B7VD36TI.js.map → chunk-WHROVQ5Q.js.map} +0 -0
- /package/dist/{chunk-LDHR465K.js.map → chunk-Y3XPSGNF.js.map} +0 -0
- /package/dist/{chunk-JPY3RMG2.js.map → chunk-ZORS5BG5.js.map} +0 -0
- /package/dist/{chunk-KXY4HW2Y.js.map → chunk-ZXF3O7P4.js.map} +0 -0
package/src/orchestrator.ts
CHANGED
|
@@ -123,7 +123,7 @@ import {
|
|
|
123
123
|
type ExtractionResilienceStatus,
|
|
124
124
|
} from "./orchestration/extraction-run.js";
|
|
125
125
|
import { ConsolidationRunCoordinator } from "./orchestration/consolidation-run.js";
|
|
126
|
-
import { ExtractionPersistCoordinator } from "./orchestration/extraction-persist.js";
|
|
126
|
+
import { ExtractionPersistCoordinator, type ExtractionPersistResult } from "./orchestration/extraction-persist.js";
|
|
127
127
|
import type { ExtractionSourceContext } from "./orchestration/extraction-origin-context.js";
|
|
128
128
|
import { drainExtractionAndShutdownDependencyPropagation } from "./orchestration/extraction-shutdown.js";
|
|
129
129
|
import {
|
|
@@ -614,19 +614,6 @@ export class Orchestrator {
|
|
|
614
614
|
* (rule 11) — these hang off the orchestrator instance.
|
|
615
615
|
*/
|
|
616
616
|
private faithfulnessCounters: FaithfulnessGateCounters = createFaithfulnessCounters();
|
|
617
|
-
/**
|
|
618
|
-
* Side-channel: number of facts deferred in the most recent
|
|
619
|
-
* `persistExtraction` call (issue #562, PR 2). The caller reads this after
|
|
620
|
-
* `persistExtraction` returns to decide whether to retain buffer turns for
|
|
621
|
-
* the next extraction pass. Not part of the return signature because many
|
|
622
|
-
* callers already destructure `persistedIds` by position.
|
|
623
|
-
*/
|
|
624
|
-
private lastPersistExtractionDeferredCount: number = 0;
|
|
625
|
-
/**
|
|
626
|
-
* Side-channel (#1635): pending_review persisted ids from the last
|
|
627
|
-
* persistExtraction; runExtraction excludes them from the thread episode set.
|
|
628
|
-
*/
|
|
629
|
-
private lastPersistExtractionPendingReviewIds: string[] = [];
|
|
630
617
|
private readonly _fastGatewayLlm: FallbackLlmClient | null;
|
|
631
618
|
|
|
632
619
|
get fastGatewayLlm(): FallbackLlmClient | null {
|
|
@@ -785,11 +772,11 @@ export class Orchestrator {
|
|
|
785
772
|
getCodingContextForSession: (sessionKey) => this.getCodingContextForSession(sessionKey),
|
|
786
773
|
applyCodingNamespaceOverlay: (sessionKey, namespace) => this.applyCodingNamespaceOverlay(sessionKey, namespace),
|
|
787
774
|
boxBuilderFor: (storage) => this.boxBuilderFor(storage),
|
|
788
|
-
appendPersistedThreadEpisodes: (threadId, ids) =>
|
|
775
|
+
appendPersistedThreadEpisodes: (threadId, ids, pendingReviewIds) =>
|
|
776
|
+
this.appendPersistedThreadEpisodes(threadId, ids, pendingReviewIds),
|
|
789
777
|
maybeScheduleConsolidation: (nonZero) => this.maybeScheduleConsolidation(nonZero),
|
|
790
778
|
requestQmdMaintenance: () => this.requestQmdMaintenance(),
|
|
791
779
|
runTierMigrationCycle: (storage, trigger, options) => this.runTierMigrationCycle(storage, trigger, options),
|
|
792
|
-
getLastPersistExtractionDeferredCount: () => this.lastPersistExtractionDeferredCount,
|
|
793
780
|
recordProcessedExtractionFingerprint: (storage, fingerprint, preloadedMeta) =>
|
|
794
781
|
this.recordProcessedExtractionFingerprint(storage, fingerprint, preloadedMeta),
|
|
795
782
|
});
|
|
@@ -841,8 +828,6 @@ export class Orchestrator {
|
|
|
841
828
|
getJudgeDeferCounts: () => this.judgeDeferCounts,
|
|
842
829
|
getFaithfulnessCounters: () => this.faithfulnessCounters,
|
|
843
830
|
getEmbeddingFallback: () => this.embeddingFallback,
|
|
844
|
-
setLastPersistExtractionDeferredCount: (v) => { this.lastPersistExtractionDeferredCount = v; },
|
|
845
|
-
setLastPersistExtractionPendingReviewIds: (ids) => { this.lastPersistExtractionPendingReviewIds = ids; },
|
|
846
831
|
addContentHashDedup: (targetStorage, content) => this.addContentHashDedup(targetStorage, content),
|
|
847
832
|
hasContentHashDedup: (targetStorage, content) => this.hasContentHashDedup(targetStorage, content),
|
|
848
833
|
backfillTemporalBoundsOnDedupHit: (targetStorage, dedupContent, bounds, entityRef, sourceConnector) =>
|
|
@@ -1528,7 +1513,7 @@ export class Orchestrator {
|
|
|
1528
1513
|
this.storageRouter.onStorageCreated = bindStorage;
|
|
1529
1514
|
this.storageRouter.forEachCachedStorage(bindStorage);
|
|
1530
1515
|
// Issue #1526: background extraction queue lives on its own coordinator.
|
|
1531
|
-
this.extractionQueueCoordinator = new ExtractionQueueCoordinator();
|
|
1516
|
+
this.extractionQueueCoordinator = new ExtractionQueueCoordinator(config.extractionQueueConcurrency);
|
|
1532
1517
|
|
|
1533
1518
|
this.recallResultFormatter = new RecallResultFormatter(this.config);
|
|
1534
1519
|
const conversationIndexRuntime = createConversationIndexRuntime(config, {
|
|
@@ -3197,8 +3182,8 @@ export class Orchestrator {
|
|
|
3197
3182
|
sourceText?: string,
|
|
3198
3183
|
graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.config),
|
|
3199
3184
|
lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.config),
|
|
3200
|
-
): Promise<
|
|
3201
|
-
|
|
3185
|
+
): Promise<ExtractionPersistResult> {
|
|
3186
|
+
return await this.extractionPersistCoordinator.persistExtraction(
|
|
3202
3187
|
result,
|
|
3203
3188
|
storage,
|
|
3204
3189
|
threadIdForExtraction,
|
|
@@ -3209,7 +3194,6 @@ export class Orchestrator {
|
|
|
3209
3194
|
graphCaps,
|
|
3210
3195
|
lifecycleCaps,
|
|
3211
3196
|
);
|
|
3212
|
-
return { persistedIds, memoryPathById };
|
|
3213
3197
|
}
|
|
3214
3198
|
/**
|
|
3215
3199
|
* Append persisted ids to the thread episode set, excluding pending_review
|
|
@@ -3219,8 +3203,8 @@ export class Orchestrator {
|
|
|
3219
3203
|
private async appendPersistedThreadEpisodes(
|
|
3220
3204
|
threadId: string,
|
|
3221
3205
|
persistedIds: string[],
|
|
3206
|
+
pendingReviewIds: string[],
|
|
3222
3207
|
): Promise<void> {
|
|
3223
|
-
const pendingReviewIds = this.lastPersistExtractionPendingReviewIds ?? [];
|
|
3224
3208
|
const episodeIds =
|
|
3225
3209
|
pendingReviewIds.length > 0
|
|
3226
3210
|
? persistedIds.filter((id) => !pendingReviewIds.includes(id))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { serializeMutations, withHeldFileLock } from "../utils/serialize-mutations.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ports over the StorageManager surface the profile mutation domain needs.
|
|
6
|
+
* Kept as ports so this module stays out of the storage.ts god-file (issue
|
|
7
|
+
* #1533/#1995) while sharing ONE serialization domain per baseDir: the
|
|
8
|
+
* `profile:<baseDir>` key is the in-process FIFO and `profile.md.lock` is the
|
|
9
|
+
* cross-process gate, matching updateMetaSerialized's meta.json.lock shape
|
|
10
|
+
* (bounded, fail-closed BEFORE any read/write, no LLM inside).
|
|
11
|
+
*/
|
|
12
|
+
export interface ProfileMutationPorts {
|
|
13
|
+
readonly baseDir: string;
|
|
14
|
+
readProfile(): Promise<string>;
|
|
15
|
+
writeProfile(content: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ProfileCommitOutcome = "committed" | "conflict";
|
|
19
|
+
|
|
20
|
+
function normalizeForDedup(s: string): string {
|
|
21
|
+
return s
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a new bullet is a fuzzy duplicate of any existing bullet.
|
|
30
|
+
* Returns true if the new bullet should be skipped.
|
|
31
|
+
*/
|
|
32
|
+
function isFuzzyDuplicate(newNorm: string, existingNorms: string[]): boolean {
|
|
33
|
+
for (const existing of existingNorms) {
|
|
34
|
+
// Exact normalized match
|
|
35
|
+
if (newNorm === existing) return true;
|
|
36
|
+
|
|
37
|
+
// Containment check: shorter must be >60% length of longer
|
|
38
|
+
const shorter = newNorm.length <= existing.length ? newNorm : existing;
|
|
39
|
+
const longer = newNorm.length > existing.length ? newNorm : existing;
|
|
40
|
+
if (shorter.length > 20 && shorter.length / longer.length > 0.6 && longer.includes(shorter)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function appendToProfileUnlocked(ports: ProfileMutationPorts, updates: string[]): Promise<void> {
|
|
48
|
+
// Filter out empty entries the LLM may return (persist screens strings already).
|
|
49
|
+
updates = updates.filter((u) => u.trim().length > 0);
|
|
50
|
+
if (updates.length === 0) return;
|
|
51
|
+
const existing = await ports.readProfile();
|
|
52
|
+
|
|
53
|
+
const existingNorms = (existing ? existing.split("\n") : [])
|
|
54
|
+
.filter((l) => l.startsWith("- "))
|
|
55
|
+
.map((l) => normalizeForDedup(l.slice(2).trim()));
|
|
56
|
+
const newBullets = updates.filter((u) => {
|
|
57
|
+
const norm = normalizeForDedup(u);
|
|
58
|
+
return !isFuzzyDuplicate(norm, existingNorms);
|
|
59
|
+
});
|
|
60
|
+
if (newBullets.length === 0) return;
|
|
61
|
+
|
|
62
|
+
await ports.writeProfile(
|
|
63
|
+
existing
|
|
64
|
+
? existing.trimEnd() + "\n" + newBullets.map((b) => `- ${b}`).join("\n") + "\n"
|
|
65
|
+
: ["# Behavioral Profile", "", ...newBullets.map((b) => `- ${b}`), ""].join("\n"),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Cross-process profile mutation domain: fail-closed `profile.md.lock` for the
|
|
71
|
+
* brief mutation window only. Lock contention throws (`profile update skipped:
|
|
72
|
+
* profile.md.lock not acquired`) — never a silent unlocked RMW; callers treat
|
|
73
|
+
* it like a meta lock skip (retain and retry).
|
|
74
|
+
*/
|
|
75
|
+
async function withProfileMutationLock<T>(ports: ProfileMutationPorts, task: () => Promise<T>): Promise<T> {
|
|
76
|
+
return withHeldFileLock(
|
|
77
|
+
path.join(ports.baseDir, "profile.md.lock"),
|
|
78
|
+
// Bounded: a profile RMW is a short read-merge-write, never a scan.
|
|
79
|
+
{ staleMs: 5_000, maxWaitMs: 2_000, pollMs: 50 },
|
|
80
|
+
async (acquired) => {
|
|
81
|
+
if (!acquired) {
|
|
82
|
+
throw new Error(`profile update skipped: profile.md.lock not acquired (${ports.baseDir})`);
|
|
83
|
+
}
|
|
84
|
+
return task();
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function appendToProfileSerialized(ports: ProfileMutationPorts, updates: string[]): Promise<void> {
|
|
90
|
+
// Per-dir in-process chain, then the cross-process lock around the whole
|
|
91
|
+
// RMW: serialized, the second append re-reads after the first's write.
|
|
92
|
+
// Errors propagate: the extraction retry path retains the accepted turns.
|
|
93
|
+
await serializeMutations(`profile:${ports.baseDir}`, () =>
|
|
94
|
+
withProfileMutationLock(ports, () => appendToProfileUnlocked(ports, updates)),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Commit an LLM profile consolidation only if the profile is unchanged since
|
|
100
|
+
* `baseline` was read; when a concurrent append landed during the LLM call,
|
|
101
|
+
* return "conflict" and leave the file untouched (the append survives and the
|
|
102
|
+
* line cap re-triggers consolidation on fresh data next pass). The LLM call
|
|
103
|
+
* itself stays OUTSIDE this serialization domain; only this short
|
|
104
|
+
* read-compare-write is serialized + locked.
|
|
105
|
+
*/
|
|
106
|
+
export async function commitConsolidatedProfileSerialized(
|
|
107
|
+
ports: ProfileMutationPorts,
|
|
108
|
+
baseline: string,
|
|
109
|
+
consolidated: string,
|
|
110
|
+
): Promise<ProfileCommitOutcome> {
|
|
111
|
+
return serializeMutations(`profile:${ports.baseDir}`, () =>
|
|
112
|
+
withProfileMutationLock(ports, async () => {
|
|
113
|
+
const current = await ports.readProfile();
|
|
114
|
+
if (current !== baseline) return "conflict";
|
|
115
|
+
await ports.writeProfile(consolidated);
|
|
116
|
+
return "committed";
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { StorageManager } from "./storage.js";
|
|
7
|
+
import { withHeldFileLock } from "./utils/serialize-mutations.js";
|
|
8
|
+
|
|
9
|
+
async function makeStorage(): Promise<{ storage: StorageManager; cleanup: () => Promise<void> }> {
|
|
10
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "remnic-profile-append-"));
|
|
11
|
+
return { storage: new StorageManager(root), cleanup: () => rm(root, { recursive: true, force: true }) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("concurrent appendToProfile bullets both survive the shared profile read-modify-write", async () => {
|
|
15
|
+
const { storage, cleanup } = await makeStorage();
|
|
16
|
+
try {
|
|
17
|
+
// Hold the FIRST write open while the second append runs a full RMW. Under
|
|
18
|
+
// a shared unlocked RMW both reads see the same (missing) profile and the
|
|
19
|
+
// second write silently drops the first task's bullet.
|
|
20
|
+
const realWrite = storage.writeProfile.bind(storage);
|
|
21
|
+
const firstWriteEntered = Promise.withResolvers<void>();
|
|
22
|
+
const firstWriteGate = Promise.withResolvers<void>();
|
|
23
|
+
let writes = 0;
|
|
24
|
+
// SAFETY: the test drives only readProfile/writeProfile on the real manager;
|
|
25
|
+
// instance overrides keep the RMW interception local to this test.
|
|
26
|
+
storage.writeProfile = async (content: string) => {
|
|
27
|
+
writes += 1;
|
|
28
|
+
if (writes === 1) {
|
|
29
|
+
firstWriteEntered.resolve();
|
|
30
|
+
await firstWriteGate.promise;
|
|
31
|
+
}
|
|
32
|
+
await realWrite(content);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const first = storage.appendToProfile(["bullet-a"]);
|
|
36
|
+
await firstWriteEntered.promise;
|
|
37
|
+
const second = storage.appendToProfile(["bullet-b"]);
|
|
38
|
+
firstWriteGate.resolve();
|
|
39
|
+
await Promise.all([first, second]);
|
|
40
|
+
|
|
41
|
+
const profile = await storage.readProfile();
|
|
42
|
+
assert.match(profile ?? "", /bullet-a/, "first task's bullet must survive");
|
|
43
|
+
assert.match(profile ?? "", /bullet-b/, "second task's bullet must survive");
|
|
44
|
+
} finally {
|
|
45
|
+
await cleanup();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("appendToProfile fails closed when a cross-process peer holds profile.md.lock", async () => {
|
|
50
|
+
const { storage, cleanup } = await makeStorage();
|
|
51
|
+
const peer = new StorageManager(storage["baseDir"]);
|
|
52
|
+
try {
|
|
53
|
+
const holderEntered = Promise.withResolvers<void>();
|
|
54
|
+
const holderRelease = Promise.withResolvers<void>();
|
|
55
|
+
// Foreign holder (simulated external drain process) owns the lock file.
|
|
56
|
+
const holder = withHeldFileLock(
|
|
57
|
+
path.join(storage["baseDir"], "profile.md.lock"),
|
|
58
|
+
{ staleMs: 60_000 },
|
|
59
|
+
async (acquired) => {
|
|
60
|
+
assert.ok(acquired, "test holder must acquire the lock");
|
|
61
|
+
holderEntered.resolve();
|
|
62
|
+
await holderRelease.promise;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
await holderEntered.promise;
|
|
66
|
+
|
|
67
|
+
// The peer must NOT fall back to an unlocked RMW: fail closed, file untouched.
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
peer.appendToProfile(["lost bullet"]),
|
|
70
|
+
/profile\.md\.lock not acquired/,
|
|
71
|
+
);
|
|
72
|
+
const before = await storage.readProfile();
|
|
73
|
+
assert.doesNotMatch(before, /lost bullet/, "locked-out append must not write");
|
|
74
|
+
|
|
75
|
+
holderRelease.resolve();
|
|
76
|
+
await holder;
|
|
77
|
+
|
|
78
|
+
// Once the peer releases, the append goes through normally.
|
|
79
|
+
await peer.appendToProfile(["late bullet"]);
|
|
80
|
+
const after = await storage.readProfile();
|
|
81
|
+
assert.match(after, /late bullet/, "append after contention window must succeed");
|
|
82
|
+
} finally {
|
|
83
|
+
await cleanup();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("commitConsolidatedProfile commits unchanged baseline and conflicts on a stale one", async () => {
|
|
88
|
+
const { storage, cleanup } = await makeStorage();
|
|
89
|
+
try {
|
|
90
|
+
await storage.appendToProfile(["trait one", "trait two"]);
|
|
91
|
+
const baseline = await storage.readProfile();
|
|
92
|
+
|
|
93
|
+
// Unchanged profile → commit takes the consolidated rewrite.
|
|
94
|
+
const committed = await storage.commitConsolidatedProfile(
|
|
95
|
+
baseline,
|
|
96
|
+
"# Behavioral Profile\n\n- consolidated summary\n",
|
|
97
|
+
);
|
|
98
|
+
assert.equal(committed, "committed");
|
|
99
|
+
assert.match(await storage.readProfile(), /consolidated summary/);
|
|
100
|
+
|
|
101
|
+
// Stale baseline (profile moved on since the LLM saw it) → conflict, file untouched.
|
|
102
|
+
await storage.appendToProfile(["fresh concurrent trait"]);
|
|
103
|
+
const current = await storage.readProfile();
|
|
104
|
+
const conflict = await storage.commitConsolidatedProfile(
|
|
105
|
+
baseline,
|
|
106
|
+
"# Behavioral Profile\n\n- older stale rewrite\n",
|
|
107
|
+
);
|
|
108
|
+
assert.equal(conflict, "conflict");
|
|
109
|
+
assert.equal(await storage.readProfile(), current, "conflicting commit must not touch the file");
|
|
110
|
+
assert.match(await storage.readProfile(), /fresh concurrent trait/);
|
|
111
|
+
} finally {
|
|
112
|
+
await cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
package/src/storage.ts
CHANGED
|
@@ -28,13 +28,14 @@ import { createTombstoneMigrationSourceContents } from "./storage/tombstone-migr
|
|
|
28
28
|
import { MemoryReadStore, readWindowedMemories, type WindowedMemoryReadOptions, type WindowedMemoryReadResult } from "./storage/memory-read-store.js";
|
|
29
29
|
import { runCommittedInvalidation } from "./storage/committed-invalidation.js";
|
|
30
30
|
import { renderProfileWithLastUpdated, stripOkfProfileFrontmatter, withOkfProfileFrontmatter } from "./storage/profile-header.js";
|
|
31
|
+
import { appendToProfileSerialized, commitConsolidatedProfileSerialized, type ProfileMutationPorts } from "./storage/profile-mutation.js";
|
|
31
32
|
import { parseQuestionFile as parseQuestionFileText } from "./storage/questions-file.js";
|
|
32
33
|
import { assertNotOkfReservedBasename, OKF_QUESTION_TYPE, okfTypeForEntityKind, okfTypeForMemory } from "./okf/type-mapping.js";
|
|
33
34
|
import { appendContextTransformRecords, readContextTransformRecords } from "./storage/context-transform-ledger.js";
|
|
34
35
|
import type { ContextTransformTelemetryRecord } from "./active-context-transform.js";
|
|
35
36
|
import { readMaybeEncryptedLines, readMemoryActionEventRowsFromLines } from "./storage/secure-line-reader.js";
|
|
36
37
|
import { parseOfflineSyncDigestCache, type OfflineSyncDigestCacheEntry } from "./offline-sync-digest-cache.js";
|
|
37
|
-
import { updateMetaSerialized
|
|
38
|
+
import { updateMetaSerialized } from "./utils/serialize-mutations.js";
|
|
38
39
|
import {
|
|
39
40
|
appendLifecycleEventsSerialized,
|
|
40
41
|
type DrainPendingLifecycleForSyncResult,
|
|
@@ -4296,57 +4297,25 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
|
|
|
4296
4297
|
log.debug("updated profile.md");
|
|
4297
4298
|
}
|
|
4298
4299
|
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
return s
|
|
4302
|
-
.toLowerCase()
|
|
4303
|
-
.replace(/[^a-z0-9\s]/g, " ")
|
|
4304
|
-
.replace(/\s+/g, " ")
|
|
4305
|
-
.trim();
|
|
4300
|
+
async appendToProfile(updates: string[]): Promise<void> {
|
|
4301
|
+
await appendToProfileSerialized(this.profileMutationPorts(), updates);
|
|
4306
4302
|
}
|
|
4307
4303
|
|
|
4308
|
-
/**
|
|
4309
|
-
*
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
if (newNorm === existing) return true;
|
|
4316
|
-
|
|
4317
|
-
// Containment check: shorter must be >60% length of longer
|
|
4318
|
-
const shorter = newNorm.length <= existing.length ? newNorm : existing;
|
|
4319
|
-
const longer = newNorm.length > existing.length ? newNorm : existing;
|
|
4320
|
-
if (shorter.length > 20 && shorter.length / longer.length > 0.6 && longer.includes(shorter)) {
|
|
4321
|
-
return true;
|
|
4322
|
-
}
|
|
4323
|
-
}
|
|
4324
|
-
return false;
|
|
4304
|
+
/** Commit an LLM profile consolidation only if the profile is unchanged
|
|
4305
|
+
* since `baseline` was read; see profile-mutation.ts for the race domain. */
|
|
4306
|
+
async commitConsolidatedProfile(
|
|
4307
|
+
baseline: string,
|
|
4308
|
+
consolidated: string,
|
|
4309
|
+
): Promise<"committed" | "conflict"> {
|
|
4310
|
+
return commitConsolidatedProfileSerialized(this.profileMutationPorts(), baseline, consolidated);
|
|
4325
4311
|
}
|
|
4326
4312
|
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
const lines = existing ? existing.split("\n") : [];
|
|
4334
|
-
const existingBulletRaw = lines.filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim());
|
|
4335
|
-
const existingNorms = existingBulletRaw.map(StorageManager.normalizeForDedup);
|
|
4336
|
-
|
|
4337
|
-
const newBullets = updates.filter((u) => {
|
|
4338
|
-
const norm = StorageManager.normalizeForDedup(u);
|
|
4339
|
-
return !StorageManager.isFuzzyDuplicate(norm, existingNorms);
|
|
4340
|
-
});
|
|
4341
|
-
if (newBullets.length === 0) return;
|
|
4342
|
-
|
|
4343
|
-
if (!existing) {
|
|
4344
|
-
const content = ["# Behavioral Profile", "", ...newBullets.map((b) => `- ${b}`), ""].join("\n");
|
|
4345
|
-
await this.writeProfile(content);
|
|
4346
|
-
} else {
|
|
4347
|
-
const withBullets = existing.trimEnd() + "\n" + newBullets.map((b) => `- ${b}`).join("\n") + "\n";
|
|
4348
|
-
await this.writeProfile(withBullets);
|
|
4349
|
-
}
|
|
4313
|
+
private profileMutationPorts(): ProfileMutationPorts {
|
|
4314
|
+
return {
|
|
4315
|
+
baseDir: this.baseDir,
|
|
4316
|
+
readProfile: () => this.readProfile(),
|
|
4317
|
+
writeProfile: (content) => this.writeProfile(content),
|
|
4318
|
+
};
|
|
4350
4319
|
}
|
|
4351
4320
|
|
|
4352
4321
|
/** Check if profile.md exceeds the max line cap and needs LLM consolidation */
|
|
@@ -166,7 +166,7 @@ export function stubPersistExtraction(
|
|
|
166
166
|
const impl: PersistExtractionFn = async (...args) => {
|
|
167
167
|
calls.push(args);
|
|
168
168
|
const persistedIds = factory ? await factory(args, calls.length) : [];
|
|
169
|
-
return { persistedIds, memoryPathById: new Map() };
|
|
169
|
+
return { persistedIds, memoryPathById: new Map(), deferredCount: 0, pendingReviewIds: [] };
|
|
170
170
|
};
|
|
171
171
|
seam.persistExtraction = impl;
|
|
172
172
|
return calls;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { ThreadingManager } from "./threading.js";
|
|
7
|
+
import type { BufferTurn, ConversationThread } from "./types.js";
|
|
8
|
+
|
|
9
|
+
function turn(sessionKey: string, content: string): BufferTurn {
|
|
10
|
+
return { role: "user", content, timestamp: "2026-11-04T10:00:00.000Z", sessionKey };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function makeThreading(): Promise<{ threading: ThreadingManager; cleanup: () => Promise<void> }> {
|
|
14
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-threads-"));
|
|
15
|
+
return { threading: new ThreadingManager(dir), cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("the state being inserted is never evicted, even with every older session in flight", async () => {
|
|
19
|
+
const { threading, cleanup } = await makeThreading();
|
|
20
|
+
try {
|
|
21
|
+
const realSave = threading.saveThread.bind(threading);
|
|
22
|
+
const floodGate = Promise.withResolvers<void>();
|
|
23
|
+
const allEntered = Promise.withResolvers<void>();
|
|
24
|
+
let entered = 0;
|
|
25
|
+
threading.saveThread = async (thread: ConversationThread): Promise<void> => {
|
|
26
|
+
if (thread.sessionKey?.startsWith("flood-")) {
|
|
27
|
+
entered += 1;
|
|
28
|
+
if (entered === 512) allEntered.resolve();
|
|
29
|
+
await floodGate.promise;
|
|
30
|
+
}
|
|
31
|
+
await realSave(thread);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Park all 512 flood sessions inside saveThread so every one of their run
|
|
35
|
+
// states is in-flight while the next session inserts its own.
|
|
36
|
+
const floodRuns: Promise<ConversationThread["id"]>[] = [];
|
|
37
|
+
for (let i = 0; i < 512; i++) {
|
|
38
|
+
floodRuns.push(threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []));
|
|
39
|
+
}
|
|
40
|
+
await allEntered.promise;
|
|
41
|
+
|
|
42
|
+
// The new session's state is the map's last entry and not yet registered
|
|
43
|
+
// in-flight when runStateFor's eviction loop runs; eviction must skip it
|
|
44
|
+
// or this turn's state is orphaned and the follow-up turn forks a new
|
|
45
|
+
// thread (PRRT_kwDORJXyws6ztlO2).
|
|
46
|
+
const idN1 = await threading.processTurn(turn("session-new", "n1"), []);
|
|
47
|
+
const idN2 = await threading.processTurn(turn("session-new", "n2"), []);
|
|
48
|
+
assert.equal(idN2, idN1, "follow-up turn must append to the same thread");
|
|
49
|
+
|
|
50
|
+
floodGate.resolve();
|
|
51
|
+
await Promise.all(floodRuns);
|
|
52
|
+
} finally {
|
|
53
|
+
await cleanup();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a recently used session is not evicted FIFO when the cap overflows", async () => {
|
|
58
|
+
const { threading, cleanup } = await makeThreading();
|
|
59
|
+
try {
|
|
60
|
+
const threadA = await threading.processTurn(turn("session-a", "a1"), []);
|
|
61
|
+
// Fill the map to exactly the cap with inactive sessions (session-a is
|
|
62
|
+
// now the OLDEST entry).
|
|
63
|
+
for (let i = 0; i < 511; i++) {
|
|
64
|
+
await threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []);
|
|
65
|
+
}
|
|
66
|
+
// Touch session-a: under LRU this makes it the most recent entry; under
|
|
67
|
+
// FIFO it stays oldest and the next insertion evicts it.
|
|
68
|
+
await threading.processTurn(turn("session-a", "a2"), ["episode-a2"]);
|
|
69
|
+
await threading.processTurn(turn("flood-new", "the straw"), []);
|
|
70
|
+
|
|
71
|
+
const idA3 = await threading.processTurn(turn("session-a", "a3"), ["episode-a3"]);
|
|
72
|
+
assert.equal(idA3, threadA, "a just-used session must not be evicted before inactive ones");
|
|
73
|
+
assert.ok((await threading.loadThread(threadA))?.episodeIds.includes("episode-a2"));
|
|
74
|
+
} finally {
|
|
75
|
+
await cleanup();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("concurrent turns for two sessions never share thread run state", async () => {
|
|
80
|
+
const { threading, cleanup } = await makeThreading();
|
|
81
|
+
try {
|
|
82
|
+
const realSave = threading.saveThread.bind(threading);
|
|
83
|
+
const aSaveEntered = Promise.withResolvers<void>();
|
|
84
|
+
const aGate = Promise.withResolvers<void>();
|
|
85
|
+
let holdArmed = false;
|
|
86
|
+
// Hold session A's save open, and only let it resume after session B has
|
|
87
|
+
// run a full processTurn to completion through the shared manager — the
|
|
88
|
+
// exact interleaving a shared run-state slot corrupts.
|
|
89
|
+
threading.saveThread = async (thread: ConversationThread): Promise<void> => {
|
|
90
|
+
if (holdArmed && thread.sessionKey === "session-a") {
|
|
91
|
+
aSaveEntered.resolve();
|
|
92
|
+
await aGate.promise;
|
|
93
|
+
}
|
|
94
|
+
await realSave(thread);
|
|
95
|
+
};
|
|
96
|
+
holdArmed = true;
|
|
97
|
+
const pendingA = threading.processTurn(turn("session-a", "a1"), []);
|
|
98
|
+
await aSaveEntered.promise;
|
|
99
|
+
const idB = await threading.processTurn(turn("session-b", "b1"), []);
|
|
100
|
+
aGate.resolve();
|
|
101
|
+
const idA = await pendingA;
|
|
102
|
+
|
|
103
|
+
assert.notEqual(idA, idB);
|
|
104
|
+
assert.equal((await threading.loadThread(idA))?.sessionKey, "session-a");
|
|
105
|
+
assert.equal((await threading.loadThread(idB))?.sessionKey, "session-b");
|
|
106
|
+
|
|
107
|
+
// Each session's follow-up turn must append to its OWN thread. With the
|
|
108
|
+
// shared slot, A resumed last and left its session key in the global
|
|
109
|
+
// state, so B's next turn sees "session-a" and starts a fresh thread.
|
|
110
|
+
assert.equal(await threading.processTurn(turn("session-a", "a2"), []), idA);
|
|
111
|
+
assert.equal(await threading.processTurn(turn("session-b", "b2"), []), idB);
|
|
112
|
+
} finally {
|
|
113
|
+
await cleanup();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("an in-flight session survives a 512-session flood arriving mid-await", async () => {
|
|
118
|
+
const { threading, cleanup } = await makeThreading();
|
|
119
|
+
try {
|
|
120
|
+
const threadA = await threading.processTurn(turn("session-a", "a1"), []);
|
|
121
|
+
|
|
122
|
+
const realSave = threading.saveThread.bind(threading);
|
|
123
|
+
const gate = Promise.withResolvers<void>();
|
|
124
|
+
const a2SaveEntered = Promise.withResolvers<void>();
|
|
125
|
+
let floodArmed = false;
|
|
126
|
+
threading.saveThread = async (thread: ConversationThread): Promise<void> => {
|
|
127
|
+
if (floodArmed && thread.id === threadA) {
|
|
128
|
+
a2SaveEntered.resolve();
|
|
129
|
+
await gate.promise;
|
|
130
|
+
}
|
|
131
|
+
await realSave(thread);
|
|
132
|
+
};
|
|
133
|
+
floodArmed = true;
|
|
134
|
+
const secondTurn = threading.processTurn(turn("session-a", "a2"), ["episode-a2"]);
|
|
135
|
+
await a2SaveEntered.promise;
|
|
136
|
+
|
|
137
|
+
// Session A is parked inside saveThread. 512 other sessions insert their
|
|
138
|
+
// run state while it waits; eviction must not take an in-flight session's
|
|
139
|
+
// state, or A's thread id is lost and its next turn starts a new thread.
|
|
140
|
+
for (let i = 0; i < 512; i++) {
|
|
141
|
+
await threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []);
|
|
142
|
+
}
|
|
143
|
+
gate.resolve();
|
|
144
|
+
|
|
145
|
+
const idA2 = await secondTurn;
|
|
146
|
+
assert.equal(idA2, threadA, "resumed turn must still own its session's thread");
|
|
147
|
+
const idA3 = await threading.processTurn(turn("session-a", "a3"), ["episode-a3"]);
|
|
148
|
+
assert.equal(idA3, threadA);
|
|
149
|
+
assert.ok((await threading.loadThread(threadA))?.episodeIds.includes("episode-a2"));
|
|
150
|
+
} finally {
|
|
151
|
+
await cleanup();
|
|
152
|
+
}
|
|
153
|
+
});
|