@remnic/core 10.0.30 → 10.0.32
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 +47 -46
- package/dist/access-authorization-probe.js +48 -47
- package/dist/access-boundary.js +47 -46
- package/dist/access-cli.js +107 -107
- package/dist/access-extraction-force-flush.d.ts.map +1 -1
- package/dist/access-extraction-force-flush.js +47 -46
- package/dist/access-http-briefing.js +48 -47
- package/dist/access-http-query.js +49 -48
- package/dist/access-http.js +65 -64
- package/dist/access-identity-continuity-surface.js +47 -46
- package/dist/access-lcm-surface.js +47 -46
- package/dist/access-mcp.js +57 -56
- package/dist/access-namespace-preflight.js +47 -46
- package/dist/access-observe-write-surface.d.ts.map +1 -1
- package/dist/access-observe-write-surface.js +47 -46
- package/dist/access-offline-manifest.js +4 -4
- package/dist/access-operations-batch.js +48 -47
- package/dist/access-operations.js +55 -54
- package/dist/access-recall-concurrency.js +47 -46
- package/dist/access-recall-diagnostics.js +2 -2
- package/dist/access-recall-response.js +47 -46
- package/dist/access-recall-surface.js +47 -46
- package/dist/access-schema.d.ts +47 -47
- package/dist/access-service.js +47 -46
- package/dist/access-surface-catalog.js +49 -48
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/active-recall.js +20 -20
- package/dist/briefing.js +21 -21
- package/dist/buffer-turn-helpers.d.ts +20 -0
- package/dist/buffer-turn-helpers.d.ts.map +1 -1
- package/dist/buffer-turn-helpers.js +11 -3
- package/dist/buffer-turn.d.ts +50 -0
- package/dist/buffer-turn.d.ts.map +1 -0
- package/dist/buffer-turn.js +1 -0
- package/dist/buffer-turn.js.map +1 -0
- package/dist/buffer.d.ts +13 -1
- package/dist/buffer.d.ts.map +1 -1
- package/dist/buffer.js +4 -4
- package/dist/causal-consolidation.js +26 -26
- package/dist/{chunk-BIE3ZH3N.js → chunk-2IXEKMBJ.js} +143 -112
- package/dist/chunk-2IXEKMBJ.js.map +1 -0
- package/dist/{chunk-YGVZ4QOZ.js → chunk-3CRE3UZI.js} +2 -2
- package/dist/{chunk-YZTFQBYY.js → chunk-3GOPAWA7.js} +3 -3
- package/dist/{chunk-NAPHQ2MG.js → chunk-3IDNKOCE.js} +2 -2
- package/dist/{chunk-PYCTZ2HX.js → chunk-3VYNGM5A.js} +2 -2
- package/dist/{chunk-2XVLY424.js → chunk-42MM5DKK.js} +2 -2
- package/dist/{chunk-CKR7LXEK.js → chunk-464QR7WO.js} +255 -63
- package/dist/chunk-464QR7WO.js.map +1 -0
- package/dist/{chunk-3IXNL6SK.js → chunk-4ZTVOECF.js} +4 -4
- package/dist/{chunk-ETZUIRCI.js → chunk-53GYS3GG.js} +2 -2
- package/dist/{chunk-ZXTAHSQU.js → chunk-5GPHJ333.js} +2 -2
- package/dist/{chunk-AUYFUBBE.js → chunk-5HJCZ7SB.js} +4 -4
- package/dist/{chunk-52II7PB6.js → chunk-645WOU5I.js} +4 -4
- package/dist/{chunk-2WDMUQOH.js → chunk-64IO6ZEE.js} +2 -2
- package/dist/{chunk-OIIAIY7J.js → chunk-7SXUGYPE.js} +3 -3
- package/dist/{chunk-74IN6PV5.js → chunk-7WWITD3H.js} +4 -4
- package/dist/{chunk-7FZ4YVOH.js → chunk-A5ZY537U.js} +2 -2
- package/dist/{chunk-LDXNKEYB.js → chunk-A6PKEU7T.js} +1 -1
- package/dist/chunk-A6PKEU7T.js.map +1 -0
- package/dist/{chunk-VLBFSD7B.js → chunk-APRGEZQN.js} +2 -2
- package/dist/{chunk-XNROYP5E.js → chunk-AQ5XLA36.js} +2 -2
- package/dist/{chunk-K2REHR42.js → chunk-BDHYRMJC.js} +2 -2
- package/dist/{chunk-GQ7VWY6N.js → chunk-BQ6DGL6A.js} +2 -2
- package/dist/{chunk-4OE4UE2Y.js → chunk-D3EXT5KG.js} +2 -2
- package/dist/{chunk-MROA6OFU.js → chunk-DGBWPSM6.js} +2 -2
- package/dist/{chunk-NOMHXZD7.js → chunk-DNZ2CWPG.js} +2 -2
- package/dist/{chunk-HG2VVIKI.js → chunk-DZBMASSI.js} +12 -12
- package/dist/{chunk-NB2YX6X2.js → chunk-EXYDKAR6.js} +5 -5
- package/dist/{chunk-V4FI2TG6.js → chunk-F5FZ7DB4.js} +2 -2
- package/dist/{chunk-IY7DGT2Z.js → chunk-GDPTMK5V.js} +2 -2
- package/dist/{chunk-27OJ5G3D.js → chunk-GJBSEMC5.js} +7 -7
- package/dist/{chunk-MMBLOJJB.js → chunk-HMON55NJ.js} +85 -44
- package/dist/chunk-HMON55NJ.js.map +1 -0
- package/dist/{chunk-C2QVDLQV.js → chunk-HNTB7NIE.js} +2 -2
- package/dist/{chunk-4HNINU7Q.js → chunk-HSPEVXT5.js} +42 -42
- package/dist/{chunk-4QPW6JVI.js → chunk-ID2PLENW.js} +2 -2
- package/dist/{chunk-FN6OFDBZ.js → chunk-ID62FNER.js} +2 -2
- package/dist/{chunk-GSKW64NB.js → chunk-J6SZXAQ2.js} +2 -2
- package/dist/{chunk-3CHHQXAR.js → chunk-JTACUJP3.js} +2 -2
- package/dist/{chunk-3SN2D43X.js → chunk-K6RQ2KOX.js} +2 -2
- package/dist/{chunk-GWB7ZD2A.js → chunk-K6YJGWJP.js} +2 -2
- package/dist/{chunk-OANSZGAG.js → chunk-KFR343VH.js} +2 -2
- package/dist/{chunk-WC3V7HHP.js → chunk-KJZKO2M6.js} +2 -1
- package/dist/chunk-KJZKO2M6.js.map +1 -0
- package/dist/{chunk-T5IF2ZCE.js → chunk-KZMRMKC7.js} +81 -2
- package/dist/chunk-KZMRMKC7.js.map +1 -0
- package/dist/{chunk-NPO4ZQFY.js → chunk-LDXS746V.js} +3 -3
- package/dist/{chunk-UTXYOWDR.js → chunk-LGR54NYT.js} +3 -3
- package/dist/{chunk-UTXYOWDR.js.map → chunk-LGR54NYT.js.map} +1 -1
- package/dist/{chunk-DDLRKPR2.js → chunk-NKW27NU3.js} +2 -2
- package/dist/{chunk-4HYP4OKH.js → chunk-NU247WVX.js} +2 -2
- package/dist/{chunk-RRJ462HP.js → chunk-O6YJI5VX.js} +2 -2
- package/dist/{chunk-WYLBEJQI.js → chunk-OCWNT322.js} +4 -4
- package/dist/{chunk-UUYAW3JH.js → chunk-OIKLZCJD.js} +2 -2
- package/dist/{chunk-IO2UQVR2.js → chunk-OTWZXOJI.js} +2 -2
- package/dist/{chunk-OXYXMGRH.js → chunk-PGHYQMKZ.js} +11 -11
- package/dist/{chunk-LDEKVBVO.js → chunk-PZWW6MDE.js} +2 -2
- package/dist/{chunk-TFA6HPOA.js → chunk-QCP75JGO.js} +5 -5
- package/dist/{chunk-M236YHUH.js → chunk-RILIWZA6.js} +3 -3
- package/dist/{chunk-7VB3Y5OL.js → chunk-RRYN5FFC.js} +4 -4
- package/dist/{chunk-PZEADMX4.js → chunk-RU6WB55E.js} +9 -9
- package/dist/{chunk-VARNSLUW.js → chunk-SGII36L6.js} +2 -2
- package/dist/{chunk-KFKFK6G6.js → chunk-TB2J4PBY.js} +3 -3
- package/dist/{chunk-WQIYGYY6.js → chunk-TEUPXKC5.js} +2 -2
- package/dist/{chunk-VEB7NEON.js → chunk-U4BNX4R2.js} +3 -3
- package/dist/{chunk-7HFTCBCZ.js → chunk-UBBZFODO.js} +2 -2
- package/dist/{chunk-654GT5UP.js → chunk-UZDOPJ2P.js} +2 -2
- package/dist/{chunk-D4RV54HH.js → chunk-VAPVRDC4.js} +2 -2
- package/dist/{chunk-OXX4ORRL.js → chunk-W7X7XT7Z.js} +15 -15
- package/dist/{chunk-5NPZFXQH.js → chunk-WK7GFAQ2.js} +2 -2
- package/dist/{chunk-G6DAE2LT.js → chunk-WPCK6EJ2.js} +2 -2
- package/dist/{chunk-VKQI47DJ.js → chunk-Y7427SL7.js} +4 -4
- package/dist/{chunk-ZCRDYQL6.js → chunk-YGBWSKBD.js} +2 -2
- package/dist/{chunk-NCPR2FOJ.js → chunk-YXWLQHAW.js} +2 -2
- package/dist/{chunk-UM73Q46J.js → chunk-ZEMBRSKK.js} +2 -2
- package/dist/{chunk-TTIEXXL3.js → chunk-ZGP2XB4S.js} +16 -16
- package/dist/{chunk-UVUBLLM3.js → chunk-ZY4S5WMQ.js} +2 -2
- package/dist/cli.js +83 -82
- package/dist/coding/export-okf-codegraph.js +20 -20
- package/dist/compounding/engine.js +22 -22
- package/dist/config.js +20 -20
- package/dist/connectors/codex-materialize-runner.js +21 -21
- package/dist/connectors/index.js +21 -21
- package/dist/convergence-refresh.js +23 -23
- package/dist/conversation-index/backend.js +2 -2
- package/dist/corpus-watermark.js +23 -23
- package/dist/entity-retrieval.js +22 -22
- package/dist/event-order-recall.js +3 -3
- package/dist/external-wiki-access.js +48 -47
- package/dist/extraction.js +31 -31
- package/dist/harmonic-retrieval.js +21 -21
- package/dist/index.js +126 -126
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +2 -2
- package/dist/location/index.js +23 -23
- package/dist/maintenance/memory-governance.js +21 -21
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +21 -21
- package/dist/maintenance/rebuild-memory-projection-runner.js +23 -23
- package/dist/maintenance/rebuild-memory-projection.js +22 -22
- package/dist/memory-browse-operations.js +48 -47
- package/dist/namespaces/migrate.js +22 -22
- package/dist/namespaces/scope-profiles.d.ts +1 -0
- package/dist/namespaces/scope-profiles.d.ts.map +1 -1
- package/dist/namespaces/search.js +9 -9
- package/dist/namespaces/storage.js +21 -21
- package/dist/offline-sync-impression-drain.js +3 -3
- package/dist/operator-doctor-corpus.js +24 -24
- package/dist/operator-toolkit.js +32 -32
- package/dist/orchestration/maintenance.js +33 -33
- package/dist/orchestration/session-context.d.ts +6 -0
- package/dist/orchestration/session-context.d.ts.map +1 -1
- package/dist/orchestration/turn-ingestion.d.ts +35 -1
- package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
- package/dist/orchestrator.d.ts +2 -32
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +107 -107
- 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 +48 -47
- package/dist/recall-navigation.js +47 -46
- 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/relay/mission.d.ts +2 -2
- package/dist/resume-bundles.js +20 -20
- package/dist/schemas.d.ts +4 -4
- package/dist/search/factory.js +8 -8
- package/dist/search/index.js +13 -13
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/semantic-consolidation.js +22 -22
- package/dist/semantic-rule-promotion.js +21 -21
- package/dist/semantic-rule-verifier.js +21 -21
- package/dist/source-agent-qualifier.js +24 -24
- package/dist/standup/build.js +21 -21
- package/dist/storage.js +20 -20
- package/dist/support-passport/contracts.d.ts +6 -6
- package/dist/support-passport/grant-contracts.d.ts +4 -4
- package/dist/support-passport/index.js +50 -49
- package/dist/transfer/export-okf.js +21 -21
- package/dist/types.d.ts +3 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/verified-recall.js +21 -21
- package/dist/who-knows-cli.js +22 -22
- package/dist/who-knows.js +21 -21
- package/package.json +2 -2
- package/src/access-extraction-force-flush.ts +37 -25
- package/src/access-observe-acceptance-boundary.test.ts +172 -0
- package/src/access-observe-replay-durability.test.ts +2212 -0
- package/src/access-observe-write-surface.ts +36 -41
- package/src/access-service-observe-lcm-parity.test.ts +63 -39
- package/src/access-service-observe-scope.test.ts +3 -2
- package/src/buffer-turn-helpers.ts +120 -1
- package/src/buffer-turn.ts +50 -0
- package/src/buffer.ts +115 -49
- package/src/namespaces/scope-profiles.ts +1 -1
- package/src/orchestration/extraction-run.ts +1 -1
- package/src/orchestration/session-context.ts +117 -49
- package/src/orchestration/turn-ingestion.ts +313 -9
- package/src/orchestrator-flush.test.ts +16 -3
- package/src/orchestrator.ts +2 -32
- package/src/types.ts +3 -17
- package/dist/chunk-BIE3ZH3N.js.map +0 -1
- package/dist/chunk-CKR7LXEK.js.map +0 -1
- package/dist/chunk-LDXNKEYB.js.map +0 -1
- package/dist/chunk-MMBLOJJB.js.map +0 -1
- package/dist/chunk-T5IF2ZCE.js.map +0 -1
- package/dist/chunk-WC3V7HHP.js.map +0 -1
- /package/dist/{chunk-YGVZ4QOZ.js.map → chunk-3CRE3UZI.js.map} +0 -0
- /package/dist/{chunk-YZTFQBYY.js.map → chunk-3GOPAWA7.js.map} +0 -0
- /package/dist/{chunk-NAPHQ2MG.js.map → chunk-3IDNKOCE.js.map} +0 -0
- /package/dist/{chunk-PYCTZ2HX.js.map → chunk-3VYNGM5A.js.map} +0 -0
- /package/dist/{chunk-2XVLY424.js.map → chunk-42MM5DKK.js.map} +0 -0
- /package/dist/{chunk-3IXNL6SK.js.map → chunk-4ZTVOECF.js.map} +0 -0
- /package/dist/{chunk-ETZUIRCI.js.map → chunk-53GYS3GG.js.map} +0 -0
- /package/dist/{chunk-ZXTAHSQU.js.map → chunk-5GPHJ333.js.map} +0 -0
- /package/dist/{chunk-AUYFUBBE.js.map → chunk-5HJCZ7SB.js.map} +0 -0
- /package/dist/{chunk-52II7PB6.js.map → chunk-645WOU5I.js.map} +0 -0
- /package/dist/{chunk-2WDMUQOH.js.map → chunk-64IO6ZEE.js.map} +0 -0
- /package/dist/{chunk-OIIAIY7J.js.map → chunk-7SXUGYPE.js.map} +0 -0
- /package/dist/{chunk-74IN6PV5.js.map → chunk-7WWITD3H.js.map} +0 -0
- /package/dist/{chunk-7FZ4YVOH.js.map → chunk-A5ZY537U.js.map} +0 -0
- /package/dist/{chunk-VLBFSD7B.js.map → chunk-APRGEZQN.js.map} +0 -0
- /package/dist/{chunk-XNROYP5E.js.map → chunk-AQ5XLA36.js.map} +0 -0
- /package/dist/{chunk-K2REHR42.js.map → chunk-BDHYRMJC.js.map} +0 -0
- /package/dist/{chunk-GQ7VWY6N.js.map → chunk-BQ6DGL6A.js.map} +0 -0
- /package/dist/{chunk-4OE4UE2Y.js.map → chunk-D3EXT5KG.js.map} +0 -0
- /package/dist/{chunk-MROA6OFU.js.map → chunk-DGBWPSM6.js.map} +0 -0
- /package/dist/{chunk-NOMHXZD7.js.map → chunk-DNZ2CWPG.js.map} +0 -0
- /package/dist/{chunk-HG2VVIKI.js.map → chunk-DZBMASSI.js.map} +0 -0
- /package/dist/{chunk-NB2YX6X2.js.map → chunk-EXYDKAR6.js.map} +0 -0
- /package/dist/{chunk-V4FI2TG6.js.map → chunk-F5FZ7DB4.js.map} +0 -0
- /package/dist/{chunk-IY7DGT2Z.js.map → chunk-GDPTMK5V.js.map} +0 -0
- /package/dist/{chunk-27OJ5G3D.js.map → chunk-GJBSEMC5.js.map} +0 -0
- /package/dist/{chunk-C2QVDLQV.js.map → chunk-HNTB7NIE.js.map} +0 -0
- /package/dist/{chunk-4HNINU7Q.js.map → chunk-HSPEVXT5.js.map} +0 -0
- /package/dist/{chunk-4QPW6JVI.js.map → chunk-ID2PLENW.js.map} +0 -0
- /package/dist/{chunk-FN6OFDBZ.js.map → chunk-ID62FNER.js.map} +0 -0
- /package/dist/{chunk-GSKW64NB.js.map → chunk-J6SZXAQ2.js.map} +0 -0
- /package/dist/{chunk-3CHHQXAR.js.map → chunk-JTACUJP3.js.map} +0 -0
- /package/dist/{chunk-3SN2D43X.js.map → chunk-K6RQ2KOX.js.map} +0 -0
- /package/dist/{chunk-GWB7ZD2A.js.map → chunk-K6YJGWJP.js.map} +0 -0
- /package/dist/{chunk-OANSZGAG.js.map → chunk-KFR343VH.js.map} +0 -0
- /package/dist/{chunk-NPO4ZQFY.js.map → chunk-LDXS746V.js.map} +0 -0
- /package/dist/{chunk-DDLRKPR2.js.map → chunk-NKW27NU3.js.map} +0 -0
- /package/dist/{chunk-4HYP4OKH.js.map → chunk-NU247WVX.js.map} +0 -0
- /package/dist/{chunk-RRJ462HP.js.map → chunk-O6YJI5VX.js.map} +0 -0
- /package/dist/{chunk-WYLBEJQI.js.map → chunk-OCWNT322.js.map} +0 -0
- /package/dist/{chunk-UUYAW3JH.js.map → chunk-OIKLZCJD.js.map} +0 -0
- /package/dist/{chunk-IO2UQVR2.js.map → chunk-OTWZXOJI.js.map} +0 -0
- /package/dist/{chunk-OXYXMGRH.js.map → chunk-PGHYQMKZ.js.map} +0 -0
- /package/dist/{chunk-LDEKVBVO.js.map → chunk-PZWW6MDE.js.map} +0 -0
- /package/dist/{chunk-TFA6HPOA.js.map → chunk-QCP75JGO.js.map} +0 -0
- /package/dist/{chunk-M236YHUH.js.map → chunk-RILIWZA6.js.map} +0 -0
- /package/dist/{chunk-7VB3Y5OL.js.map → chunk-RRYN5FFC.js.map} +0 -0
- /package/dist/{chunk-PZEADMX4.js.map → chunk-RU6WB55E.js.map} +0 -0
- /package/dist/{chunk-VARNSLUW.js.map → chunk-SGII36L6.js.map} +0 -0
- /package/dist/{chunk-KFKFK6G6.js.map → chunk-TB2J4PBY.js.map} +0 -0
- /package/dist/{chunk-WQIYGYY6.js.map → chunk-TEUPXKC5.js.map} +0 -0
- /package/dist/{chunk-VEB7NEON.js.map → chunk-U4BNX4R2.js.map} +0 -0
- /package/dist/{chunk-7HFTCBCZ.js.map → chunk-UBBZFODO.js.map} +0 -0
- /package/dist/{chunk-654GT5UP.js.map → chunk-UZDOPJ2P.js.map} +0 -0
- /package/dist/{chunk-D4RV54HH.js.map → chunk-VAPVRDC4.js.map} +0 -0
- /package/dist/{chunk-OXX4ORRL.js.map → chunk-W7X7XT7Z.js.map} +0 -0
- /package/dist/{chunk-5NPZFXQH.js.map → chunk-WK7GFAQ2.js.map} +0 -0
- /package/dist/{chunk-G6DAE2LT.js.map → chunk-WPCK6EJ2.js.map} +0 -0
- /package/dist/{chunk-VKQI47DJ.js.map → chunk-Y7427SL7.js.map} +0 -0
- /package/dist/{chunk-ZCRDYQL6.js.map → chunk-YGBWSKBD.js.map} +0 -0
- /package/dist/{chunk-NCPR2FOJ.js.map → chunk-YXWLQHAW.js.map} +0 -0
- /package/dist/{chunk-UM73Q46J.js.map → chunk-ZEMBRSKK.js.map} +0 -0
- /package/dist/{chunk-TTIEXXL3.js.map → chunk-ZGP2XB4S.js.map} +0 -0
- /package/dist/{chunk-UVUBLLM3.js.map → chunk-ZY4S5WMQ.js.map} +0 -0
|
@@ -0,0 +1,2212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #3251 regressions: an ACCEPTED `observe` (202) must leave a durably
|
|
3
|
+
* pending capture. Pre-#3251, `observe` handed its turns to
|
|
4
|
+
* `TurnIngestionCoordinator.ingestReplayBatch`, which queued them directly
|
|
5
|
+
* into the serial extraction queue WITHOUT appending them to the SmartBuffer;
|
|
6
|
+
* `queueBufferedExtraction` settled such a queued task on abort or queue-wait
|
|
7
|
+
* deadline and the closure then no-opped, so the turns existed nowhere
|
|
8
|
+
* durable and a later `flushSession` returned empty (silent extraction loss).
|
|
9
|
+
*
|
|
10
|
+
* Every observe-path call below requests buffered ingestion explicitly
|
|
11
|
+
* (`extractionMode: "buffered"`, the API the observe surface invokes). The
|
|
12
|
+
* tests pin the whole acceptance contract: durable capture before the ack,
|
|
13
|
+
* bounded admission without eviction, scope isolation by namespace / owner /
|
|
14
|
+
* scope profile, identity metadata surviving restart, and background
|
|
15
|
+
* extraction that never delays or gates the acceptance.
|
|
16
|
+
*
|
|
17
|
+
* All tests drive the REAL orchestrator — real SmartBuffer, real serial
|
|
18
|
+
* queue, real `ingestReplayBatch` / `flushSession` / `flushPendingSave` —
|
|
19
|
+
* with only the LLM extraction client stubbed at the established field seam.
|
|
20
|
+
* The stub produces no claimed extraction output: the wedged first call is a
|
|
21
|
+
* gate the test controls, so every assertion targets capture durability, not
|
|
22
|
+
* extraction success.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import test from "node:test";
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
31
|
+
import { EngramAccessService } from "./access-service.js";
|
|
32
|
+
import { SmartBuffer } from "./buffer.js";
|
|
33
|
+
import { ExtractionDeadlineError } from "./orchestration/extraction-run.js";
|
|
34
|
+
import { runExtractionDeferRetention } from "./orchestration/extraction-run-helpers.js";
|
|
35
|
+
import { TurnIngestionCoordinator, type TurnIngestionDeps } from "./orchestration/turn-ingestion.js";
|
|
36
|
+
import { resolveScopeProfilePlan } from "./namespaces/scope-profiles.js";
|
|
37
|
+
import { resolveCodingNamespaceOverlay } from "./coding/coding-namespace.js";
|
|
38
|
+
import type { ResolvedScopeProfilePlan } from "./namespaces/scope-profiles.js";
|
|
39
|
+
import type { ReplayTurn } from "./replay/types.js";
|
|
40
|
+
import type { BufferState, BufferTurn, ExtractionResult, PluginConfig, ScopeProfileConfig } from "./types.js";
|
|
41
|
+
import { parseConfig } from "./config.js";
|
|
42
|
+
import type { StorageManager } from "./storage.js";
|
|
43
|
+
import {
|
|
44
|
+
cleanupDir,
|
|
45
|
+
eventually,
|
|
46
|
+
makeLifecycleConfig,
|
|
47
|
+
mkTempMemoryDir,
|
|
48
|
+
singleFactResult,
|
|
49
|
+
sleep,
|
|
50
|
+
stubExtraction,
|
|
51
|
+
} from "./testing/orchestrator-lite.js";
|
|
52
|
+
|
|
53
|
+
/** Inline storage for direct-SmartBuffer fixtures: durable in-memory, no disk. */
|
|
54
|
+
class SeedStorage {
|
|
55
|
+
async loadBuffer(): Promise<BufferState> {
|
|
56
|
+
return { turns: [], lastExtractionAt: null, extractionCount: 0 };
|
|
57
|
+
}
|
|
58
|
+
async saveBuffer(_state: BufferState): Promise<void> {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
function makeReplayTurn(sessionKey: string, content: string, sourceValidAt?: string): ReplayTurn {
|
|
63
|
+
return {
|
|
64
|
+
source: "openclaw",
|
|
65
|
+
sessionKey,
|
|
66
|
+
role: "user",
|
|
67
|
+
content,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
sourceValidAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Same shape the extraction-run retry suite uses to park a fingerprint (no invented classes). */
|
|
74
|
+
function providerRetryableFailure(): ExtractionResult {
|
|
75
|
+
return {
|
|
76
|
+
facts: [],
|
|
77
|
+
profileUpdates: [],
|
|
78
|
+
entities: [],
|
|
79
|
+
questions: [],
|
|
80
|
+
extractionFailure: "synthetic_provider_retryable",
|
|
81
|
+
extractionFailureClass: "provider_retryable",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The observe surface's buffered-ingestion request (#3251): `extractionMode:
|
|
87
|
+
* "buffered"` is the API the observe surface invokes explicitly. The wedge
|
|
88
|
+
* filler calls stay in the default immediate mode — they only occupy the
|
|
89
|
+
* queue.
|
|
90
|
+
*/
|
|
91
|
+
function bufferedObserveOptions(
|
|
92
|
+
options: {
|
|
93
|
+
abortSignal?: AbortSignal;
|
|
94
|
+
deadlineMs?: number;
|
|
95
|
+
writeNamespaceOverride?: string;
|
|
96
|
+
principalOverride?: string;
|
|
97
|
+
sessionOwnerPrincipal?: string;
|
|
98
|
+
scopeProfileWritePlan?: ResolvedScopeProfilePlan;
|
|
99
|
+
codingContext?: import("./types.js").CodingContext;
|
|
100
|
+
onExtractionQueued?: (extraction: Promise<void>) => void;
|
|
101
|
+
} = {},
|
|
102
|
+
): Parameters<Orchestrator["ingestReplayBatch"]>[1] {
|
|
103
|
+
return { archiveLcm: false, extractionMode: "buffered", ...options };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One trigger-queued background extraction observed by a test. */
|
|
107
|
+
interface QueuedExtractionSlot {
|
|
108
|
+
extraction?: Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Activate a minimal scope profile on a test config (native resolver reads it). */
|
|
112
|
+
function activateProfile(config: PluginConfig, profileId: string): void {
|
|
113
|
+
config.scopeProfiles = {
|
|
114
|
+
...(config.scopeProfiles ?? {}),
|
|
115
|
+
[profileId]: {
|
|
116
|
+
readOrder: ["userGlobal"],
|
|
117
|
+
writeDefault: "userGlobal",
|
|
118
|
+
promotionTargets: [],
|
|
119
|
+
autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
config.defaultScopeProfile = profileId;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Fill the serial extraction queue with a gated extraction; returns its idempotent release. */
|
|
126
|
+
async function wedgeExtractionQueue(orchestrator: Orchestrator): Promise<() => void> {
|
|
127
|
+
const { promise: gate, resolve: release } = Promise.withResolvers<void>();
|
|
128
|
+
const calls = stubExtraction(orchestrator, (_turns, call) =>
|
|
129
|
+
call === 1 ? gate.then(() => singleFactResult("wedge probe fact")) : singleFactResult("drain probe fact"),
|
|
130
|
+
);
|
|
131
|
+
void orchestrator
|
|
132
|
+
.ingestReplayBatch([makeReplayTurn("wedge-session", "queue wedge payload")], { archiveLcm: false })
|
|
133
|
+
.catch(() => {});
|
|
134
|
+
await eventually(async () => (calls.length >= 1 ? true : null), "wedge extraction started");
|
|
135
|
+
return release;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test("#3251 accepted observe capture survives queue cancellation and drains after restart", async () => {
|
|
139
|
+
const memoryDir = await mkTempMemoryDir("observe-durable");
|
|
140
|
+
const orchestrator = new Orchestrator(makeLifecycleConfig(memoryDir, { bufferMaxTurns: 1, bufferSaveDebounceMs: 0 }));
|
|
141
|
+
let restarted: Orchestrator | null = null;
|
|
142
|
+
let release: (() => void) | undefined;
|
|
143
|
+
try {
|
|
144
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
145
|
+
|
|
146
|
+
// The exact call the observe surface makes; the pending-observe tracker
|
|
147
|
+
// aborts this controller on lifecycle deadline or disconnect (#3251).
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
let backgroundExtraction: Promise<void> | undefined;
|
|
150
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
151
|
+
[makeReplayTurn("sess-a", "observe payload that must stay durable")],
|
|
152
|
+
bufferedObserveOptions({
|
|
153
|
+
abortSignal: controller.signal,
|
|
154
|
+
onExtractionQueued: (extraction) => {
|
|
155
|
+
// Mark handled early: the rejection itself is asserted below, and
|
|
156
|
+
// an unhandled rejection between abort and assert must not fail
|
|
157
|
+
// the run.
|
|
158
|
+
extraction.catch(() => {});
|
|
159
|
+
backgroundExtraction = extraction;
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
controller.abort(new Error("pending observe extraction deadline exceeded"));
|
|
164
|
+
|
|
165
|
+
// #3251 contract: the CAPTURE settles (accepted + durable) even when the
|
|
166
|
+
// tracker cancels; only the background extraction rejects.
|
|
167
|
+
await replay;
|
|
168
|
+
assert.ok(backgroundExtraction, "native trigger must queue background extraction");
|
|
169
|
+
await assert.rejects(backgroundExtraction);
|
|
170
|
+
|
|
171
|
+
// THE REGRESSION: the accepted capture must be durably pending in the
|
|
172
|
+
// extraction buffer, not tied to the settled queue task.
|
|
173
|
+
const bufferKeys = await orchestrator.buffer.findBufferKeysForSession("sess-a");
|
|
174
|
+
assert.ok(
|
|
175
|
+
bufferKeys.length > 0,
|
|
176
|
+
"accepted observe turns must remain buffered after the queued extraction was cancelled",
|
|
177
|
+
);
|
|
178
|
+
const pending = bufferKeys.flatMap((key) => orchestrator.buffer.getTurns(key));
|
|
179
|
+
assert.ok(
|
|
180
|
+
pending.some((turn) => turn.sessionKey === "sess-a" && turn.content.includes("observe payload")),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
release();
|
|
184
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
185
|
+
|
|
186
|
+
// Restart on the same memory dir: the capture must survive process death.
|
|
187
|
+
restarted = new Orchestrator(makeLifecycleConfig(memoryDir, { bufferMaxTurns: 1, bufferSaveDebounceMs: 0 }));
|
|
188
|
+
const drainCalls = stubExtraction(restarted, (turns) =>
|
|
189
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
190
|
+
);
|
|
191
|
+
await restarted.flushSession("sess-a", {
|
|
192
|
+
reason: "access_force_flush",
|
|
193
|
+
failOnExtractionFailure: true,
|
|
194
|
+
});
|
|
195
|
+
assert.ok(
|
|
196
|
+
drainCalls.some((batch) => batch.some((turn) => turn.content.includes("observe payload"))),
|
|
197
|
+
"force flush after restart must drain the accepted observe capture",
|
|
198
|
+
);
|
|
199
|
+
} finally {
|
|
200
|
+
// Idempotent: a failing assertion must never leave the gate unresolved
|
|
201
|
+
// into destroy(), or the harness reports a timeout instead of the failure.
|
|
202
|
+
release?.();
|
|
203
|
+
await restarted?.destroy().catch(() => undefined);
|
|
204
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
205
|
+
await cleanupDir(memoryDir);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("#3251 same session+owner observes under different namespaces stay isolated when durably pending", async () => {
|
|
210
|
+
const memoryDir = await mkTempMemoryDir("observe-scope-durable");
|
|
211
|
+
const makeConfig = () =>
|
|
212
|
+
makeLifecycleConfig(memoryDir, {
|
|
213
|
+
bufferMaxTurns: 1,
|
|
214
|
+
bufferSaveDebounceMs: 0,
|
|
215
|
+
namespacesEnabled: true,
|
|
216
|
+
defaultNamespace: "default",
|
|
217
|
+
sharedNamespace: "shared",
|
|
218
|
+
namespacePolicies: [
|
|
219
|
+
{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
|
|
220
|
+
{ name: "ns-b", readPrincipals: ["alice"], writePrincipals: ["alice"] },
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
224
|
+
let restarted: Orchestrator | null = null;
|
|
225
|
+
let release: (() => void) | undefined;
|
|
226
|
+
try {
|
|
227
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
228
|
+
|
|
229
|
+
// Two observes for the SAME sessionKey + SAME owner under DIFFERENT
|
|
230
|
+
// explicit namespaces — exactly the shapes the observe surface passes to
|
|
231
|
+
// ingestReplayBatch (writeNamespaceOverride / principalOverride /
|
|
232
|
+
// sessionOwnerPrincipal). One is dropped by tracker cancellation, the
|
|
233
|
+
// other by a queue-wait deadline.
|
|
234
|
+
const controllerA = new AbortController();
|
|
235
|
+
const backgroundA: QueuedExtractionSlot = {};
|
|
236
|
+
const backgroundB: QueuedExtractionSlot = {};
|
|
237
|
+
const trackBackground =
|
|
238
|
+
(slot: QueuedExtractionSlot) =>
|
|
239
|
+
(extraction: Promise<void>): void => {
|
|
240
|
+
// Mark handled early: rejections are asserted below.
|
|
241
|
+
extraction.catch(() => {});
|
|
242
|
+
slot.extraction = extraction;
|
|
243
|
+
};
|
|
244
|
+
const replayA = orchestrator.ingestReplayBatch(
|
|
245
|
+
[makeReplayTurn("proj-sess", "project A observation")],
|
|
246
|
+
bufferedObserveOptions({
|
|
247
|
+
abortSignal: controllerA.signal,
|
|
248
|
+
writeNamespaceOverride: "ns-a",
|
|
249
|
+
principalOverride: "alice",
|
|
250
|
+
sessionOwnerPrincipal: "alice",
|
|
251
|
+
onExtractionQueued: trackBackground(backgroundA),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
const replayB = orchestrator.ingestReplayBatch(
|
|
255
|
+
[makeReplayTurn("proj-sess", "project B observation")],
|
|
256
|
+
bufferedObserveOptions({
|
|
257
|
+
deadlineMs: Date.now() + 40,
|
|
258
|
+
writeNamespaceOverride: "ns-b",
|
|
259
|
+
principalOverride: "alice",
|
|
260
|
+
sessionOwnerPrincipal: "alice",
|
|
261
|
+
onExtractionQueued: trackBackground(backgroundB),
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
controllerA.abort(new Error("pending observe extraction cancelled"));
|
|
265
|
+
// #3251 contract: both captures settle durably; only the background
|
|
266
|
+
// extractions reject (abort for A, queue-wait deadline for B).
|
|
267
|
+
await Promise.all([replayA, replayB]);
|
|
268
|
+
assert.ok(backgroundA.extraction, "trigger must queue background extraction for A");
|
|
269
|
+
assert.ok(backgroundB.extraction, "trigger must queue background extraction for B");
|
|
270
|
+
await assert.rejects(backgroundA.extraction);
|
|
271
|
+
await assert.rejects(backgroundB.extraction, ExtractionDeadlineError);
|
|
272
|
+
|
|
273
|
+
const bufferKeys = await orchestrator.buffer.findBufferKeysForSession("proj-sess");
|
|
274
|
+
assert.ok(
|
|
275
|
+
bufferKeys.length > 0,
|
|
276
|
+
"both accepted observe captures must remain buffered after cancellation/deadline",
|
|
277
|
+
);
|
|
278
|
+
const pending = bufferKeys.flatMap((key) => orchestrator.buffer.getTurns(key));
|
|
279
|
+
assert.ok(pending.some((turn) => turn.content.includes("project A observation")));
|
|
280
|
+
assert.ok(pending.some((turn) => turn.content.includes("project B observation")));
|
|
281
|
+
|
|
282
|
+
release();
|
|
283
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
284
|
+
|
|
285
|
+
const second = new Orchestrator(makeConfig());
|
|
286
|
+
restarted = second;
|
|
287
|
+
const drainCalls = stubExtraction(second, (turns) =>
|
|
288
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
289
|
+
);
|
|
290
|
+
await second.flushSession("proj-sess", {
|
|
291
|
+
reason: "access_force_flush",
|
|
292
|
+
failOnExtractionFailure: true,
|
|
293
|
+
writeNamespaceOverride: "ns-a",
|
|
294
|
+
principalOverride: "alice",
|
|
295
|
+
});
|
|
296
|
+
const drainedUnderA = drainCalls.flat().map((turn) => turn.content);
|
|
297
|
+
assert.ok(
|
|
298
|
+
drainedUnderA.some((content) => content.includes("project A observation")),
|
|
299
|
+
"scoped ns-a flush must drain ns-a's capture",
|
|
300
|
+
);
|
|
301
|
+
assert.ok(
|
|
302
|
+
!drainedUnderA.some((content) => content.includes("project B observation")),
|
|
303
|
+
"scoped ns-a flush must NOT drain ns-b's pending capture (scope isolation)",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const stillPending = (await second.buffer.findBufferKeysForSession("proj-sess")).flatMap((key) =>
|
|
307
|
+
second.buffer.getTurns(key),
|
|
308
|
+
);
|
|
309
|
+
assert.ok(
|
|
310
|
+
stillPending.some((turn) => turn.content.includes("project B observation")),
|
|
311
|
+
"ns-b's capture must still be pending after the ns-a drain",
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
await second.flushSession("proj-sess", {
|
|
315
|
+
reason: "access_force_flush",
|
|
316
|
+
failOnExtractionFailure: true,
|
|
317
|
+
writeNamespaceOverride: "ns-b",
|
|
318
|
+
principalOverride: "alice",
|
|
319
|
+
});
|
|
320
|
+
const drainedUnderB = drainCalls.flat().map((turn) => turn.content);
|
|
321
|
+
assert.ok(
|
|
322
|
+
drainedUnderB.some((content) => content.includes("project B observation")),
|
|
323
|
+
"scoped ns-b flush must drain ns-b's capture",
|
|
324
|
+
);
|
|
325
|
+
} finally {
|
|
326
|
+
release?.();
|
|
327
|
+
await restarted?.destroy().catch(() => undefined);
|
|
328
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
329
|
+
await cleanupDir(memoryDir);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("#3251 buffered observe append is durable before accept: a failed save rejects instead of acking", async () => {
|
|
334
|
+
const memoryDir = await mkTempMemoryDir("observe-save-failure");
|
|
335
|
+
const orchestrator = new Orchestrator(makeLifecycleConfig(memoryDir, { bufferSaveDebounceMs: 0 }));
|
|
336
|
+
let release: (() => void) | undefined;
|
|
337
|
+
try {
|
|
338
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
339
|
+
stubExtraction(orchestrator, () => singleFactResult("save-failure probe fact"));
|
|
340
|
+
|
|
341
|
+
// Force every durable buffer write to fail (issue #1909 seam). The
|
|
342
|
+
// capture path must surface this to the caller — an observe whose save
|
|
343
|
+
// failed is NOT accepted and must reject, never ack 202 over non-durable
|
|
344
|
+
// turns.
|
|
345
|
+
const failingStorage = {
|
|
346
|
+
async loadBuffer(): Promise<BufferState> {
|
|
347
|
+
return { turns: [], lastExtractionAt: null, extractionCount: 0 };
|
|
348
|
+
},
|
|
349
|
+
async saveBuffer(_state: BufferState): Promise<void> {
|
|
350
|
+
throw new Error("simulated durable buffer save failure");
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
// SAFETY: `storage` is a private SmartBuffer field, structurally replaced
|
|
354
|
+
// in place to force durable-write failures. Named cast per rule.
|
|
355
|
+
const bufferSeam = orchestrator.buffer as any;
|
|
356
|
+
bufferSeam.storage = failingStorage;
|
|
357
|
+
|
|
358
|
+
// Bounded settle: a regression that queues instead of rejecting must fail
|
|
359
|
+
// the assertion fast, never hang the harness.
|
|
360
|
+
const settled = await Promise.race([
|
|
361
|
+
orchestrator
|
|
362
|
+
.ingestReplayBatch(
|
|
363
|
+
[makeReplayTurn("sess-save-fail", "observe payload over failing storage")],
|
|
364
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice" }),
|
|
365
|
+
)
|
|
366
|
+
.then(
|
|
367
|
+
() => null,
|
|
368
|
+
(err: Error) => err,
|
|
369
|
+
),
|
|
370
|
+
sleep(2000).then(() => "timeout" as const),
|
|
371
|
+
]);
|
|
372
|
+
assert.ok(settled !== "timeout", "buffered append must settle promptly, not hang acceptance");
|
|
373
|
+
assert.ok(settled instanceof Error, "a failed durable save must reject the capture, never ack it");
|
|
374
|
+
assert.match(settled.message, /simulated durable buffer save failure/);
|
|
375
|
+
} finally {
|
|
376
|
+
release?.();
|
|
377
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
378
|
+
await cleanupDir(memoryDir);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("#3251 durable observe admission is bounded: at the entry cap a new session is refused, not evicted", async () => {
|
|
383
|
+
// Real SmartBuffer + real coordinator, NO Orchestrator background actors —
|
|
384
|
+
// background eviction sweeps would make a 200-entry fixture nondeterministic.
|
|
385
|
+
class SeedStorage {
|
|
386
|
+
async loadBuffer(): Promise<BufferState> {
|
|
387
|
+
return { turns: [], lastExtractionAt: null, extractionCount: 0 };
|
|
388
|
+
}
|
|
389
|
+
async saveBuffer(_state: BufferState): Promise<void> {}
|
|
390
|
+
}
|
|
391
|
+
const config = parseConfig({
|
|
392
|
+
memoryDir: join(tmpdir(), "remnic-observe-capacity"),
|
|
393
|
+
bufferSaveDebounceMs: 0,
|
|
394
|
+
bufferMaxTurns: 1000,
|
|
395
|
+
extractionMinChars: 1_000_000,
|
|
396
|
+
extractionMinUserTurns: 1000,
|
|
397
|
+
});
|
|
398
|
+
const buffer = new SmartBuffer(
|
|
399
|
+
config,
|
|
400
|
+
// SAFETY: SeedStorage implements SmartBuffer's storage seam (loadBuffer/
|
|
401
|
+
// saveBuffer); the wider StorageManager surface is unused here.
|
|
402
|
+
new SeedStorage() as StorageManager,
|
|
403
|
+
);
|
|
404
|
+
const queued: BufferTurn[][] = [];
|
|
405
|
+
// SAFETY: capacity fixture exercises only buffered admission — the queue
|
|
406
|
+
// is recorded, never executed; the remaining deps are unreachable here.
|
|
407
|
+
const deps = { buffer, config } as any;
|
|
408
|
+
deps.queueBufferedExtraction = async (turns: BufferTurn[]) => {
|
|
409
|
+
queued.push(turns);
|
|
410
|
+
};
|
|
411
|
+
const coordinator = new TurnIngestionCoordinator(deps);
|
|
412
|
+
// (buffer.ts), each holding an accepted, durably pending capture. The
|
|
413
|
+
// seed turns are PINNED accepted captures — cap pressure must refuse new
|
|
414
|
+
// captures, never evict these (#3251 eviction guard; the pre-existing
|
|
415
|
+
// `default` mirror entry also occupies the entries map).
|
|
416
|
+
for (let index = 0; index < 200; index += 1) {
|
|
417
|
+
const key = `seed-${index}`;
|
|
418
|
+
await buffer.addTurn(key, {
|
|
419
|
+
role: "user",
|
|
420
|
+
content: `accepted seed capture ${index}`,
|
|
421
|
+
timestamp: new Date().toISOString(),
|
|
422
|
+
sessionKey: key,
|
|
423
|
+
sessionOwnerPrincipal: "alice",
|
|
424
|
+
acceptedCapture: true,
|
|
425
|
+
writeNamespaceOverride: "seed-store",
|
|
426
|
+
} satisfies BufferTurn);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A NEW observe session at the cap must be REFUSED before acceptance —
|
|
430
|
+
// bounded backpressure, not silent eviction of previously accepted
|
|
431
|
+
// pending captures. Bounded settle so a regression that queues instead
|
|
432
|
+
// of refusing fails fast instead of hanging the harness.
|
|
433
|
+
const settled = await Promise.race([
|
|
434
|
+
coordinator
|
|
435
|
+
.ingestReplayBatch(
|
|
436
|
+
[makeReplayTurn("fresh-observe-session", "late observe at capacity")],
|
|
437
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice" }),
|
|
438
|
+
)
|
|
439
|
+
.then(
|
|
440
|
+
() => null,
|
|
441
|
+
(err: Error) => err,
|
|
442
|
+
),
|
|
443
|
+
sleep(2000).then(() => "timeout" as const),
|
|
444
|
+
]);
|
|
445
|
+
assert.ok(settled !== "timeout", "at-cap observe must settle promptly");
|
|
446
|
+
assert.ok(settled instanceof Error, "at-cap observe must be refused, not accepted");
|
|
447
|
+
assert.match(
|
|
448
|
+
settled instanceof Error ? settled.message : "",
|
|
449
|
+
/capacity/,
|
|
450
|
+
);
|
|
451
|
+
assert.equal(queued.length, 0, "refused capture must not queue extraction");
|
|
452
|
+
|
|
453
|
+
const snapshot = await buffer.getBufferSnapshot();
|
|
454
|
+
assert.equal(snapshot.bufferedSessionCount, 200, "refusal must not evict accepted captures");
|
|
455
|
+
assert.ok(
|
|
456
|
+
buffer.getTurns("seed-0").some((turn) => turn.content.includes("accepted seed capture 0")),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// Appending to an ALREADY accepted session stays admitted (bounded by the
|
|
460
|
+
// same entry, no new eviction pressure).
|
|
461
|
+
await coordinator.ingestReplayBatch(
|
|
462
|
+
[makeReplayTurn("seed-0", "append to accepted session")],
|
|
463
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice" }),
|
|
464
|
+
);
|
|
465
|
+
assert.ok(
|
|
466
|
+
buffer.getTurns("seed-0").some((turn) => turn.content.includes("append to accepted session")),
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("#3251 same session+owner across scope profiles never combine; sourceValidAt identity survives restart", async () => {
|
|
471
|
+
const memoryDir = await mkTempMemoryDir("observe-profile-durable");
|
|
472
|
+
const makeConfig = () =>
|
|
473
|
+
makeLifecycleConfig(memoryDir, {
|
|
474
|
+
bufferMaxTurns: 1,
|
|
475
|
+
bufferSaveDebounceMs: 0,
|
|
476
|
+
namespacesEnabled: true,
|
|
477
|
+
defaultNamespace: "default",
|
|
478
|
+
sharedNamespace: "shared",
|
|
479
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
480
|
+
});
|
|
481
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
482
|
+
let restarted: Orchestrator | null = null;
|
|
483
|
+
let release: (() => void) | undefined;
|
|
484
|
+
try {
|
|
485
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
486
|
+
|
|
487
|
+
const sourceValidAtA = "2026-10-26T10:00:00.000Z";
|
|
488
|
+
|
|
489
|
+
// SAME raw session + owner + namespace; profiles switch BETWEEN captures,
|
|
490
|
+
// so each durable capture pins the profile that was active when accepted.
|
|
491
|
+
activateProfile(orchestrator.config, "profile-team");
|
|
492
|
+
const planP1 = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
493
|
+
assert.ok(planP1, "active profile must resolve");
|
|
494
|
+
const controllerP1 = new AbortController();
|
|
495
|
+
const backgroundP1: QueuedExtractionSlot = {};
|
|
496
|
+
const backgroundP2: QueuedExtractionSlot = {};
|
|
497
|
+
const trackBackground =
|
|
498
|
+
(slot: QueuedExtractionSlot) =>
|
|
499
|
+
(extraction: Promise<void>): void => {
|
|
500
|
+
extraction.catch(() => {});
|
|
501
|
+
slot.extraction = extraction;
|
|
502
|
+
};
|
|
503
|
+
const replayP1 = orchestrator.ingestReplayBatch(
|
|
504
|
+
[makeReplayTurn("proj-sess", "profile P1 observation", sourceValidAtA)],
|
|
505
|
+
bufferedObserveOptions({
|
|
506
|
+
abortSignal: controllerP1.signal,
|
|
507
|
+
writeNamespaceOverride: "ns-a",
|
|
508
|
+
principalOverride: "alice",
|
|
509
|
+
sessionOwnerPrincipal: "alice",
|
|
510
|
+
scopeProfileWritePlan: planP1,
|
|
511
|
+
onExtractionQueued: trackBackground(backgroundP1),
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
controllerP1.abort(new Error("pending observe extraction cancelled"));
|
|
515
|
+
|
|
516
|
+
activateProfile(orchestrator.config, "profile-personal");
|
|
517
|
+
const planP2 = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
518
|
+
assert.ok(planP2, "switched profile must resolve");
|
|
519
|
+
const replayP2 = orchestrator.ingestReplayBatch(
|
|
520
|
+
[makeReplayTurn("proj-sess", "profile P2 observation")],
|
|
521
|
+
bufferedObserveOptions({
|
|
522
|
+
deadlineMs: Date.now() + 40,
|
|
523
|
+
writeNamespaceOverride: "ns-a",
|
|
524
|
+
principalOverride: "alice",
|
|
525
|
+
sessionOwnerPrincipal: "alice",
|
|
526
|
+
scopeProfileWritePlan: planP2,
|
|
527
|
+
onExtractionQueued: trackBackground(backgroundP2),
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
// #3251 contract: both captures settle durably; only the background
|
|
531
|
+
// extractions reject (abort for P1, queue-wait deadline for P2).
|
|
532
|
+
await Promise.all([replayP1, replayP2]);
|
|
533
|
+
assert.ok(backgroundP1.extraction, "trigger must queue background extraction for P1");
|
|
534
|
+
assert.ok(backgroundP2.extraction, "trigger must queue background extraction for P2");
|
|
535
|
+
await assert.rejects(backgroundP1.extraction);
|
|
536
|
+
await assert.rejects(backgroundP2.extraction, ExtractionDeadlineError);
|
|
537
|
+
|
|
538
|
+
const bufferKeys = await orchestrator.buffer.findBufferKeysForSession("proj-sess");
|
|
539
|
+
const pending = bufferKeys.flatMap((key) => orchestrator.buffer.getTurns(key));
|
|
540
|
+
assert.ok(pending.some((turn) => turn.content.includes("profile P1 observation")));
|
|
541
|
+
assert.ok(pending.some((turn) => turn.content.includes("profile P2 observation")));
|
|
542
|
+
|
|
543
|
+
release();
|
|
544
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
545
|
+
|
|
546
|
+
const second = new Orchestrator(makeConfig());
|
|
547
|
+
restarted = second;
|
|
548
|
+
activateProfile(second.config, "profile-team");
|
|
549
|
+
const drainCalls = stubExtraction(second, (turns) =>
|
|
550
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// Metadata identity check on the durable capture after restart: the
|
|
554
|
+
// sourceValidAt anchor must round-trip so deferred extraction keeps its
|
|
555
|
+
// bi-temporal identity.
|
|
556
|
+
const restartedPending = (await second.buffer.findBufferKeysForSession("proj-sess")).flatMap((key) =>
|
|
557
|
+
second.buffer.getTurns(key),
|
|
558
|
+
);
|
|
559
|
+
const durableTurn = restartedPending.find((turn) => turn.content.includes("profile P1 observation"));
|
|
560
|
+
assert.ok(durableTurn, "P1 capture must be durably pending after restart");
|
|
561
|
+
assert.equal(durableTurn?.sourceValidAt, sourceValidAtA, "sourceValidAt identity must survive the durable roundtrip");
|
|
562
|
+
|
|
563
|
+
// Profile isolation: a flush pinned to plan P1 drains ONLY P1's turns.
|
|
564
|
+
await second.flushSession("proj-sess", {
|
|
565
|
+
reason: "access_force_flush",
|
|
566
|
+
failOnExtractionFailure: true,
|
|
567
|
+
writeNamespaceOverride: "ns-a",
|
|
568
|
+
principalOverride: "alice",
|
|
569
|
+
scopeProfileWritePlan: planP1,
|
|
570
|
+
});
|
|
571
|
+
const drainedUnderP1 = drainCalls.flat().map((turn) => turn.content);
|
|
572
|
+
assert.ok(
|
|
573
|
+
drainedUnderP1.some((content) => content.includes("profile P1 observation")),
|
|
574
|
+
"plan-P1 flush must drain P1's capture",
|
|
575
|
+
);
|
|
576
|
+
assert.ok(
|
|
577
|
+
!drainedUnderP1.some((content) => content.includes("profile P2 observation")),
|
|
578
|
+
"same session+owner under a different scope profile must NOT combine",
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const stillPending = (await second.buffer.findBufferKeysForSession("proj-sess")).flatMap((key) =>
|
|
582
|
+
second.buffer.getTurns(key),
|
|
583
|
+
);
|
|
584
|
+
assert.ok(
|
|
585
|
+
stillPending.some((turn) => turn.content.includes("profile P2 observation")),
|
|
586
|
+
"P2's capture must stay pending after the P1 drain",
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// Switch the active profile: P2's pinned plan becomes current and drains;
|
|
590
|
+
// P1 is already gone.
|
|
591
|
+
activateProfile(second.config, "profile-personal");
|
|
592
|
+
await second.flushSession("proj-sess", {
|
|
593
|
+
reason: "access_force_flush",
|
|
594
|
+
failOnExtractionFailure: true,
|
|
595
|
+
writeNamespaceOverride: "ns-a",
|
|
596
|
+
principalOverride: "alice",
|
|
597
|
+
scopeProfileWritePlan: planP2,
|
|
598
|
+
});
|
|
599
|
+
const drainedUnderP2 = drainCalls.flat().map((turn) => turn.content);
|
|
600
|
+
assert.ok(
|
|
601
|
+
drainedUnderP2.some((content) => content.includes("profile P2 observation")),
|
|
602
|
+
"pinned P2 capture drains once its own profile is active",
|
|
603
|
+
);
|
|
604
|
+
} finally {
|
|
605
|
+
release?.();
|
|
606
|
+
await restarted?.destroy().catch(() => undefined);
|
|
607
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
608
|
+
await cleanupDir(memoryDir);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("#3251 cancellation before admission still leaves the capture durably pending", async () => {
|
|
613
|
+
const memoryDir = await mkTempMemoryDir("observe-cancelled");
|
|
614
|
+
const orchestrator = new Orchestrator(
|
|
615
|
+
makeLifecycleConfig(memoryDir, { bufferMaxTurns: 1, bufferSaveDebounceMs: 0 }),
|
|
616
|
+
);
|
|
617
|
+
let release: (() => void) | undefined;
|
|
618
|
+
try {
|
|
619
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
620
|
+
const controller = new AbortController();
|
|
621
|
+
const background: QueuedExtractionSlot = {};
|
|
622
|
+
controller.abort(new Error("cancelled before admission"));
|
|
623
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
624
|
+
[makeReplayTurn("sess-cancel", "cancelled observe payload")],
|
|
625
|
+
bufferedObserveOptions({
|
|
626
|
+
abortSignal: controller.signal,
|
|
627
|
+
onExtractionQueued: (extraction) => {
|
|
628
|
+
extraction.catch(() => {});
|
|
629
|
+
background.extraction = extraction;
|
|
630
|
+
},
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
// Cancellation applies to extraction, never to the durable capture.
|
|
634
|
+
await replay;
|
|
635
|
+
assert.ok(background.extraction, "cancelled capture still queues (and aborts) its extraction");
|
|
636
|
+
await assert.rejects(background.extraction);
|
|
637
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("sess-cancel")).flatMap((key) =>
|
|
638
|
+
orchestrator.buffer.getTurns(key),
|
|
639
|
+
);
|
|
640
|
+
assert.ok(pending.some((turn) => turn.content.includes("cancelled observe payload")));
|
|
641
|
+
} finally {
|
|
642
|
+
release?.();
|
|
643
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
644
|
+
await cleanupDir(memoryDir);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("#3251 unscoped session_end drains the default store and never clears another pinned namespace's capture", async () => {
|
|
649
|
+
const memoryDir = await mkTempMemoryDir("observe-session-end");
|
|
650
|
+
const orchestrator = new Orchestrator(
|
|
651
|
+
makeLifecycleConfig(memoryDir, {
|
|
652
|
+
bufferMaxTurns: 1,
|
|
653
|
+
bufferSaveDebounceMs: 0,
|
|
654
|
+
namespacesEnabled: true,
|
|
655
|
+
defaultNamespace: "default",
|
|
656
|
+
sharedNamespace: "shared",
|
|
657
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
661
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
662
|
+
);
|
|
663
|
+
try {
|
|
664
|
+
const controller = new AbortController();
|
|
665
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
666
|
+
[makeReplayTurn("mix-sess", "pinned namespace capture")],
|
|
667
|
+
bufferedObserveOptions({
|
|
668
|
+
abortSignal: controller.signal,
|
|
669
|
+
writeNamespaceOverride: "ns-a",
|
|
670
|
+
principalOverride: "alice",
|
|
671
|
+
sessionOwnerPrincipal: "alice",
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
controller.abort(new Error("keep the capture pending"));
|
|
675
|
+
await replay;
|
|
676
|
+
assert.ok(
|
|
677
|
+
(await orchestrator.buffer.findBufferKeysForSession("mix-sess")).length > 0,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
await orchestrator.flushSession("mix-sess", { reason: "session_end" });
|
|
681
|
+
|
|
682
|
+
const drained = drainCalls.flat().map((turn) => turn.content);
|
|
683
|
+
assert.ok(
|
|
684
|
+
!drained.some((content) => content.includes("pinned namespace capture")),
|
|
685
|
+
"unqualified session_end must not drain another namespace's durable capture",
|
|
686
|
+
);
|
|
687
|
+
const stillPending = (await orchestrator.buffer.findBufferKeysForSession("mix-sess")).flatMap((key) =>
|
|
688
|
+
orchestrator.buffer.getTurns(key),
|
|
689
|
+
);
|
|
690
|
+
assert.ok(
|
|
691
|
+
stillPending.some((turn) => turn.content.includes("pinned namespace capture")),
|
|
692
|
+
"pinned capture survives the unscoped lifecycle drain",
|
|
693
|
+
);
|
|
694
|
+
} finally {
|
|
695
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
696
|
+
await cleanupDir(memoryDir);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("#3251 a pinned capture whose scope profile is revoked stays pending instead of extracting", async () => {
|
|
701
|
+
const memoryDir = await mkTempMemoryDir("observe-revoked-plan");
|
|
702
|
+
const orchestrator = new Orchestrator(
|
|
703
|
+
makeLifecycleConfig(memoryDir, {
|
|
704
|
+
bufferMaxTurns: 1,
|
|
705
|
+
bufferSaveDebounceMs: 0,
|
|
706
|
+
namespacesEnabled: true,
|
|
707
|
+
defaultNamespace: "default",
|
|
708
|
+
sharedNamespace: "shared",
|
|
709
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
activateProfile(orchestrator.config, "profile-team");
|
|
713
|
+
const pinnedPlan = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
714
|
+
assert.ok(pinnedPlan, "active profile must resolve before capture");
|
|
715
|
+
let release: (() => void) | undefined;
|
|
716
|
+
try {
|
|
717
|
+
// Wedge the queue so the capture's trigger-queued drain executes only
|
|
718
|
+
// AFTER the revocation below.
|
|
719
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
720
|
+
// Own stub for post-wedge calls (replaces the wedge's recorder).
|
|
721
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
722
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
723
|
+
);
|
|
724
|
+
const background: QueuedExtractionSlot = {};
|
|
725
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
726
|
+
[makeReplayTurn("revoked-sess", "revoked plan capture")],
|
|
727
|
+
bufferedObserveOptions({
|
|
728
|
+
writeNamespaceOverride: "ns-a",
|
|
729
|
+
principalOverride: "alice",
|
|
730
|
+
sessionOwnerPrincipal: "alice",
|
|
731
|
+
scopeProfileWritePlan: pinnedPlan,
|
|
732
|
+
onExtractionQueued: (extraction) => {
|
|
733
|
+
extraction.catch(() => {});
|
|
734
|
+
background.extraction = extraction;
|
|
735
|
+
},
|
|
736
|
+
}),
|
|
737
|
+
);
|
|
738
|
+
await replay;
|
|
739
|
+
assert.ok(background.extraction, "authorized trigger queues the drain");
|
|
740
|
+
|
|
741
|
+
// Revoke AFTER capture: the active profile no longer matches the pinned
|
|
742
|
+
// identity — the execution boundary must skip the LLM and keep the
|
|
743
|
+
// capture pending, never retarget or clear.
|
|
744
|
+
activateProfile(orchestrator.config, "profile-other");
|
|
745
|
+
|
|
746
|
+
release();
|
|
747
|
+
if (background.extraction) await background.extraction;
|
|
748
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
749
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("revoked-sess")).flatMap((key) =>
|
|
750
|
+
orchestrator.buffer.getTurns(key),
|
|
751
|
+
);
|
|
752
|
+
assert.ok(
|
|
753
|
+
pending.some((turn) => turn.content.includes("revoked plan capture")),
|
|
754
|
+
"capture stays pending while its plan is unauthorized",
|
|
755
|
+
);
|
|
756
|
+
assert.equal(drainCalls.length, 0, "no extraction ran under revoked policy");
|
|
757
|
+
} finally {
|
|
758
|
+
release?.();
|
|
759
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
760
|
+
await cleanupDir(memoryDir);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("#3251 a pinned capture outlives a namespaces-disabled config without flattening into the default store", async () => {
|
|
765
|
+
const memoryDir = await mkTempMemoryDir("observe-ns-off");
|
|
766
|
+
const orchestrator = new Orchestrator(
|
|
767
|
+
makeLifecycleConfig(memoryDir, {
|
|
768
|
+
bufferMaxTurns: 1,
|
|
769
|
+
bufferSaveDebounceMs: 0,
|
|
770
|
+
namespacesEnabled: true,
|
|
771
|
+
defaultNamespace: "default",
|
|
772
|
+
sharedNamespace: "shared",
|
|
773
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
777
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
778
|
+
);
|
|
779
|
+
let release: (() => void) | undefined;
|
|
780
|
+
try {
|
|
781
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
782
|
+
const background: QueuedExtractionSlot = {};
|
|
783
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
784
|
+
[makeReplayTurn("ns-off-sess", "pinned store capture")],
|
|
785
|
+
bufferedObserveOptions({
|
|
786
|
+
writeNamespaceOverride: "ns-a",
|
|
787
|
+
principalOverride: "alice",
|
|
788
|
+
sessionOwnerPrincipal: "alice",
|
|
789
|
+
onExtractionQueued: (extraction) => {
|
|
790
|
+
extraction.catch(() => {});
|
|
791
|
+
background.extraction = extraction;
|
|
792
|
+
},
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
await replay;
|
|
796
|
+
assert.ok(background.extraction, "trigger queues the drain while namespaces honor the pin");
|
|
797
|
+
|
|
798
|
+
// Disable namespaces AFTER capture: the pinned non-default store can no
|
|
799
|
+
// longer be honored — skip the LLM, keep the capture pending, never
|
|
800
|
+
// flatten.
|
|
801
|
+
orchestrator.config.namespacesEnabled = false;
|
|
802
|
+
|
|
803
|
+
release();
|
|
804
|
+
if (background.extraction) await background.extraction;
|
|
805
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
806
|
+
assert.equal(
|
|
807
|
+
drainCalls.length,
|
|
808
|
+
0,
|
|
809
|
+
"pinned store capture must not extract into a flattened default store",
|
|
810
|
+
);
|
|
811
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("ns-off-sess")).flatMap((key) =>
|
|
812
|
+
orchestrator.buffer.getTurns(key),
|
|
813
|
+
);
|
|
814
|
+
assert.ok(
|
|
815
|
+
pending.some((turn) => turn.content.includes("pinned store capture")),
|
|
816
|
+
"capture stays pending until its pinned store is honored again",
|
|
817
|
+
);
|
|
818
|
+
} finally {
|
|
819
|
+
release?.();
|
|
820
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
821
|
+
await cleanupDir(memoryDir);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// #3251 follow-up: retained-turn cleanup honors plan identity + original
|
|
827
|
+
// project binding — flush A must not delete project B's retained context.
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
test("#3251 retained-turn cleanup honors plan+binding: flush A leaves project B's retained turns", async () => {
|
|
831
|
+
const memoryDir = await mkTempMemoryDir("observe-retained-cleanup");
|
|
832
|
+
const config = parseConfig({
|
|
833
|
+
memoryDir,
|
|
834
|
+
namespacesEnabled: true,
|
|
835
|
+
defaultNamespace: "default",
|
|
836
|
+
sharedNamespace: "shared",
|
|
837
|
+
});
|
|
838
|
+
const buffer = new SmartBuffer(
|
|
839
|
+
config,
|
|
840
|
+
// SAFETY: inline seed storage satisfies SmartBuffer's storage needs.
|
|
841
|
+
{
|
|
842
|
+
async loadBuffer(): Promise<BufferState> {
|
|
843
|
+
return { turns: [], lastExtractionAt: null, extractionCount: 0 };
|
|
844
|
+
},
|
|
845
|
+
async saveBuffer(_state: BufferState): Promise<void> {},
|
|
846
|
+
} as StorageManager,
|
|
847
|
+
);
|
|
848
|
+
activateProfile(config, "profile-global");
|
|
849
|
+
const planP = resolveScopeProfilePlan({ config, principal: "alice" });
|
|
850
|
+
assert.ok(planP, "active profile must resolve");
|
|
851
|
+
const ctxA = { projectId: "proj-a", branch: null, rootPath: "/repo-proj-a", defaultBranch: null };
|
|
852
|
+
const ctxB = { projectId: "proj-b", branch: null, rootPath: "/repo-proj-b", defaultBranch: null };
|
|
853
|
+
const retainedTurn = (content: string, ctx: typeof ctxA): BufferTurn => ({
|
|
854
|
+
role: "user",
|
|
855
|
+
content,
|
|
856
|
+
timestamp: new Date().toISOString(),
|
|
857
|
+
sessionKey: "shared-sess",
|
|
858
|
+
sessionOwnerPrincipal: "alice",
|
|
859
|
+
writeNamespaceOverride: "ns-a",
|
|
860
|
+
acceptedCapture: true,
|
|
861
|
+
scopeProfileWritePlan: planP,
|
|
862
|
+
codingContext: ctx,
|
|
863
|
+
});
|
|
864
|
+
await buffer.retainDeferredTurns("shared-sess", [
|
|
865
|
+
retainedTurn("project A retained context", ctxA),
|
|
866
|
+
retainedTurn("project B retained context", ctxB),
|
|
867
|
+
], 10);
|
|
868
|
+
|
|
869
|
+
// Flush A's cleanup: same namespace + plan, DIFFERENT project binding —
|
|
870
|
+
// project A's retained context is cleaned, project B's survives.
|
|
871
|
+
await buffer.clearRetainedTurnsForSession("shared-sess", "alice", {
|
|
872
|
+
writeNamespaceOverride: "ns-a",
|
|
873
|
+
scopeProfileWritePlan: planP,
|
|
874
|
+
codingContext: ctxA,
|
|
875
|
+
});
|
|
876
|
+
const retainedAfterA = buffer.getRetainedDeferredTurns("shared-sess").map((turn) => turn.content);
|
|
877
|
+
assert.equal(
|
|
878
|
+
retainedAfterA.some((content) => content.includes("project A retained context")),
|
|
879
|
+
false,
|
|
880
|
+
"own-scope retained turns are still cleaned",
|
|
881
|
+
);
|
|
882
|
+
assert.ok(
|
|
883
|
+
retainedAfterA.some((content) => content.includes("project B retained context")),
|
|
884
|
+
"retained turns bound to a DIFFERENT project survive plan+binding-scoped cleanup",
|
|
885
|
+
);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
/** The plan produced by the native resolver for a project-scoped capture. */
|
|
890
|
+
interface ProjectPlanProbe {
|
|
891
|
+
plan: ResolvedScopeProfilePlan;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Native plan for a project-scoped capture: resolver + real coding context. */
|
|
895
|
+
function projectProfilePlan(config: PluginConfig, projectId: string): ProjectPlanProbe {
|
|
896
|
+
const codingContext = {
|
|
897
|
+
projectId,
|
|
898
|
+
branch: null,
|
|
899
|
+
rootPath: `/repo-${projectId}`,
|
|
900
|
+
defaultBranch: null,
|
|
901
|
+
};
|
|
902
|
+
const codingOverlay = resolveCodingNamespaceOverlay(
|
|
903
|
+
codingContext,
|
|
904
|
+
{ projectScope: true, branchScope: false, globalFallback: false },
|
|
905
|
+
"default",
|
|
906
|
+
);
|
|
907
|
+
assert.ok(codingOverlay, "project scope must produce an overlay");
|
|
908
|
+
const plan = resolveScopeProfilePlan({
|
|
909
|
+
config,
|
|
910
|
+
principal: "alice",
|
|
911
|
+
codingContext,
|
|
912
|
+
codingOverlay,
|
|
913
|
+
});
|
|
914
|
+
assert.ok(plan, "project profile must resolve");
|
|
915
|
+
return { plan };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function withProjectWriteLayer(config: PluginConfig, profileId: string): void {
|
|
919
|
+
const profile = config.scopeProfiles?.[profileId];
|
|
920
|
+
if (profile) {
|
|
921
|
+
profile.readOrder = ["userProject", "userGlobal"];
|
|
922
|
+
profile.writeDefault = "userProject";
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
test("#3251 a project-profile capture survives restart and extracts under its original project binding", async () => {
|
|
927
|
+
const memoryDir = await mkTempMemoryDir("observe-project-restart");
|
|
928
|
+
const makeConfig = () =>
|
|
929
|
+
makeLifecycleConfig(memoryDir, {
|
|
930
|
+
bufferMaxTurns: 1,
|
|
931
|
+
bufferSaveDebounceMs: 0,
|
|
932
|
+
namespacesEnabled: true,
|
|
933
|
+
defaultNamespace: "default",
|
|
934
|
+
sharedNamespace: "shared",
|
|
935
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
936
|
+
});
|
|
937
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
938
|
+
activateProfile(orchestrator.config, "profile-project");
|
|
939
|
+
withProjectWriteLayer(orchestrator.config, "profile-project");
|
|
940
|
+
const probe = projectProfilePlan(orchestrator.config, "proj-a");
|
|
941
|
+
const projectContext = {
|
|
942
|
+
projectId: "proj-a",
|
|
943
|
+
branch: null,
|
|
944
|
+
rootPath: "/repo-proj-a",
|
|
945
|
+
defaultBranch: null,
|
|
946
|
+
};
|
|
947
|
+
let restarted: Orchestrator | null = null;
|
|
948
|
+
let release: (() => void) | undefined;
|
|
949
|
+
try {
|
|
950
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
951
|
+
const controller = new AbortController();
|
|
952
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
953
|
+
[makeReplayTurn("proj-bind-sess", "project binding capture")],
|
|
954
|
+
bufferedObserveOptions({
|
|
955
|
+
abortSignal: controller.signal,
|
|
956
|
+
writeNamespaceOverride: probe.plan.writeNamespace,
|
|
957
|
+
principalOverride: "alice",
|
|
958
|
+
sessionOwnerPrincipal: "alice",
|
|
959
|
+
scopeProfileWritePlan: probe.plan,
|
|
960
|
+
codingContext: projectContext,
|
|
961
|
+
}),
|
|
962
|
+
);
|
|
963
|
+
controller.abort(new Error("keep the capture pending"));
|
|
964
|
+
await replay;
|
|
965
|
+
assert.ok(
|
|
966
|
+
(await orchestrator.buffer.findBufferKeysForSession("proj-bind-sess")).length > 0,
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
release();
|
|
970
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
971
|
+
|
|
972
|
+
const second = new Orchestrator(makeConfig());
|
|
973
|
+
restarted = second;
|
|
974
|
+
activateProfile(second.config, "profile-project");
|
|
975
|
+
withProjectWriteLayer(second.config, "profile-project");
|
|
976
|
+
const drainCalls = stubExtraction(second, (turns) =>
|
|
977
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
978
|
+
);
|
|
979
|
+
await second.flushSession("proj-bind-sess", {
|
|
980
|
+
reason: "access_force_flush",
|
|
981
|
+
failOnExtractionFailure: true,
|
|
982
|
+
writeNamespaceOverride: probe.plan.writeNamespace,
|
|
983
|
+
principalOverride: "alice",
|
|
984
|
+
scopeProfileWritePlan: probe.plan,
|
|
985
|
+
codingContext: projectContext,
|
|
986
|
+
});
|
|
987
|
+
const drained = drainCalls.flat().map((turn) => turn.content);
|
|
988
|
+
assert.ok(
|
|
989
|
+
drained.some((content) => content.includes("project binding capture")),
|
|
990
|
+
"restart re-resolution must honor the ORIGINAL project binding and drain the capture",
|
|
991
|
+
);
|
|
992
|
+
} finally {
|
|
993
|
+
release?.();
|
|
994
|
+
await restarted?.destroy().catch(() => undefined);
|
|
995
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
996
|
+
await cleanupDir(memoryDir);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("#3251 same userGlobal profile across different projects never combines and keeps promotion bindings", async () => {
|
|
1001
|
+
const memoryDir = await mkTempMemoryDir("observe-project-isolation");
|
|
1002
|
+
// Thresholds force keep_buffering: no trigger tasks, so the scoped flush
|
|
1003
|
+
// alone decides what drains — deterministic isolation check.
|
|
1004
|
+
const makeConfig = () =>
|
|
1005
|
+
makeLifecycleConfig(memoryDir, {
|
|
1006
|
+
bufferMaxTurns: 1000,
|
|
1007
|
+
bufferSaveDebounceMs: 0,
|
|
1008
|
+
extractionMinChars: 1_000_000,
|
|
1009
|
+
extractionMinUserTurns: 1000,
|
|
1010
|
+
namespacesEnabled: true,
|
|
1011
|
+
defaultNamespace: "default",
|
|
1012
|
+
sharedNamespace: "shared",
|
|
1013
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1014
|
+
});
|
|
1015
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
1016
|
+
activateProfile(orchestrator.config, "profile-global");
|
|
1017
|
+
const probeA = projectProfilePlan(orchestrator.config, "proj-a");
|
|
1018
|
+
const probeB = projectProfilePlan(orchestrator.config, "proj-b");
|
|
1019
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1020
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1021
|
+
);
|
|
1022
|
+
try {
|
|
1023
|
+
await orchestrator.ingestReplayBatch(
|
|
1024
|
+
[makeReplayTurn("proj-sess", "project A promotion binding")],
|
|
1025
|
+
bufferedObserveOptions({
|
|
1026
|
+
writeNamespaceOverride: "ns-a",
|
|
1027
|
+
principalOverride: "alice",
|
|
1028
|
+
sessionOwnerPrincipal: "alice",
|
|
1029
|
+
scopeProfileWritePlan: probeA.plan,
|
|
1030
|
+
codingContext: { projectId: "proj-a", branch: null, rootPath: "/repo-proj-a", defaultBranch: null },
|
|
1031
|
+
}),
|
|
1032
|
+
);
|
|
1033
|
+
await orchestrator.ingestReplayBatch(
|
|
1034
|
+
[makeReplayTurn("proj-sess", "project B promotion binding")],
|
|
1035
|
+
bufferedObserveOptions({
|
|
1036
|
+
writeNamespaceOverride: "ns-a",
|
|
1037
|
+
principalOverride: "alice",
|
|
1038
|
+
sessionOwnerPrincipal: "alice",
|
|
1039
|
+
scopeProfileWritePlan: probeB.plan,
|
|
1040
|
+
codingContext: { projectId: "proj-b", branch: null, rootPath: "/repo-proj-b", defaultBranch: null },
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
await orchestrator.flushSession("proj-sess", {
|
|
1045
|
+
reason: "access_force_flush",
|
|
1046
|
+
failOnExtractionFailure: true,
|
|
1047
|
+
writeNamespaceOverride: "ns-a",
|
|
1048
|
+
principalOverride: "alice",
|
|
1049
|
+
scopeProfileWritePlan: probeA.plan,
|
|
1050
|
+
codingContext: { projectId: "proj-a", branch: null, rootPath: "/repo-proj-a", defaultBranch: null },
|
|
1051
|
+
});
|
|
1052
|
+
const drainedUnderA = drainCalls.flat().map((turn) => turn.content);
|
|
1053
|
+
assert.ok(
|
|
1054
|
+
drainedUnderA.some((content) => content.includes("project A promotion binding")),
|
|
1055
|
+
"plan-A flush must drain project A's capture",
|
|
1056
|
+
);
|
|
1057
|
+
assert.ok(
|
|
1058
|
+
!drainedUnderA.some((content) => content.includes("project B promotion binding")),
|
|
1059
|
+
"same profile/userGlobal write namespace across DIFFERENT projects must NOT combine",
|
|
1060
|
+
);
|
|
1061
|
+
const stillPending = (await orchestrator.buffer.findBufferKeysForSession("proj-sess")).flatMap((key) =>
|
|
1062
|
+
orchestrator.buffer.getTurns(key),
|
|
1063
|
+
);
|
|
1064
|
+
assert.ok(
|
|
1065
|
+
stillPending.some((turn) => turn.content.includes("project B promotion binding")),
|
|
1066
|
+
"project B's capture stays pending under its own binding",
|
|
1067
|
+
);
|
|
1068
|
+
} finally {
|
|
1069
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1070
|
+
await cleanupDir(memoryDir);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("#3251 a revoked namespace write grant preserves the accepted capture without LLM work", async () => {
|
|
1075
|
+
const memoryDir = await mkTempMemoryDir("observe-ns-revoked");
|
|
1076
|
+
const orchestrator = new Orchestrator(
|
|
1077
|
+
makeLifecycleConfig(memoryDir, {
|
|
1078
|
+
bufferMaxTurns: 1,
|
|
1079
|
+
bufferSaveDebounceMs: 0,
|
|
1080
|
+
namespacesEnabled: true,
|
|
1081
|
+
defaultNamespace: "default",
|
|
1082
|
+
sharedNamespace: "shared",
|
|
1083
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1084
|
+
}),
|
|
1085
|
+
);
|
|
1086
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1087
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1088
|
+
);
|
|
1089
|
+
let release: (() => void) | undefined;
|
|
1090
|
+
try {
|
|
1091
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
1092
|
+
const background: QueuedExtractionSlot = {};
|
|
1093
|
+
const replay = orchestrator.ingestReplayBatch(
|
|
1094
|
+
[makeReplayTurn("ns-revoke-sess", "revoked namespace capture")],
|
|
1095
|
+
bufferedObserveOptions({
|
|
1096
|
+
writeNamespaceOverride: "ns-a",
|
|
1097
|
+
principalOverride: "alice",
|
|
1098
|
+
sessionOwnerPrincipal: "alice",
|
|
1099
|
+
onExtractionQueued: (extraction) => {
|
|
1100
|
+
extraction.catch(() => {});
|
|
1101
|
+
background.extraction = extraction;
|
|
1102
|
+
},
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
await replay;
|
|
1106
|
+
assert.ok(background.extraction, "authorized trigger queues the drain");
|
|
1107
|
+
|
|
1108
|
+
// Revoke alice's WRITE grant on ns-a AFTER capture: the drain must skip
|
|
1109
|
+
// and preserve, never write into a namespace the principal can no longer
|
|
1110
|
+
// write.
|
|
1111
|
+
orchestrator.config.namespacePolicies = [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: [] }];
|
|
1112
|
+
|
|
1113
|
+
release();
|
|
1114
|
+
if (background.extraction) await background.extraction;
|
|
1115
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
1116
|
+
assert.equal(drainCalls.length, 0, "no extraction under a revoked namespace write grant");
|
|
1117
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("ns-revoke-sess")).flatMap((key) =>
|
|
1118
|
+
orchestrator.buffer.getTurns(key),
|
|
1119
|
+
);
|
|
1120
|
+
assert.ok(
|
|
1121
|
+
pending.some((turn) => turn.content.includes("revoked namespace capture")),
|
|
1122
|
+
"capture stays pending until write access is restored",
|
|
1123
|
+
);
|
|
1124
|
+
} finally {
|
|
1125
|
+
release?.();
|
|
1126
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1127
|
+
await cleanupDir(memoryDir);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// #3251 native trigger scope routing: processTurn / heartbeat must never run
|
|
1133
|
+
// mixed accepted scopes under one policy.
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
function makeTurnIngestionDouble(config: PluginConfig, buffer: SmartBuffer, queued: BufferTurn[][]): TurnIngestionCoordinator {
|
|
1137
|
+
// SAFETY: these fixtures exercise only buffered admission + trigger
|
|
1138
|
+
// routing; the remaining deps are never reached on this path.
|
|
1139
|
+
const deps = { buffer, config } as any;
|
|
1140
|
+
deps.queueBufferedExtraction = async (turns: BufferTurn[]) => {
|
|
1141
|
+
queued.push(turns);
|
|
1142
|
+
};
|
|
1143
|
+
return new TurnIngestionCoordinator(deps);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
test("#3251 native processTurn routes mixed accepted scopes as separate scope groups", async () => {
|
|
1147
|
+
const memoryDir = await mkTempMemoryDir("observe-native-trigger");
|
|
1148
|
+
const config = parseConfig({
|
|
1149
|
+
memoryDir,
|
|
1150
|
+
bufferMaxTurns: 1,
|
|
1151
|
+
bufferSaveDebounceMs: 0,
|
|
1152
|
+
namespacesEnabled: true,
|
|
1153
|
+
defaultNamespace: "default",
|
|
1154
|
+
});
|
|
1155
|
+
// SAFETY: SeedStorage implements SmartBuffer's storage seam; the wider
|
|
1156
|
+
// StorageManager surface is unused in this fixture.
|
|
1157
|
+
// SAFETY: SeedStorage implements SmartBuffer's storage seam; the wider
|
|
1158
|
+
// StorageManager surface is unused in this fixture.
|
|
1159
|
+
const buffer = new SmartBuffer(config, new SeedStorage() as StorageManager);
|
|
1160
|
+
const queued: BufferTurn[][] = [];
|
|
1161
|
+
const coordinator = makeTurnIngestionDouble(config, buffer, queued);
|
|
1162
|
+
try {
|
|
1163
|
+
// Two accepted captures already durable in the entry, different pins.
|
|
1164
|
+
for (const [content, namespace, projectId] of [
|
|
1165
|
+
["scope A native capture", "ns-a", "proj-a"],
|
|
1166
|
+
["scope B native capture", "ns-b", "proj-b"],
|
|
1167
|
+
] as const) {
|
|
1168
|
+
await buffer.addTurn("mixed-sess", {
|
|
1169
|
+
role: "user",
|
|
1170
|
+
content,
|
|
1171
|
+
timestamp: new Date().toISOString(),
|
|
1172
|
+
sessionKey: "mixed-sess",
|
|
1173
|
+
sessionOwnerPrincipal: "alice",
|
|
1174
|
+
acceptedCapture: true,
|
|
1175
|
+
writeNamespaceOverride: namespace,
|
|
1176
|
+
codingContext: { projectId, branch: null, rootPath: `/repo-${projectId}`, defaultBranch: null },
|
|
1177
|
+
} satisfies BufferTurn);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
await coordinator.processTurn("user", "native trigger turn", "mixed-sess");
|
|
1181
|
+
|
|
1182
|
+
assert.equal(queued.length, 3, "mixed scopes queue separately; the unpinned trigger turn is its own group");
|
|
1183
|
+
const seen = queued.map((batch) => batch.map((turn) => turn.content.slice(0, 6)).join(","));
|
|
1184
|
+
const scopesPerCall = queued.map(
|
|
1185
|
+
(batch) => new Set(batch.map((turn) => turn.writeNamespaceOverride ?? "-")).size,
|
|
1186
|
+
);
|
|
1187
|
+
assert.deepEqual(
|
|
1188
|
+
scopesPerCall.filter((size) => size > 1),
|
|
1189
|
+
[],
|
|
1190
|
+
"no extraction batch may mix accepted scopes",
|
|
1191
|
+
);
|
|
1192
|
+
const allQueued = queued.flat().map((turn) => turn.content);
|
|
1193
|
+
assert.ok(allQueued.some((content) => content.includes("scope A native capture")));
|
|
1194
|
+
assert.ok(allQueued.some((content) => content.includes("scope B native capture")));
|
|
1195
|
+
assert.ok(seen.length >= 2);
|
|
1196
|
+
} finally {
|
|
1197
|
+
await cleanupDir(memoryDir);
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("#3251 heartbeat observer routes mixed accepted scopes as separate scope groups", async () => {
|
|
1202
|
+
const memoryDir = await mkTempMemoryDir("observe-heartbeat-trigger");
|
|
1203
|
+
const config = parseConfig({
|
|
1204
|
+
memoryDir,
|
|
1205
|
+
bufferMaxTurns: 1000,
|
|
1206
|
+
bufferSaveDebounceMs: 0,
|
|
1207
|
+
extractionMinChars: 1_000_000,
|
|
1208
|
+
extractionMinUserTurns: 1000,
|
|
1209
|
+
namespacesEnabled: true,
|
|
1210
|
+
defaultNamespace: "default",
|
|
1211
|
+
sessionObserverEnabled: true,
|
|
1212
|
+
});
|
|
1213
|
+
// SAFETY: SeedStorage implements SmartBuffer's storage seam; the wider
|
|
1214
|
+
// StorageManager surface is unused in this fixture.
|
|
1215
|
+
const buffer = new SmartBuffer(config, new SeedStorage() as StorageManager);
|
|
1216
|
+
const queued: BufferTurn[][] = [];
|
|
1217
|
+
// SAFETY: heartbeat fixture exercises only the observer trigger routing;
|
|
1218
|
+
// the remaining deps are unreachable on this path.
|
|
1219
|
+
const deps = { buffer, config } as any;
|
|
1220
|
+
deps.queueBufferedExtraction = async (turns: BufferTurn[]) => {
|
|
1221
|
+
queued.push(turns);
|
|
1222
|
+
};
|
|
1223
|
+
deps.heartbeatObserverChains = new Map();
|
|
1224
|
+
deps.shouldQueueExtraction = () => true;
|
|
1225
|
+
// SAFETY: transcript double — remaining TranscriptManager members are
|
|
1226
|
+
// unreachable from the heartbeat observer trigger path.
|
|
1227
|
+
deps.transcript = {
|
|
1228
|
+
estimateSessionFootprint: async () => ({ bytes: 1, tokens: 1 }),
|
|
1229
|
+
getTranscriptPath: (sessionKey: string) => ({
|
|
1230
|
+
dir: join(memoryDir, "transcripts"),
|
|
1231
|
+
alternateDir: undefined,
|
|
1232
|
+
legacyDir: undefined,
|
|
1233
|
+
readbackDirs: [],
|
|
1234
|
+
primaryPath: join(memoryDir, "transcripts", sessionKey),
|
|
1235
|
+
}),
|
|
1236
|
+
} as never;
|
|
1237
|
+
// SAFETY: session observer double — only observe() is called.
|
|
1238
|
+
deps.sessionObserver = {
|
|
1239
|
+
observe: async () => ({ triggered: true, deltaBytes: 1, deltaTokens: 1 }),
|
|
1240
|
+
} as never;
|
|
1241
|
+
const coordinator = new TurnIngestionCoordinator(deps);
|
|
1242
|
+
try {
|
|
1243
|
+
for (const [content, namespace, projectId] of [
|
|
1244
|
+
["heartbeat scope A capture", "ns-a", "proj-a"],
|
|
1245
|
+
["heartbeat scope B capture", "ns-b", "proj-b"],
|
|
1246
|
+
] as const) {
|
|
1247
|
+
await buffer.addTurn("hb-sess", {
|
|
1248
|
+
role: "user",
|
|
1249
|
+
content,
|
|
1250
|
+
timestamp: new Date().toISOString(),
|
|
1251
|
+
sessionKey: "hb-sess",
|
|
1252
|
+
sessionOwnerPrincipal: "alice",
|
|
1253
|
+
acceptedCapture: true,
|
|
1254
|
+
writeNamespaceOverride: namespace,
|
|
1255
|
+
codingContext: { projectId, branch: null, rootPath: `/repo-${projectId}`, defaultBranch: null },
|
|
1256
|
+
} satisfies BufferTurn);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
await coordinator.observeSessionHeartbeat("hb-sess");
|
|
1260
|
+
|
|
1261
|
+
assert.equal(queued.length, 2, "heartbeat mixed accepted scopes queue as separate scope groups");
|
|
1262
|
+
const scopesPerCall = queued.map(
|
|
1263
|
+
(batch) => new Set(batch.map((turn) => turn.writeNamespaceOverride ?? "-")).size,
|
|
1264
|
+
);
|
|
1265
|
+
assert.deepEqual(
|
|
1266
|
+
scopesPerCall.filter((size) => size > 1),
|
|
1267
|
+
[],
|
|
1268
|
+
"no heartbeat extraction batch may mix accepted scopes",
|
|
1269
|
+
);
|
|
1270
|
+
} finally {
|
|
1271
|
+
await cleanupDir(memoryDir);
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("#3251 policy-revoked retained capture: forceflush rejects before clearing, grant restore allows retry", async () => {
|
|
1276
|
+
const memoryDir = await mkTempMemoryDir("observe-retained-revoked");
|
|
1277
|
+
const orchestrator = new Orchestrator(
|
|
1278
|
+
makeLifecycleConfig(memoryDir, {
|
|
1279
|
+
bufferMaxTurns: 1,
|
|
1280
|
+
bufferSaveDebounceMs: 0,
|
|
1281
|
+
namespacesEnabled: true,
|
|
1282
|
+
defaultNamespace: "default",
|
|
1283
|
+
sharedNamespace: "shared",
|
|
1284
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1285
|
+
}),
|
|
1286
|
+
);
|
|
1287
|
+
activateProfile(orchestrator.config, "profile-global");
|
|
1288
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1289
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1290
|
+
);
|
|
1291
|
+
let release: (() => void) | undefined;
|
|
1292
|
+
try {
|
|
1293
|
+
release = await wedgeExtractionQueue(orchestrator);
|
|
1294
|
+
|
|
1295
|
+
// Accept a capture pinned to ns-a and place it in RETAINED turns (native
|
|
1296
|
+
// defer retention path), so clearRetainedTurnsForSession is the deletion
|
|
1297
|
+
// vector after a flush.
|
|
1298
|
+
const retainedCapture: BufferTurn = {
|
|
1299
|
+
role: "user",
|
|
1300
|
+
content: "retained policy-revoked capture",
|
|
1301
|
+
timestamp: new Date().toISOString(),
|
|
1302
|
+
sessionKey: "retained-sess",
|
|
1303
|
+
sessionOwnerPrincipal: "alice",
|
|
1304
|
+
acceptedCapture: true,
|
|
1305
|
+
writeNamespaceOverride: "ns-a",
|
|
1306
|
+
};
|
|
1307
|
+
await buffer_addRetained(orchestrator, retainedCapture);
|
|
1308
|
+
|
|
1309
|
+
// Queue the flush behind the wedge, then revoke alice's write grant.
|
|
1310
|
+
const flushPromise = orchestrator
|
|
1311
|
+
.flushSession("retained-sess", {
|
|
1312
|
+
reason: "access_force_flush",
|
|
1313
|
+
failOnExtractionFailure: true,
|
|
1314
|
+
writeNamespaceOverride: "ns-a",
|
|
1315
|
+
principalOverride: "alice",
|
|
1316
|
+
})
|
|
1317
|
+
.then(
|
|
1318
|
+
() => "resolved" as const,
|
|
1319
|
+
(err: Error) => `rejected:${err.message}` as const,
|
|
1320
|
+
);
|
|
1321
|
+
orchestrator.config.namespacePolicies = [
|
|
1322
|
+
{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: [] },
|
|
1323
|
+
];
|
|
1324
|
+
|
|
1325
|
+
release();
|
|
1326
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
1327
|
+
const flushOutcome = await flushPromise;
|
|
1328
|
+
|
|
1329
|
+
// The flush must REJECT (failOnExtractionFailure), not silently resolve.
|
|
1330
|
+
assert.ok(
|
|
1331
|
+
flushOutcome.startsWith("rejected"),
|
|
1332
|
+
`forceflush must reject when a policy-revoked accepted capture is skipped: got ${flushOutcome}`,
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
// The retained capture must survive on disk despite the flush attempt.
|
|
1336
|
+
const stillRetained = orchestrator.buffer.getRetainedDeferredTurns("ns-a");
|
|
1337
|
+
const activePending = (await orchestrator.buffer.findBufferKeysForSession("retained-sess")).flatMap((key) =>
|
|
1338
|
+
orchestrator.buffer.getTurns(key),
|
|
1339
|
+
);
|
|
1340
|
+
const allPending = [...stillRetained, ...activePending].map((turn) => turn.content);
|
|
1341
|
+
assert.ok(
|
|
1342
|
+
allPending.some((content) => content.includes("retained policy-revoked capture")),
|
|
1343
|
+
"retained capture must survive the rejected flush",
|
|
1344
|
+
);
|
|
1345
|
+
|
|
1346
|
+
// Restore the grant: retry flush drains the capture. Reinstall the drain
|
|
1347
|
+
// recorder (the wedge's stub is no longer needed after release).
|
|
1348
|
+
orchestrator.config.namespacePolicies = [
|
|
1349
|
+
{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
|
|
1350
|
+
];
|
|
1351
|
+
const retryDrainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1352
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1353
|
+
);
|
|
1354
|
+
await orchestrator.flushSession("retained-sess", {
|
|
1355
|
+
reason: "access_force_flush",
|
|
1356
|
+
failOnExtractionFailure: true,
|
|
1357
|
+
writeNamespaceOverride: "ns-a",
|
|
1358
|
+
principalOverride: "alice",
|
|
1359
|
+
});
|
|
1360
|
+
assert.ok(
|
|
1361
|
+
retryDrainCalls.some((batch) => batch.some((turn) => turn.content.includes("retained policy-revoked capture"))),
|
|
1362
|
+
"capture drains after write access is restored",
|
|
1363
|
+
);
|
|
1364
|
+
} finally {
|
|
1365
|
+
release?.();
|
|
1366
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1367
|
+
await cleanupDir(memoryDir);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
/** Place a turn in the buffer's retained slot for a namespace key (#562 defer retention). */
|
|
1372
|
+
async function buffer_addRetained(orchestrator: Orchestrator, turn: BufferTurn): Promise<void> {
|
|
1373
|
+
const key = turn.writeNamespaceOverride ?? turn.sessionKey ?? "default";
|
|
1374
|
+
await orchestrator.buffer.addTurn(key, turn);
|
|
1375
|
+
await orchestrator.buffer.retainDeferredTurns(key, [turn], 10);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
test("#3251 namespaces-disabled flush with failOnExtractionFailure rejects and preserves the capture", async () => {
|
|
1379
|
+
const memoryDir = await mkTempMemoryDir("observe-ns-off-fail");
|
|
1380
|
+
const orchestrator = new Orchestrator(
|
|
1381
|
+
makeLifecycleConfig(memoryDir, {
|
|
1382
|
+
bufferMaxTurns: 1,
|
|
1383
|
+
bufferSaveDebounceMs: 0,
|
|
1384
|
+
namespacesEnabled: true,
|
|
1385
|
+
defaultNamespace: "default",
|
|
1386
|
+
sharedNamespace: "shared",
|
|
1387
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1388
|
+
}),
|
|
1389
|
+
);
|
|
1390
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1391
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1392
|
+
);
|
|
1393
|
+
try {
|
|
1394
|
+
await orchestrator.ingestReplayBatch(
|
|
1395
|
+
[makeReplayTurn("ns-off-fail-sess", "pinned capture before ns-off")],
|
|
1396
|
+
bufferedObserveOptions({
|
|
1397
|
+
writeNamespaceOverride: "ns-a",
|
|
1398
|
+
principalOverride: "alice",
|
|
1399
|
+
sessionOwnerPrincipal: "alice",
|
|
1400
|
+
}),
|
|
1401
|
+
);
|
|
1402
|
+
orchestrator.config.namespacesEnabled = false;
|
|
1403
|
+
|
|
1404
|
+
// failOnExtractionFailure=true must reject the flush, not silently skip.
|
|
1405
|
+
await assert.rejects(
|
|
1406
|
+
orchestrator.flushSession("ns-off-fail-sess", {
|
|
1407
|
+
reason: "access_force_flush",
|
|
1408
|
+
failOnExtractionFailure: true,
|
|
1409
|
+
writeNamespaceOverride: "ns-a",
|
|
1410
|
+
principalOverride: "alice",
|
|
1411
|
+
}),
|
|
1412
|
+
/pinned_namespace_unavailable/,
|
|
1413
|
+
);
|
|
1414
|
+
assert.equal(drainCalls.length, 0, "no LLM under unavailable pinned namespace with failOn");
|
|
1415
|
+
|
|
1416
|
+
// The capture survives.
|
|
1417
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("ns-off-fail-sess")).flatMap((key) =>
|
|
1418
|
+
orchestrator.buffer.getTurns(key),
|
|
1419
|
+
);
|
|
1420
|
+
assert.ok(pending.some((turn) => turn.content.includes("pinned capture before ns-off")));
|
|
1421
|
+
} finally {
|
|
1422
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1423
|
+
await cleanupDir(memoryDir);
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// ---------------------------------------------------------------------------
|
|
1428
|
+
// #3251 flat capture namespace pin: OFF→ON must never retarget the original
|
|
1429
|
+
// collapsed default store to a newly-active principal/project namespace.
|
|
1430
|
+
test("#3251 flat capture (namespaces OFF) survives namespaces-ON restart in original default store", async () => {
|
|
1431
|
+
const memoryDir = await mkTempMemoryDir("observe-flat-off-on");
|
|
1432
|
+
// SAFETY: phase-specific configs — off-phase collapses to the default
|
|
1433
|
+
// store, on-phase enables routing to verify the pin prevents retargeting.
|
|
1434
|
+
const offConfig = makeLifecycleConfig(memoryDir, {
|
|
1435
|
+
bufferMaxTurns: 1000,
|
|
1436
|
+
bufferSaveDebounceMs: 0,
|
|
1437
|
+
extractionMinChars: 1_000_000,
|
|
1438
|
+
extractionMinUserTurns: 1000,
|
|
1439
|
+
defaultNamespace: "default",
|
|
1440
|
+
namespacesEnabled: false,
|
|
1441
|
+
});
|
|
1442
|
+
const onConfig = makeLifecycleConfig(memoryDir, {
|
|
1443
|
+
bufferMaxTurns: 1000,
|
|
1444
|
+
bufferSaveDebounceMs: 0,
|
|
1445
|
+
extractionMinChars: 1_000_000,
|
|
1446
|
+
extractionMinUserTurns: 1000,
|
|
1447
|
+
namespacesEnabled: true,
|
|
1448
|
+
defaultNamespace: "default",
|
|
1449
|
+
sharedNamespace: "shared",
|
|
1450
|
+
principalFromSessionKeyMode: "prefix",
|
|
1451
|
+
principalFromSessionKeyRules: [{ match: "alice:", principal: "alice" }],
|
|
1452
|
+
namespacePolicies: [
|
|
1453
|
+
{ name: "alice", readPrincipals: ["alice"], writePrincipals: ["alice"] },
|
|
1454
|
+
{ name: "default", readPrincipals: ["alice", "default"], writePrincipals: ["alice", "default"] },
|
|
1455
|
+
],
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// Phase 1: namespaces OFF — capture, pin, save, destroy.
|
|
1459
|
+
{
|
|
1460
|
+
const orch = new Orchestrator(offConfig);
|
|
1461
|
+
try {
|
|
1462
|
+
await orch.ingestReplayBatch(
|
|
1463
|
+
[makeReplayTurn("flat-sess", "flat capture original default")],
|
|
1464
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice" }),
|
|
1465
|
+
);
|
|
1466
|
+
const flatTurns = (await orch.buffer.findBufferKeysForSession("flat-sess")).flatMap((key) =>
|
|
1467
|
+
orch.buffer.getTurns(key),
|
|
1468
|
+
);
|
|
1469
|
+
const flatTurn = flatTurns.find((t) => t.acceptedCapture === true);
|
|
1470
|
+
assert.ok(flatTurn, "accepted capture must exist");
|
|
1471
|
+
assert.equal(
|
|
1472
|
+
flatTurn?.writeNamespaceOverride, "default",
|
|
1473
|
+
"flat capture must pin config.defaultNamespace when namespaces are off",
|
|
1474
|
+
);
|
|
1475
|
+
} finally {
|
|
1476
|
+
await orch.destroy().catch(() => undefined);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Phase 2: namespaces ON with alice routing — a scoped alice flush must
|
|
1481
|
+
// NOT steal the original default capture; an explicit default flush drains.
|
|
1482
|
+
{
|
|
1483
|
+
const orch = new Orchestrator(onConfig);
|
|
1484
|
+
const drainCalls = stubExtraction(orch, (turns) =>
|
|
1485
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1486
|
+
);
|
|
1487
|
+
try {
|
|
1488
|
+
// Ingest a second flat capture so we have two items, both pinned "default".
|
|
1489
|
+
await orch.ingestReplayBatch(
|
|
1490
|
+
[makeReplayTurn("flat-sess", "second flat stays default")],
|
|
1491
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice", writeNamespaceOverride: "default" }),
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
// Alice-scoped flush: must NOT drain the original default captures.
|
|
1495
|
+
await orch.flushSession("flat-sess", {
|
|
1496
|
+
reason: "access_force_flush",
|
|
1497
|
+
failOnExtractionFailure: true,
|
|
1498
|
+
writeNamespaceOverride: "alice",
|
|
1499
|
+
principalOverride: "alice",
|
|
1500
|
+
});
|
|
1501
|
+
const drainedAlice = drainCalls.flat().map((t) => t.content);
|
|
1502
|
+
assert.equal(
|
|
1503
|
+
drainedAlice.filter((c) => c.includes("flat capture") || c.includes("second flat")).length,
|
|
1504
|
+
0,
|
|
1505
|
+
"alice-scoped flush must NOT drain the original default store captures",
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
// Explicit authorized original-default flush drains them.
|
|
1509
|
+
await orch.flushSession("flat-sess", {
|
|
1510
|
+
reason: "access_force_flush",
|
|
1511
|
+
failOnExtractionFailure: true,
|
|
1512
|
+
writeNamespaceOverride: "default",
|
|
1513
|
+
principalOverride: "alice",
|
|
1514
|
+
});
|
|
1515
|
+
const drainedDefault = drainCalls.flat().map((t) => t.content);
|
|
1516
|
+
assert.ok(
|
|
1517
|
+
drainedDefault.some((c) => c.includes("flat capture original default")),
|
|
1518
|
+
"original default capture extracts under explicit original-default flush",
|
|
1519
|
+
);
|
|
1520
|
+
assert.ok(
|
|
1521
|
+
drainedDefault.some((c) => c.includes("second flat stays default")),
|
|
1522
|
+
"second flat capture extracts under explicit original-default flush",
|
|
1523
|
+
);
|
|
1524
|
+
} finally {
|
|
1525
|
+
await orch.destroy().catch(() => undefined);
|
|
1526
|
+
await cleanupDir(memoryDir);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
test("#3251 namespaced buffered admission without writeNamespaceOverride or plan rejects with zero side effects", async () => {
|
|
1531
|
+
const memoryDir = await mkTempMemoryDir("observe-ns-missing-target");
|
|
1532
|
+
const config = makeLifecycleConfig(memoryDir, {
|
|
1533
|
+
bufferMaxTurns: 1000,
|
|
1534
|
+
bufferSaveDebounceMs: 0,
|
|
1535
|
+
namespacesEnabled: true,
|
|
1536
|
+
defaultNamespace: "default",
|
|
1537
|
+
});
|
|
1538
|
+
// SAFETY: inline seed storage satisfies SmartBuffer's storage seam.
|
|
1539
|
+
const buffer = new SmartBuffer(config, new SeedStorage() as StorageManager);
|
|
1540
|
+
const lcmCalls: string[] = [];
|
|
1541
|
+
const queued: BufferTurn[][] = [];
|
|
1542
|
+
// SAFETY: admission-guard fixture exercises only the rejection path; the
|
|
1543
|
+
// LCM engine records calls and the remaining deps are unreachable.
|
|
1544
|
+
const deps = {
|
|
1545
|
+
buffer,
|
|
1546
|
+
config,
|
|
1547
|
+
queueBufferedExtraction: async (turns: BufferTurn[]) => {
|
|
1548
|
+
queued.push(turns);
|
|
1549
|
+
},
|
|
1550
|
+
heartbeatObserverChains: new Map(),
|
|
1551
|
+
lcmEngine: {
|
|
1552
|
+
enabled: true,
|
|
1553
|
+
enqueueObserveMessages: (sessionKey: string) => { lcmCalls.push(sessionKey); },
|
|
1554
|
+
observeMessages: async (sessionKey: string) => { lcmCalls.push(sessionKey); },
|
|
1555
|
+
} as any,
|
|
1556
|
+
} as any;
|
|
1557
|
+
const coordinator = new TurnIngestionCoordinator(deps);
|
|
1558
|
+
try {
|
|
1559
|
+
// Namespaces ON, no writeNamespaceOverride, no scopeProfileWritePlan →
|
|
1560
|
+
// must reject BEFORE LCM archive or buffer append.
|
|
1561
|
+
await assert.rejects(
|
|
1562
|
+
coordinator.ingestReplayBatch(
|
|
1563
|
+
[makeReplayTurn("no-target-sess", "capture without target")],
|
|
1564
|
+
bufferedObserveOptions({ sessionOwnerPrincipal: "alice" }),
|
|
1565
|
+
),
|
|
1566
|
+
/requires an explicit storage target/,
|
|
1567
|
+
);
|
|
1568
|
+
assert.equal(lcmCalls.length, 0, "zero LCM archival on rejected admission");
|
|
1569
|
+
assert.equal(queued.length, 0, "zero extraction queued on rejected admission");
|
|
1570
|
+
const pending = (await buffer.findBufferKeysForSession("no-target-sess")).flatMap((key) =>
|
|
1571
|
+
buffer.getTurns(key),
|
|
1572
|
+
);
|
|
1573
|
+
assert.equal(pending.length, 0, "zero buffer appends on rejected admission");
|
|
1574
|
+
} finally {
|
|
1575
|
+
await cleanupDir(memoryDir);
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// ---------------------------------------------------------------------------
|
|
1580
|
+
// #3251 review round fixes: (1) native re-auth uses correct base, (2) native
|
|
1581
|
+
// groups don't borrow observe scope, (3) retainDeferredTurns preserves
|
|
1582
|
+
// other-scope retained turns, (4) filtered flush enables identity clear.
|
|
1583
|
+
// ---------------------------------------------------------------------------
|
|
1584
|
+
|
|
1585
|
+
test("#3251 retainDeferredTurns preserves other-scope retained turns on subsequent defer", async () => {
|
|
1586
|
+
const memoryDir = await mkTempMemoryDir("observe-retain-multi");
|
|
1587
|
+
const config = parseConfig({ memoryDir, bufferSaveDebounceMs: 0 });
|
|
1588
|
+
// SAFETY: inline seed storage satisfies SmartBuffer's storage needs.
|
|
1589
|
+
const buffer = new SmartBuffer(config, { loadBuffer: async () => ({ turns: [], lastExtractionAt: null, extractionCount: 0 }), saveBuffer: async () => {} } as any);
|
|
1590
|
+
const turnA: BufferTurn = { role: "user", content: "scope A deferred context", timestamp: new Date().toISOString(), sessionKey: "multi-sess" };
|
|
1591
|
+
const turnB: BufferTurn = { role: "user", content: "scope B deferred context", timestamp: new Date().toISOString(), sessionKey: "multi-sess" };
|
|
1592
|
+
// Legacy mode (no originalTurns): replace semantics unchanged.
|
|
1593
|
+
await buffer.retainDeferredTurns("multi-sess", [turnA], 10);
|
|
1594
|
+
await buffer.retainDeferredTurns("multi-sess", [turnB], 10);
|
|
1595
|
+
assert.deepEqual(buffer.getRetainedDeferredTurns("multi-sess").map((t) => t.content), ["scope B deferred context"]);
|
|
1596
|
+
// Clear legacy retained turns before testing scoped mode from a clean state.
|
|
1597
|
+
await buffer.retainDeferredTurns("multi-sess", [], 10);
|
|
1598
|
+
assert.equal(buffer.getRetainedDeferredTurns("multi-sess").length, 0);
|
|
1599
|
+
// Scoped mode: A defers (originalTurns=[A]), B defers (originalTurns=[B]).
|
|
1600
|
+
// B's defer must preserve A's retained context (different scope).
|
|
1601
|
+
await buffer.retainDeferredTurns("multi-sess", [turnA], 10, [turnA]);
|
|
1602
|
+
assert.equal(buffer.getRetainedDeferredTurns("multi-sess").length, 1);
|
|
1603
|
+
await buffer.retainDeferredTurns("multi-sess", [turnB], 10, [turnB]);
|
|
1604
|
+
const retained = buffer.getRetainedDeferredTurns("multi-sess").map((t) => t.content);
|
|
1605
|
+
assert.ok(retained.some((c) => c.includes("scope A")), "A's retained context survives B's defer");
|
|
1606
|
+
assert.ok(retained.some((c) => c.includes("scope B")), "B's retained context is present");
|
|
1607
|
+
// Inverse: B defers first, then A.
|
|
1608
|
+
await buffer.retainDeferredTurns("multi-sess", [turnB], 10, [turnB]);
|
|
1609
|
+
await buffer.retainDeferredTurns("multi-sess", [turnA], 10, [turnA]);
|
|
1610
|
+
const retained2 = buffer.getRetainedDeferredTurns("multi-sess").map((t) => t.content);
|
|
1611
|
+
assert.ok(retained2.some((c) => c.includes("scope B")), "B survives A's defer (inverse)");
|
|
1612
|
+
assert.ok(retained2.some((c) => c.includes("scope A")), "A is present (inverse)");
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("#3251 filtered flush with mixed scopes enables identity clear (no repeat LLM on second flush)", async () => {
|
|
1616
|
+
const memoryDir = await mkTempMemoryDir("observe-filter-clear");
|
|
1617
|
+
const makeConfig = () =>
|
|
1618
|
+
makeLifecycleConfig(memoryDir, {
|
|
1619
|
+
bufferMaxTurns: 1000,
|
|
1620
|
+
bufferSaveDebounceMs: 0,
|
|
1621
|
+
extractionMinChars: 1_000_000,
|
|
1622
|
+
extractionMinUserTurns: 1000,
|
|
1623
|
+
namespacesEnabled: true,
|
|
1624
|
+
defaultNamespace: "default",
|
|
1625
|
+
sharedNamespace: "shared",
|
|
1626
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1627
|
+
});
|
|
1628
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
1629
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1630
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1631
|
+
);
|
|
1632
|
+
try {
|
|
1633
|
+
// Unpinned native turn + pinned accepted capture in the same entry.
|
|
1634
|
+
await orchestrator.buffer.addTurn("filter-sess", {
|
|
1635
|
+
role: "user", content: "native unpinned turn",
|
|
1636
|
+
timestamp: new Date().toISOString(), sessionKey: "filter-sess",
|
|
1637
|
+
sessionOwnerPrincipal: "alice",
|
|
1638
|
+
});
|
|
1639
|
+
await orchestrator.ingestReplayBatch(
|
|
1640
|
+
[makeReplayTurn("filter-sess", "pinned accepted capture")],
|
|
1641
|
+
bufferedObserveOptions({
|
|
1642
|
+
writeNamespaceOverride: "ns-a", principalOverride: "alice", sessionOwnerPrincipal: "alice",
|
|
1643
|
+
}),
|
|
1644
|
+
);
|
|
1645
|
+
// Pre-filter: 2 turns. Post-filter: 1 native (pinned excluded by unscoped? no —
|
|
1646
|
+
// unscoped includes all). Use scoped flush: pinned matches, native unpinned also matches
|
|
1647
|
+
// (prior-authorized). But with scopeGroups splitting, 2 groups → needsIdentityClear.
|
|
1648
|
+
await orchestrator.flushSession("filter-sess", {
|
|
1649
|
+
reason: "access_force_flush",
|
|
1650
|
+
failOnExtractionFailure: true,
|
|
1651
|
+
writeNamespaceOverride: "ns-a",
|
|
1652
|
+
principalOverride: "alice",
|
|
1653
|
+
});
|
|
1654
|
+
const firstFlushCount = drainCalls.flat().length;
|
|
1655
|
+
// Second flush: the buffer should be empty (all turns cleared) — no repeat LLM.
|
|
1656
|
+
await orchestrator.flushSession("filter-sess", {
|
|
1657
|
+
reason: "access_force_flush",
|
|
1658
|
+
failOnExtractionFailure: true,
|
|
1659
|
+
writeNamespaceOverride: "ns-a",
|
|
1660
|
+
principalOverride: "alice",
|
|
1661
|
+
});
|
|
1662
|
+
assert.equal(
|
|
1663
|
+
drainCalls.flat().length, firstFlushCount,
|
|
1664
|
+
"second flush must not repeat LLM (identity clear removed all turns)",
|
|
1665
|
+
);
|
|
1666
|
+
} finally {
|
|
1667
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1668
|
+
await cleanupDir(memoryDir);
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
test("#3251 mixed accepted+native groups: accepted-no-plan dispatches null, native dispatches undefined", async () => {
|
|
1673
|
+
const memoryDir = await mkTempMemoryDir("observe-mixed-dispatch");
|
|
1674
|
+
const config = parseConfig({
|
|
1675
|
+
memoryDir,
|
|
1676
|
+
bufferMaxTurns: 1,
|
|
1677
|
+
bufferSaveDebounceMs: 0,
|
|
1678
|
+
namespacesEnabled: true,
|
|
1679
|
+
defaultNamespace: "default",
|
|
1680
|
+
});
|
|
1681
|
+
// SAFETY: inline seed storage satisfies SmartBuffer's storage seam.
|
|
1682
|
+
const buffer = new SmartBuffer(config, new SeedStorage() as StorageManager);
|
|
1683
|
+
interface QueuedDispatch {
|
|
1684
|
+
turns: BufferTurn[];
|
|
1685
|
+
opts: { scopeProfileWritePlan?: ResolvedScopeProfilePlan | null };
|
|
1686
|
+
}
|
|
1687
|
+
const queued: QueuedDispatch[] = [];
|
|
1688
|
+
// SAFETY: mixed-dispatch fixture exercises only the scope-group routing.
|
|
1689
|
+
const coordinator = new TurnIngestionCoordinator({
|
|
1690
|
+
buffer,
|
|
1691
|
+
config,
|
|
1692
|
+
queueBufferedExtraction: async (turns: BufferTurn[], _reason: string, opts?: { scopeProfileWritePlan?: ResolvedScopeProfilePlan | null }) => {
|
|
1693
|
+
queued.push({ turns, opts: opts ?? {} });
|
|
1694
|
+
},
|
|
1695
|
+
} as any);
|
|
1696
|
+
try {
|
|
1697
|
+
// Seed: accepted capture with explicit null plan + native unpinned turn.
|
|
1698
|
+
await buffer.addTurn("mix-dispatch-sess", {
|
|
1699
|
+
role: "user",
|
|
1700
|
+
content: "accepted no plan capture",
|
|
1701
|
+
timestamp: new Date().toISOString(),
|
|
1702
|
+
sessionKey: "mix-dispatch-sess",
|
|
1703
|
+
sessionOwnerPrincipal: "alice",
|
|
1704
|
+
acceptedCapture: true,
|
|
1705
|
+
writeNamespaceOverride: "ns-a",
|
|
1706
|
+
scopeProfileWritePlan: null,
|
|
1707
|
+
} satisfies BufferTurn);
|
|
1708
|
+
await buffer.addTurn("mix-dispatch-sess", {
|
|
1709
|
+
role: "user",
|
|
1710
|
+
content: "native unpinned trigger turn",
|
|
1711
|
+
timestamp: new Date().toISOString(),
|
|
1712
|
+
sessionKey: "mix-dispatch-sess",
|
|
1713
|
+
sessionOwnerPrincipal: "alice",
|
|
1714
|
+
} satisfies BufferTurn);
|
|
1715
|
+
|
|
1716
|
+
// Trigger via processTurn (native trigger on the mixed batch).
|
|
1717
|
+
await coordinator.processTurn("user", "trigger turn", "mix-dispatch-sess");
|
|
1718
|
+
|
|
1719
|
+
// Three scope groups: accepted-null-plan, native-unpinned, trigger-unpinned.
|
|
1720
|
+
const acceptedCall = queued.find(
|
|
1721
|
+
(q) => q.opts.scopeProfileWritePlan === null,
|
|
1722
|
+
);
|
|
1723
|
+
assert.ok(acceptedCall, "accepted group must dispatch scopeProfileWritePlan=null (explicit none)");
|
|
1724
|
+
const nativeCall = queued.find(
|
|
1725
|
+
(q) => q.opts.scopeProfileWritePlan === undefined && !q.turns.some((t) => t.acceptedCapture === true),
|
|
1726
|
+
);
|
|
1727
|
+
assert.ok(nativeCall, "native group must dispatch scopeProfileWritePlan=undefined (auto-derive)");
|
|
1728
|
+
const allAccepted = queued.filter((q) => q.turns.some((t) => t.acceptedCapture === true));
|
|
1729
|
+
assert.ok(
|
|
1730
|
+
allAccepted.every((q) => q.opts.scopeProfileWritePlan !== undefined),
|
|
1731
|
+
"no accepted group may dispatch undefined (which would re-resolve CURRENT profile)",
|
|
1732
|
+
);
|
|
1733
|
+
} finally {
|
|
1734
|
+
await cleanupDir(memoryDir);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
test("#3251 native group clears via identity after accepted prefix remains in buffer", async () => {
|
|
1739
|
+
const memoryDir = await mkTempMemoryDir("observe-native-clear");
|
|
1740
|
+
const config = parseConfig({
|
|
1741
|
+
memoryDir,
|
|
1742
|
+
bufferMaxTurns: 2,
|
|
1743
|
+
bufferSaveDebounceMs: 0,
|
|
1744
|
+
namespacesEnabled: true,
|
|
1745
|
+
defaultNamespace: "default",
|
|
1746
|
+
});
|
|
1747
|
+
// SAFETY: inline seed storage satisfies SmartBuffer's storage seam.
|
|
1748
|
+
const buffer = new SmartBuffer(config, new SeedStorage() as StorageManager);
|
|
1749
|
+
interface QueuedNativeCall {
|
|
1750
|
+
turns: BufferTurn[];
|
|
1751
|
+
opts: { clearMatchingTurns?: boolean };
|
|
1752
|
+
}
|
|
1753
|
+
const queued: QueuedNativeCall[] = [];
|
|
1754
|
+
// SAFETY: native-clear fixture exercises only the scope-group routing.
|
|
1755
|
+
const coordinator = new TurnIngestionCoordinator({
|
|
1756
|
+
buffer,
|
|
1757
|
+
config,
|
|
1758
|
+
queueBufferedExtraction: async (turns: BufferTurn[], _reason: string, opts?: { clearMatchingTurns?: boolean }) => {
|
|
1759
|
+
queued.push({ turns, opts: opts ?? {} });
|
|
1760
|
+
},
|
|
1761
|
+
} as any);
|
|
1762
|
+
try {
|
|
1763
|
+
// Seed buffer: accepted capture FIRST (prefix), then native unpinned (suffix).
|
|
1764
|
+
await buffer.addTurn("clear-sess", {
|
|
1765
|
+
role: "user",
|
|
1766
|
+
content: "accepted prefix stays",
|
|
1767
|
+
timestamp: new Date().toISOString(),
|
|
1768
|
+
sessionKey: "clear-sess",
|
|
1769
|
+
sessionOwnerPrincipal: "alice",
|
|
1770
|
+
acceptedCapture: true,
|
|
1771
|
+
writeNamespaceOverride: "ns-a",
|
|
1772
|
+
} satisfies BufferTurn);
|
|
1773
|
+
await buffer.addTurn("clear-sess", {
|
|
1774
|
+
role: "user",
|
|
1775
|
+
content: "native suffix must clear",
|
|
1776
|
+
timestamp: new Date().toISOString(),
|
|
1777
|
+
sessionKey: "clear-sess",
|
|
1778
|
+
sessionOwnerPrincipal: "alice",
|
|
1779
|
+
} satisfies BufferTurn);
|
|
1780
|
+
|
|
1781
|
+
// Trigger: both groups form (accepted prefix + native suffix).
|
|
1782
|
+
await coordinator.processTurn("user", "trigger turn", "clear-sess");
|
|
1783
|
+
|
|
1784
|
+
// Native group extracted the suffix — it must ACTUALLY clear from the
|
|
1785
|
+
// buffer via identity matching (not prefix matching which fails because
|
|
1786
|
+
// the accepted prefix occupies the head).
|
|
1787
|
+
const nativeCall = queued.find(
|
|
1788
|
+
(q) => q.turns.some((t) => !t.acceptedCapture) && !q.turns.some((t) => t.acceptedCapture === true),
|
|
1789
|
+
);
|
|
1790
|
+
assert.ok(nativeCall, "native group queued");
|
|
1791
|
+
assert.equal(nativeCall.opts.clearMatchingTurns, true, "native group uses identity clear");
|
|
1792
|
+
|
|
1793
|
+
// Simulate the extraction completing (which calls clearAfterExtraction
|
|
1794
|
+
// with identity matching). The accepted prefix must survive.
|
|
1795
|
+
const liveTurns = (await buffer.findBufferKeysForSession("clear-sess")).flatMap((key) =>
|
|
1796
|
+
buffer.getTurns(key),
|
|
1797
|
+
);
|
|
1798
|
+
assert.ok(
|
|
1799
|
+
liveTurns.some((t) => t.content.includes("accepted prefix stays")),
|
|
1800
|
+
"accepted prefix survives the native group's extraction",
|
|
1801
|
+
);
|
|
1802
|
+
} finally {
|
|
1803
|
+
await cleanupDir(memoryDir);
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("#3251 scoped defer then successful retry preserves other retained turns across restart", async () => {
|
|
1808
|
+
const memoryDir = await mkTempMemoryDir("observe-retain-success");
|
|
1809
|
+
const config = makeLifecycleConfig(memoryDir, { bufferSaveDebounceMs: 0 });
|
|
1810
|
+
const orchestrator = new Orchestrator(config);
|
|
1811
|
+
const buffer = orchestrator.buffer;
|
|
1812
|
+
let restarted: Orchestrator | undefined;
|
|
1813
|
+
const turnA: BufferTurn = {
|
|
1814
|
+
role: "user", content: " scope A deferred ",
|
|
1815
|
+
timestamp: new Date().toISOString(), sessionKey: "sr-sess",
|
|
1816
|
+
};
|
|
1817
|
+
const turnB: BufferTurn = { ...turnA, content: " scope B deferred " };
|
|
1818
|
+
const normalizedA = { ...turnA, content: turnA.content.trim() };
|
|
1819
|
+
const normalizedB = { ...turnB, content: turnB.content.trim() };
|
|
1820
|
+
const retain = (
|
|
1821
|
+
originalTurns: BufferTurn[],
|
|
1822
|
+
normalizedTurns: BufferTurn[],
|
|
1823
|
+
deferredCount: number,
|
|
1824
|
+
) => runExtractionDeferRetention(
|
|
1825
|
+
async (_stage, operation) => { await operation(); },
|
|
1826
|
+
{
|
|
1827
|
+
clearBufferAfterExtraction: true,
|
|
1828
|
+
extractionJudgeShadow: false,
|
|
1829
|
+
getDeferredCount: () => deferredCount,
|
|
1830
|
+
normalizedTurns,
|
|
1831
|
+
bufferKey: "sr-sess",
|
|
1832
|
+
retainDeferredTurns: (key, turns, max) =>
|
|
1833
|
+
buffer.retainDeferredTurns(key, turns, max, originalTurns),
|
|
1834
|
+
},
|
|
1835
|
+
);
|
|
1836
|
+
try {
|
|
1837
|
+
await retain([turnA], [normalizedA], 1);
|
|
1838
|
+
await retain([turnB], [normalizedB], 1);
|
|
1839
|
+
assert.deepEqual(buffer.getRetainedDeferredTurns("sr-sess"), [normalizedA, normalizedB]);
|
|
1840
|
+
// A changed normalization limit must not prevent retiring B's original snapshot.
|
|
1841
|
+
await retain([normalizedB], [{ ...normalizedB, content: "scope B" }], 0);
|
|
1842
|
+
assert.deepEqual(buffer.getRetainedDeferredTurns("sr-sess"), [normalizedA]);
|
|
1843
|
+
await buffer.flushPendingSave({ throwOnFailure: true });
|
|
1844
|
+
restarted = new Orchestrator(config);
|
|
1845
|
+
await restarted.buffer.load();
|
|
1846
|
+
assert.deepEqual(restarted.buffer.getRetainedDeferredTurns("sr-sess"), [normalizedA]);
|
|
1847
|
+
} finally {
|
|
1848
|
+
await restarted?.destroy();
|
|
1849
|
+
await orchestrator.destroy();
|
|
1850
|
+
await cleanupDir(memoryDir);
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
test("#3251 scoped flush preserves >10 outside-scope retained turns (no global max10 truncation)", async () => {
|
|
1855
|
+
const memoryDir = await mkTempMemoryDir("observe-retain-overflow");
|
|
1856
|
+
const makeConfig = () =>
|
|
1857
|
+
makeLifecycleConfig(memoryDir, {
|
|
1858
|
+
bufferMaxTurns: 1000,
|
|
1859
|
+
bufferSaveDebounceMs: 0,
|
|
1860
|
+
extractionMinChars: 1_000_000,
|
|
1861
|
+
extractionMinUserTurns: 1000,
|
|
1862
|
+
namespacesEnabled: true,
|
|
1863
|
+
defaultNamespace: "default",
|
|
1864
|
+
sharedNamespace: "shared",
|
|
1865
|
+
namespacePolicies: [{ name: "ns-a", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1866
|
+
});
|
|
1867
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
1868
|
+
const drainCalls = stubExtraction(orchestrator, (turns) =>
|
|
1869
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1870
|
+
);
|
|
1871
|
+
try {
|
|
1872
|
+
// Capture A's pinned accepted capture (own scope).
|
|
1873
|
+
await orchestrator.ingestReplayBatch(
|
|
1874
|
+
[makeReplayTurn("overflow-sess", "own scope capture")],
|
|
1875
|
+
bufferedObserveOptions({
|
|
1876
|
+
writeNamespaceOverride: "ns-a",
|
|
1877
|
+
principalOverride: "alice",
|
|
1878
|
+
sessionOwnerPrincipal: "alice",
|
|
1879
|
+
}),
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
// Seed >10 outside-scope retained turns in one call (scope = "other").
|
|
1883
|
+
const outsideTurns: BufferTurn[] = Array.from({ length: 15 }, (_, i) => ({
|
|
1884
|
+
role: "user" as const,
|
|
1885
|
+
content: `outside scope retained ${i}`,
|
|
1886
|
+
timestamp: new Date().toISOString(),
|
|
1887
|
+
sessionKey: `other-sess-${i}`,
|
|
1888
|
+
sessionOwnerPrincipal: "bob",
|
|
1889
|
+
acceptedCapture: true,
|
|
1890
|
+
writeNamespaceOverride: "other-ns",
|
|
1891
|
+
}));
|
|
1892
|
+
await orchestrator.buffer.retainDeferredTurns("overflow-sess", outsideTurns, outsideTurns.length);
|
|
1893
|
+
assert.deepEqual(orchestrator.buffer.getRetainedDeferredTurns("overflow-sess"), outsideTurns);
|
|
1894
|
+
|
|
1895
|
+
// Flush A: the scoped retainDeferredTurns at extraction-run preserves
|
|
1896
|
+
// other-scope retained turns — >10 must not be truncated.
|
|
1897
|
+
await orchestrator.flushSession("overflow-sess", {
|
|
1898
|
+
reason: "access_force_flush",
|
|
1899
|
+
failOnExtractionFailure: true,
|
|
1900
|
+
writeNamespaceOverride: "ns-a",
|
|
1901
|
+
principalOverride: "alice",
|
|
1902
|
+
});
|
|
1903
|
+
assert.deepEqual(
|
|
1904
|
+
orchestrator.buffer.getRetainedDeferredTurns("overflow-sess"),
|
|
1905
|
+
outsideTurns,
|
|
1906
|
+
"all 15 outside-scope retained turns must survive the scoped flush",
|
|
1907
|
+
);
|
|
1908
|
+
} finally {
|
|
1909
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
1910
|
+
await cleanupDir(memoryDir);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
for (const capture of ["accepted", "native"] as const) {
|
|
1915
|
+
test(`#3251 profile-scoped force flush drains ${capture} no-plan turns without changing their contract`, async () => {
|
|
1916
|
+
const memoryDir = await mkTempMemoryDir("observe-no-plan-flush");
|
|
1917
|
+
const orchestrator = new Orchestrator(makeLifecycleConfig(memoryDir, {
|
|
1918
|
+
bufferMaxTurns: 1000,
|
|
1919
|
+
bufferSaveDebounceMs: 0,
|
|
1920
|
+
extractionMinChars: 1_000_000,
|
|
1921
|
+
extractionMinUserTurns: 1000,
|
|
1922
|
+
namespacesEnabled: true,
|
|
1923
|
+
defaultNamespace: "default",
|
|
1924
|
+
sharedNamespace: "shared",
|
|
1925
|
+
namespacePolicies: [{ name: "shared", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1926
|
+
}));
|
|
1927
|
+
const calls = stubExtraction(orchestrator, (turns) =>
|
|
1928
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
1929
|
+
);
|
|
1930
|
+
const runExtraction = orchestrator["runExtraction"].bind(orchestrator);
|
|
1931
|
+
const plans: Array<ResolvedScopeProfilePlan | null | undefined> = [];
|
|
1932
|
+
orchestrator["runExtraction"] = async (turns, options) => {
|
|
1933
|
+
plans.push(options?.scopeProfileWritePlan);
|
|
1934
|
+
return runExtraction(turns, options);
|
|
1935
|
+
};
|
|
1936
|
+
try {
|
|
1937
|
+
if (capture === "accepted") {
|
|
1938
|
+
await orchestrator.ingestReplayBatch(
|
|
1939
|
+
[makeReplayTurn("no-plan-sess", "explicit shared capture without a profile")],
|
|
1940
|
+
bufferedObserveOptions({
|
|
1941
|
+
writeNamespaceOverride: "shared",
|
|
1942
|
+
principalOverride: "alice",
|
|
1943
|
+
sessionOwnerPrincipal: "alice",
|
|
1944
|
+
}),
|
|
1945
|
+
);
|
|
1946
|
+
} else {
|
|
1947
|
+
await orchestrator.buffer.addTurn("no-plan-sess", {
|
|
1948
|
+
role: "user", content: "ordinary native turn without capture pins",
|
|
1949
|
+
timestamp: new Date().toISOString(), sessionKey: "no-plan-sess",
|
|
1950
|
+
sessionOwnerPrincipal: "alice",
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
const keys = await orchestrator.buffer.findBufferKeysForSession("no-plan-sess");
|
|
1954
|
+
assert.equal(keys.length, 1);
|
|
1955
|
+
const captured = orchestrator.buffer.getTurns(keys[0]);
|
|
1956
|
+
assert.equal(captured.length, 1);
|
|
1957
|
+
assert.equal(captured[0].scopeProfileWritePlan, undefined);
|
|
1958
|
+
activateProfile(orchestrator.config, "shared-profile");
|
|
1959
|
+
orchestrator.config.scopeProfiles!["shared-profile"].readOrder = ["serverShared"];
|
|
1960
|
+
orchestrator.config.scopeProfiles!["shared-profile"].writeDefault = "serverShared";
|
|
1961
|
+
const currentPlan = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
1962
|
+
assert.ok(currentPlan);
|
|
1963
|
+
assert.equal(currentPlan.writeNamespace, "shared");
|
|
1964
|
+
|
|
1965
|
+
const response = await new EngramAccessService(orchestrator).extractionForceFlush({
|
|
1966
|
+
sessionKey: "no-plan-sess",
|
|
1967
|
+
authenticatedPrincipal: "alice",
|
|
1968
|
+
deadlineMs: 10_000,
|
|
1969
|
+
});
|
|
1970
|
+
assert.equal(response.flushed, true);
|
|
1971
|
+
assert.equal(calls.length, 1, "the matching session must actually drain");
|
|
1972
|
+
assert.deepEqual(plans, [capture === "accepted" ? null : currentPlan]);
|
|
1973
|
+
assert.deepEqual(orchestrator.buffer.getTurns(keys[0]), []);
|
|
1974
|
+
|
|
1975
|
+
await orchestrator.buffer.retainDeferredTurns(keys[0], captured, 10);
|
|
1976
|
+
await orchestrator.buffer.clearRetainedTurnsForSession("no-plan-sess", "alice", {
|
|
1977
|
+
writeNamespaceOverride: "shared",
|
|
1978
|
+
scopeProfileWritePlan: currentPlan,
|
|
1979
|
+
});
|
|
1980
|
+
assert.deepEqual(orchestrator.buffer.getRetainedDeferredTurns(keys[0]), []);
|
|
1981
|
+
} finally {
|
|
1982
|
+
await orchestrator.destroy();
|
|
1983
|
+
await cleanupDir(memoryDir);
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
test("#3251 automatic accepted-capture drain respects the provider retry cooldown; explicit flush retries", async () => {
|
|
1989
|
+
const memoryDir = await mkTempMemoryDir("observe-retry-cooldown");
|
|
1990
|
+
const makeConfig = () =>
|
|
1991
|
+
makeLifecycleConfig(memoryDir, {
|
|
1992
|
+
bufferMaxTurns: 1,
|
|
1993
|
+
bufferSaveDebounceMs: 0,
|
|
1994
|
+
namespacesEnabled: true,
|
|
1995
|
+
defaultNamespace: "default",
|
|
1996
|
+
sharedNamespace: "shared",
|
|
1997
|
+
namespacePolicies: [{ name: "shared", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
1998
|
+
// Existing retry seams (extraction-run retry suite config): one
|
|
1999
|
+
// provider_retryable failure parks the fingerprint AND opens the breaker.
|
|
2000
|
+
extractionRetryScheduleMs: [3_600_000],
|
|
2001
|
+
extractionBreakerFailureThreshold: 1,
|
|
2002
|
+
extractionBreakerCooldownMs: 3_600_000,
|
|
2003
|
+
});
|
|
2004
|
+
const orchestrator = new Orchestrator(makeConfig());
|
|
2005
|
+
try {
|
|
2006
|
+
const calls = stubExtraction(orchestrator, (_turns, call) =>
|
|
2007
|
+
call === 1 ? providerRetryableFailure() : singleFactResult("recovered drain fact"),
|
|
2008
|
+
);
|
|
2009
|
+
|
|
2010
|
+
// Accepted shared capture: the native buffer trigger drains it
|
|
2011
|
+
// automatically, the extraction fails provider_retryable, and the
|
|
2012
|
+
// capture stays durably pending.
|
|
2013
|
+
await orchestrator.ingestReplayBatch(
|
|
2014
|
+
[makeReplayTurn("retry-sess", "capture one payload")],
|
|
2015
|
+
bufferedObserveOptions({
|
|
2016
|
+
writeNamespaceOverride: "shared",
|
|
2017
|
+
principalOverride: "alice",
|
|
2018
|
+
sessionOwnerPrincipal: "alice",
|
|
2019
|
+
}),
|
|
2020
|
+
);
|
|
2021
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
2022
|
+
assert.equal(calls.length, 1, "first automatic drain attempts extraction");
|
|
2023
|
+
assert.equal(orchestrator.getExtractionResilienceStatus().breaker.state, "open");
|
|
2024
|
+
|
|
2025
|
+
// Second accepted capture, second automatic native trigger: the circuit
|
|
2026
|
+
// cooldown suppresses it — no extra backend call, capture stays pending.
|
|
2027
|
+
await orchestrator.ingestReplayBatch(
|
|
2028
|
+
[makeReplayTurn("retry-sess", "capture two payload")],
|
|
2029
|
+
bufferedObserveOptions({
|
|
2030
|
+
writeNamespaceOverride: "shared",
|
|
2031
|
+
principalOverride: "alice",
|
|
2032
|
+
sessionOwnerPrincipal: "alice",
|
|
2033
|
+
}),
|
|
2034
|
+
);
|
|
2035
|
+
await orchestrator.waitForExtractionIdle(10_000);
|
|
2036
|
+
assert.equal(
|
|
2037
|
+
calls.length,
|
|
2038
|
+
1,
|
|
2039
|
+
"automatic trigger must respect the circuit cooldown (no extra backend call)",
|
|
2040
|
+
);
|
|
2041
|
+
const pending = (await orchestrator.buffer.findBufferKeysForSession("retry-sess")).flatMap((key) =>
|
|
2042
|
+
orchestrator.buffer.getTurns(key),
|
|
2043
|
+
);
|
|
2044
|
+
assert.ok(pending.some((turn) => turn.content.includes("capture one payload")), "failed capture retained");
|
|
2045
|
+
assert.ok(pending.some((turn) => turn.content.includes("capture two payload")), "new capture retained");
|
|
2046
|
+
|
|
2047
|
+
// Same-namespace scope for the flush (serverShared → "shared"), matching
|
|
2048
|
+
// the capture pins; the explicit flush is forced past the open circuit.
|
|
2049
|
+
activateProfile(orchestrator.config, "shared-profile");
|
|
2050
|
+
orchestrator.config.scopeProfiles!["shared-profile"].readOrder = ["serverShared"];
|
|
2051
|
+
orchestrator.config.scopeProfiles!["shared-profile"].writeDefault = "serverShared";
|
|
2052
|
+
const currentPlan = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
2053
|
+
assert.ok(currentPlan);
|
|
2054
|
+
assert.equal(currentPlan.writeNamespace, "shared");
|
|
2055
|
+
const response = await new EngramAccessService(orchestrator).extractionForceFlush({
|
|
2056
|
+
sessionKey: "retry-sess",
|
|
2057
|
+
authenticatedPrincipal: "alice",
|
|
2058
|
+
deadlineMs: 10_000,
|
|
2059
|
+
});
|
|
2060
|
+
assert.equal(response.flushed, true);
|
|
2061
|
+
assert.equal(calls.length, 2, "explicit flush retries despite the open circuit");
|
|
2062
|
+
const afterFlush = (await orchestrator.buffer.findBufferKeysForSession("retry-sess")).flatMap((key) =>
|
|
2063
|
+
orchestrator.buffer.getTurns(key),
|
|
2064
|
+
);
|
|
2065
|
+
assert.deepEqual(afterFlush, []);
|
|
2066
|
+
} finally {
|
|
2067
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
2068
|
+
await cleanupDir(memoryDir);
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
test("#3251 accepted context-unbound shared capture drains under a later-attached project", async () => {
|
|
2073
|
+
const memoryDir = await mkTempMemoryDir("observe-unbound-context-flush");
|
|
2074
|
+
const orchestrator = new Orchestrator(makeLifecycleConfig(memoryDir, {
|
|
2075
|
+
bufferMaxTurns: 1000,
|
|
2076
|
+
bufferSaveDebounceMs: 0,
|
|
2077
|
+
extractionMinChars: 1_000_000,
|
|
2078
|
+
extractionMinUserTurns: 1000,
|
|
2079
|
+
namespacesEnabled: true,
|
|
2080
|
+
defaultNamespace: "default",
|
|
2081
|
+
sharedNamespace: "shared",
|
|
2082
|
+
namespacePolicies: [{ name: "shared", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
2083
|
+
}));
|
|
2084
|
+
const calls = stubExtraction(orchestrator, (turns) =>
|
|
2085
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
2086
|
+
);
|
|
2087
|
+
const runExtraction = orchestrator["runExtraction"].bind(orchestrator);
|
|
2088
|
+
const plans: Array<ResolvedScopeProfilePlan | null | undefined> = [];
|
|
2089
|
+
orchestrator["runExtraction"] = async (turns, options) => {
|
|
2090
|
+
plans.push(options?.scopeProfileWritePlan);
|
|
2091
|
+
return runExtraction(turns, options);
|
|
2092
|
+
};
|
|
2093
|
+
try {
|
|
2094
|
+
// Genuine accepted shared capture with NO profile plan and NO project context.
|
|
2095
|
+
await orchestrator.ingestReplayBatch(
|
|
2096
|
+
[makeReplayTurn("unbound-sess", "shared capture taken before the project existed")],
|
|
2097
|
+
bufferedObserveOptions({
|
|
2098
|
+
writeNamespaceOverride: "shared",
|
|
2099
|
+
principalOverride: "alice",
|
|
2100
|
+
sessionOwnerPrincipal: "alice",
|
|
2101
|
+
}),
|
|
2102
|
+
);
|
|
2103
|
+
const keys = await orchestrator.buffer.findBufferKeysForSession("unbound-sess");
|
|
2104
|
+
assert.equal(keys.length, 1);
|
|
2105
|
+
const captured = orchestrator.buffer.getTurns(keys[0]);
|
|
2106
|
+
assert.equal(captured.length, 1);
|
|
2107
|
+
assert.equal(captured[0].scopeProfileWritePlan, undefined);
|
|
2108
|
+
assert.equal(captured[0].codingContext, undefined);
|
|
2109
|
+
|
|
2110
|
+
// Project attached AFTER capture; current profile is serverShared on the
|
|
2111
|
+
// SAME write namespace, so only the binding comparison could skip it.
|
|
2112
|
+
activateProfile(orchestrator.config, "shared-profile");
|
|
2113
|
+
orchestrator.config.scopeProfiles!["shared-profile"].readOrder = ["serverShared"];
|
|
2114
|
+
orchestrator.config.scopeProfiles!["shared-profile"].writeDefault = "serverShared";
|
|
2115
|
+
const currentPlan = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
2116
|
+
assert.ok(currentPlan);
|
|
2117
|
+
assert.equal(currentPlan.writeNamespace, "shared");
|
|
2118
|
+
const lateContext = { projectId: "proj-late", branch: null, rootPath: "/repo-proj-late", defaultBranch: null };
|
|
2119
|
+
orchestrator.setCodingContextForSession("unbound-sess", lateContext);
|
|
2120
|
+
|
|
2121
|
+
const response = await new EngramAccessService(orchestrator).extractionForceFlush({
|
|
2122
|
+
sessionKey: "unbound-sess",
|
|
2123
|
+
authenticatedPrincipal: "alice",
|
|
2124
|
+
deadlineMs: 10_000,
|
|
2125
|
+
});
|
|
2126
|
+
assert.equal(response.flushed, true);
|
|
2127
|
+
assert.equal(calls.length, 1, "context-unbound accepted capture must actually extract");
|
|
2128
|
+
assert.equal(plans[0], null, "accepted no-plan capture dispatches its explicit null plan");
|
|
2129
|
+
assert.deepEqual(orchestrator.buffer.getTurns(keys[0]), []);
|
|
2130
|
+
|
|
2131
|
+
// Retained cleanup: own-scope retained copies with no original binding
|
|
2132
|
+
// clean under the same bound-flush args the force flush resolves.
|
|
2133
|
+
await orchestrator.buffer.retainDeferredTurns(keys[0], captured, 10);
|
|
2134
|
+
await orchestrator.buffer.clearRetainedTurnsForSession("unbound-sess", "alice", {
|
|
2135
|
+
writeNamespaceOverride: "shared",
|
|
2136
|
+
scopeProfileWritePlan: currentPlan,
|
|
2137
|
+
codingContext: lateContext,
|
|
2138
|
+
});
|
|
2139
|
+
assert.deepEqual(orchestrator.buffer.getRetainedDeferredTurns(keys[0]), []);
|
|
2140
|
+
} finally {
|
|
2141
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
2142
|
+
await cleanupDir(memoryDir);
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
test("#3251 profile-pinned context-unbound shared capture drains under unchanged profile after project attach", async () => {
|
|
2147
|
+
const memoryDir = await mkTempMemoryDir("observe-pinned-unbound-flush");
|
|
2148
|
+
const orchestrator = new Orchestrator(makeLifecycleConfig(memoryDir, {
|
|
2149
|
+
bufferMaxTurns: 1000,
|
|
2150
|
+
bufferSaveDebounceMs: 0,
|
|
2151
|
+
extractionMinChars: 1_000_000,
|
|
2152
|
+
extractionMinUserTurns: 1000,
|
|
2153
|
+
namespacesEnabled: true,
|
|
2154
|
+
defaultNamespace: "default",
|
|
2155
|
+
sharedNamespace: "shared",
|
|
2156
|
+
namespacePolicies: [{ name: "shared", readPrincipals: ["alice"], writePrincipals: ["alice"] }],
|
|
2157
|
+
}));
|
|
2158
|
+
const calls = stubExtraction(orchestrator, (turns) =>
|
|
2159
|
+
singleFactResult(turns.map((turn) => turn.content).join(" | ")),
|
|
2160
|
+
);
|
|
2161
|
+
const runExtraction = orchestrator["runExtraction"].bind(orchestrator);
|
|
2162
|
+
const plans: Array<ResolvedScopeProfilePlan | null | undefined> = [];
|
|
2163
|
+
orchestrator["runExtraction"] = async (turns, options) => {
|
|
2164
|
+
plans.push(options?.scopeProfileWritePlan);
|
|
2165
|
+
return runExtraction(turns, options);
|
|
2166
|
+
};
|
|
2167
|
+
try {
|
|
2168
|
+
// PROFILE-PINNED but context-unbound serverShared capture.
|
|
2169
|
+
activateProfile(orchestrator.config, "shared-profile");
|
|
2170
|
+
orchestrator.config.scopeProfiles!["shared-profile"].readOrder = ["serverShared"];
|
|
2171
|
+
orchestrator.config.scopeProfiles!["shared-profile"].writeDefault = "serverShared";
|
|
2172
|
+
const originalPlan = resolveScopeProfilePlan({ config: orchestrator.config, principal: "alice" });
|
|
2173
|
+
assert.ok(originalPlan);
|
|
2174
|
+
assert.equal(originalPlan.writeNamespace, "shared");
|
|
2175
|
+
await orchestrator.ingestReplayBatch(
|
|
2176
|
+
[makeReplayTurn("pinned-unbound-sess", "profile-pinned capture without project context")],
|
|
2177
|
+
bufferedObserveOptions({
|
|
2178
|
+
writeNamespaceOverride: originalPlan.writeNamespace,
|
|
2179
|
+
principalOverride: "alice",
|
|
2180
|
+
sessionOwnerPrincipal: "alice",
|
|
2181
|
+
scopeProfileWritePlan: originalPlan,
|
|
2182
|
+
}),
|
|
2183
|
+
);
|
|
2184
|
+
const keys = await orchestrator.buffer.findBufferKeysForSession("pinned-unbound-sess");
|
|
2185
|
+
assert.equal(keys.length, 1);
|
|
2186
|
+
const captured = orchestrator.buffer.getTurns(keys[0]);
|
|
2187
|
+
assert.equal(captured[0].scopeProfileWritePlan, originalPlan);
|
|
2188
|
+
assert.equal(captured[0].codingContext, undefined);
|
|
2189
|
+
|
|
2190
|
+
// Project attached later; profile UNCHANGED. Both the per-turn guard and
|
|
2191
|
+
// the per-group guard must not skip the original-unbound capture.
|
|
2192
|
+
orchestrator.setCodingContextForSession("pinned-unbound-sess", {
|
|
2193
|
+
projectId: "proj-late",
|
|
2194
|
+
branch: null,
|
|
2195
|
+
rootPath: "/repo-proj-late",
|
|
2196
|
+
defaultBranch: null,
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
const response = await new EngramAccessService(orchestrator).extractionForceFlush({
|
|
2200
|
+
sessionKey: "pinned-unbound-sess",
|
|
2201
|
+
authenticatedPrincipal: "alice",
|
|
2202
|
+
deadlineMs: 10_000,
|
|
2203
|
+
});
|
|
2204
|
+
assert.equal(response.flushed, true);
|
|
2205
|
+
assert.equal(calls.length, 1, "pinned context-unbound group must drain (group binding gate)");
|
|
2206
|
+
assert.deepEqual(plans[0], originalPlan, "drain keeps the original profile binding after current authorization");
|
|
2207
|
+
assert.deepEqual(orchestrator.buffer.getTurns(keys[0]), []);
|
|
2208
|
+
} finally {
|
|
2209
|
+
await orchestrator.destroy().catch(() => undefined);
|
|
2210
|
+
await cleanupDir(memoryDir);
|
|
2211
|
+
}
|
|
2212
|
+
});
|