@remnic/core 10.0.72 → 10.0.74
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 +14 -14
- package/dist/access-authorization-probe.js +15 -15
- package/dist/access-boundary.js +14 -14
- package/dist/access-cli.js +51 -51
- package/dist/access-extraction-force-flush.js +14 -14
- package/dist/access-http-briefing.js +15 -15
- package/dist/access-http-query.js +16 -16
- package/dist/access-http.js +30 -30
- package/dist/access-identity-continuity-surface.js +14 -14
- package/dist/access-lcm-surface.js +14 -14
- package/dist/access-mcp.js +23 -23
- package/dist/access-namespace-preflight.js +14 -14
- package/dist/access-observe-helpers.d.ts.map +1 -1
- package/dist/access-observe-helpers.js +1 -1
- package/dist/access-observe-write-surface.js +14 -14
- package/dist/access-operations-batch.js +15 -15
- package/dist/access-operations.js +22 -22
- package/dist/access-recall-concurrency.js +14 -14
- package/dist/access-recall-response.js +14 -14
- package/dist/access-recall-surface.js +14 -14
- package/dist/access-service.js +14 -14
- package/dist/access-surface-catalog.js +16 -16
- package/dist/active-recall.js +3 -3
- package/dist/briefing.js +4 -4
- package/dist/buffer-turn-helpers.d.ts +18 -0
- package/dist/buffer-turn-helpers.d.ts.map +1 -1
- package/dist/buffer-turn-helpers.js +5 -1
- package/dist/buffer.js +2 -2
- package/dist/causal-consolidation.js +5 -5
- package/dist/{chunk-AGRMXXVA.js → chunk-2H3SITYD.js} +2 -2
- package/dist/{chunk-ZBET3W26.js → chunk-2WDFLORC.js} +2 -2
- package/dist/{chunk-S73ZVU37.js → chunk-45SFSQON.js} +2 -2
- package/dist/{chunk-OUBEJWVI.js → chunk-4KLQI3L2.js} +42 -1
- package/dist/chunk-4KLQI3L2.js.map +1 -0
- package/dist/{chunk-XPZM7GP6.js → chunk-4SOISEZ3.js} +7 -7
- package/dist/{chunk-QIOQI7QZ.js → chunk-55NKJDKX.js} +2 -2
- package/dist/{chunk-JY4LXMZR.js → chunk-5XOCAJHC.js} +2 -2
- package/dist/{chunk-IQXQ3KWR.js → chunk-62O7YWBR.js} +2 -2
- package/dist/{chunk-S37O4S6E.js → chunk-6EKP7BJW.js} +2 -2
- package/dist/{chunk-VNTQU6KB.js → chunk-7PMOCQOL.js} +8 -13
- package/dist/chunk-7PMOCQOL.js.map +1 -0
- package/dist/{chunk-U6IL4724.js → chunk-A2BH2VE2.js} +2 -2
- package/dist/{chunk-BCBY5GYZ.js → chunk-BEQU6QGW.js} +2 -2
- package/dist/{chunk-2WPWEVAH.js → chunk-CAVXVGYK.js} +2 -2
- package/dist/{chunk-UAOJVQXC.js → chunk-CU5N4RDI.js} +2 -2
- package/dist/{chunk-4PMFSYH7.js → chunk-DHQLVS7J.js} +2 -2
- package/dist/{chunk-X4DHAXFO.js → chunk-DLLQMNYQ.js} +3 -3
- package/dist/{chunk-UE5JYPU6.js → chunk-DQVT45LZ.js} +7 -7
- package/dist/{chunk-JGLZ46JS.js → chunk-DVDRKCZK.js} +2 -2
- package/dist/{chunk-2R44BBPK.js → chunk-FU2ZLXVN.js} +2 -2
- package/dist/{chunk-DC3DWF2M.js → chunk-G7I2RQ64.js} +2 -2
- package/dist/{chunk-H2X32F2K.js → chunk-GI47WZWA.js} +2 -2
- package/dist/{chunk-PJAMFIDE.js → chunk-GJOBCNST.js} +3 -3
- package/dist/{chunk-23BA3USG.js → chunk-GO2GCJ7S.js} +10 -10
- package/dist/{chunk-ZODKV27J.js → chunk-HPCWJMMQ.js} +5 -5
- package/dist/{chunk-UKQFCOGT.js → chunk-IPHIIRDN.js} +3 -3
- package/dist/chunk-IPHIIRDN.js.map +1 -0
- package/dist/{chunk-LYOCAUI6.js → chunk-IXBIL5WY.js} +3 -3
- package/dist/{chunk-WNQ3B3Q5.js → chunk-K5Y42U2P.js} +2 -2
- package/dist/{chunk-6KBCUIZP.js → chunk-KFZ3A2EA.js} +2 -2
- package/dist/{chunk-OGQGDOYK.js → chunk-KH2HB3OW.js} +2 -2
- package/dist/{chunk-ICBUTKLL.js → chunk-KPCTZB3U.js} +2 -2
- package/dist/{chunk-BTU77RB5.js → chunk-KWUMXJN7.js} +2 -2
- package/dist/{chunk-5ORJHH43.js → chunk-LC55ZXLR.js} +2 -2
- package/dist/{chunk-QUPTDKSX.js → chunk-LIGA5KHG.js} +16 -16
- package/dist/{chunk-LHMCZ6VW.js → chunk-LUXVW5UU.js} +10 -10
- package/dist/{chunk-7P5VBRBS.js → chunk-MOJ4UDA3.js} +2 -2
- package/dist/{chunk-7WFY2L5J.js → chunk-NHTEEGE4.js} +3 -3
- package/dist/{chunk-OWZXDBV4.js → chunk-OFOV6TTZ.js} +2 -2
- package/dist/{chunk-VK5HKVQ7.js → chunk-OKBGLAAI.js} +2 -2
- package/dist/{chunk-VSJ3ARFS.js → chunk-PUP7NTNB.js} +2 -2
- package/dist/{chunk-DSSTOV5M.js → chunk-Q2QPHGEC.js} +38 -21
- package/dist/chunk-Q2QPHGEC.js.map +1 -0
- package/dist/{chunk-GXAZ4Q2S.js → chunk-QYLEE4UQ.js} +2 -2
- package/dist/{chunk-AZNE2H6D.js → chunk-RQHIN3PY.js} +2 -2
- package/dist/{chunk-USX5YIKB.js → chunk-STDAR7M7.js} +5 -5
- package/dist/{chunk-LN7CWLAU.js → chunk-T64HVBAP.js} +2 -2
- package/dist/{chunk-EGKDONTC.js → chunk-UEY5TPEE.js} +9 -9
- package/dist/chunk-UEY5TPEE.js.map +1 -0
- package/dist/{chunk-YPYSC3TX.js → chunk-V33ETCF6.js} +2 -2
- package/dist/{chunk-J72ZY7NU.js → chunk-VWHWWIFS.js} +2 -2
- package/dist/{chunk-QVZZE6VH.js → chunk-WBMT3C6A.js} +3 -3
- package/dist/{chunk-N2Y6COEM.js → chunk-WPPAXTW6.js} +2 -2
- package/dist/{chunk-W4V7P5HP.js → chunk-XV7PJVQO.js} +2 -2
- package/dist/{chunk-FGGLVCHI.js → chunk-Y2ZGT4W7.js} +2 -2
- package/dist/{chunk-WYOHQJKC.js → chunk-YJX3K5LK.js} +3 -3
- package/dist/cli.js +40 -40
- package/dist/coding/export-okf-codegraph.js +3 -3
- package/dist/compounding/engine.js +5 -5
- package/dist/config.js +3 -3
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/index.js +4 -4
- package/dist/convergence-refresh.js +6 -6
- package/dist/corpus-watermark.js +6 -6
- package/dist/entity-retrieval.js +4 -4
- package/dist/external-wiki-access.js +15 -15
- package/dist/extraction.js +4 -4
- package/dist/harmonic-retrieval.js +4 -4
- package/dist/index.js +51 -51
- package/dist/location/index.js +6 -6
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection-runner.js +6 -6
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/memory-browse-operations.js +15 -15
- package/dist/namespaces/migrate.js +5 -5
- package/dist/namespaces/storage.js +4 -4
- package/dist/operator-doctor-corpus.js +7 -7
- package/dist/operator-toolkit.js +12 -12
- package/dist/orchestration/backlog-scheduler.js +52 -52
- package/dist/orchestration/buffer-drain-runner.js +52 -52
- package/dist/orchestration/extraction-queue-coordinator.d.ts +6 -0
- package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
- package/dist/orchestration/maintenance.js +14 -14
- package/dist/orchestrator.js +51 -51
- package/dist/recall-navigation-operations.js +15 -15
- package/dist/recall-navigation.js +14 -14
- package/dist/resume-bundles.js +3 -3
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/source-agent-qualifier.js +4 -4
- package/dist/standup/build.js +4 -4
- package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
- package/dist/storage/buffer-drain-claims.js +2 -2
- package/dist/storage/buffer-session-files.js +2 -2
- package/dist/storage.js +3 -3
- package/dist/support-passport/index.js +17 -17
- package/dist/transfer/export-okf.js +4 -4
- package/dist/verified-recall.js +4 -4
- package/dist/who-knows-cli.js +5 -5
- package/dist/who-knows.js +4 -4
- package/package.json +2 -2
- package/src/access-observe-acceptance-boundary.test.ts +24 -18
- package/src/access-observe-helpers.test.ts +72 -0
- package/src/access-observe-helpers.ts +5 -2
- package/src/access-service-observe-lcm-parity.test.ts +9 -2
- package/src/access-service-observe-scope.test.ts +8 -1
- package/src/buffer-record-snapshots.test.ts +195 -0
- package/src/buffer-turn-helpers.test.ts +205 -0
- package/src/buffer-turn-helpers.ts +62 -0
- package/src/buffer.ts +8 -8
- package/src/orchestration/extraction-queue-coordinator.ts +18 -1
- package/src/orchestrator-extraction-queue.test.ts +142 -0
- package/src/storage/buffer-drain-claims.ts +5 -11
- package/dist/chunk-DSSTOV5M.js.map +0 -1
- package/dist/chunk-EGKDONTC.js.map +0 -1
- package/dist/chunk-OUBEJWVI.js.map +0 -1
- package/dist/chunk-UKQFCOGT.js.map +0 -1
- package/dist/chunk-VNTQU6KB.js.map +0 -1
- /package/dist/{chunk-AGRMXXVA.js.map → chunk-2H3SITYD.js.map} +0 -0
- /package/dist/{chunk-ZBET3W26.js.map → chunk-2WDFLORC.js.map} +0 -0
- /package/dist/{chunk-S73ZVU37.js.map → chunk-45SFSQON.js.map} +0 -0
- /package/dist/{chunk-XPZM7GP6.js.map → chunk-4SOISEZ3.js.map} +0 -0
- /package/dist/{chunk-QIOQI7QZ.js.map → chunk-55NKJDKX.js.map} +0 -0
- /package/dist/{chunk-JY4LXMZR.js.map → chunk-5XOCAJHC.js.map} +0 -0
- /package/dist/{chunk-IQXQ3KWR.js.map → chunk-62O7YWBR.js.map} +0 -0
- /package/dist/{chunk-S37O4S6E.js.map → chunk-6EKP7BJW.js.map} +0 -0
- /package/dist/{chunk-U6IL4724.js.map → chunk-A2BH2VE2.js.map} +0 -0
- /package/dist/{chunk-BCBY5GYZ.js.map → chunk-BEQU6QGW.js.map} +0 -0
- /package/dist/{chunk-2WPWEVAH.js.map → chunk-CAVXVGYK.js.map} +0 -0
- /package/dist/{chunk-UAOJVQXC.js.map → chunk-CU5N4RDI.js.map} +0 -0
- /package/dist/{chunk-4PMFSYH7.js.map → chunk-DHQLVS7J.js.map} +0 -0
- /package/dist/{chunk-X4DHAXFO.js.map → chunk-DLLQMNYQ.js.map} +0 -0
- /package/dist/{chunk-UE5JYPU6.js.map → chunk-DQVT45LZ.js.map} +0 -0
- /package/dist/{chunk-JGLZ46JS.js.map → chunk-DVDRKCZK.js.map} +0 -0
- /package/dist/{chunk-2R44BBPK.js.map → chunk-FU2ZLXVN.js.map} +0 -0
- /package/dist/{chunk-DC3DWF2M.js.map → chunk-G7I2RQ64.js.map} +0 -0
- /package/dist/{chunk-H2X32F2K.js.map → chunk-GI47WZWA.js.map} +0 -0
- /package/dist/{chunk-PJAMFIDE.js.map → chunk-GJOBCNST.js.map} +0 -0
- /package/dist/{chunk-23BA3USG.js.map → chunk-GO2GCJ7S.js.map} +0 -0
- /package/dist/{chunk-ZODKV27J.js.map → chunk-HPCWJMMQ.js.map} +0 -0
- /package/dist/{chunk-LYOCAUI6.js.map → chunk-IXBIL5WY.js.map} +0 -0
- /package/dist/{chunk-WNQ3B3Q5.js.map → chunk-K5Y42U2P.js.map} +0 -0
- /package/dist/{chunk-6KBCUIZP.js.map → chunk-KFZ3A2EA.js.map} +0 -0
- /package/dist/{chunk-OGQGDOYK.js.map → chunk-KH2HB3OW.js.map} +0 -0
- /package/dist/{chunk-ICBUTKLL.js.map → chunk-KPCTZB3U.js.map} +0 -0
- /package/dist/{chunk-BTU77RB5.js.map → chunk-KWUMXJN7.js.map} +0 -0
- /package/dist/{chunk-5ORJHH43.js.map → chunk-LC55ZXLR.js.map} +0 -0
- /package/dist/{chunk-QUPTDKSX.js.map → chunk-LIGA5KHG.js.map} +0 -0
- /package/dist/{chunk-LHMCZ6VW.js.map → chunk-LUXVW5UU.js.map} +0 -0
- /package/dist/{chunk-7P5VBRBS.js.map → chunk-MOJ4UDA3.js.map} +0 -0
- /package/dist/{chunk-7WFY2L5J.js.map → chunk-NHTEEGE4.js.map} +0 -0
- /package/dist/{chunk-OWZXDBV4.js.map → chunk-OFOV6TTZ.js.map} +0 -0
- /package/dist/{chunk-VK5HKVQ7.js.map → chunk-OKBGLAAI.js.map} +0 -0
- /package/dist/{chunk-VSJ3ARFS.js.map → chunk-PUP7NTNB.js.map} +0 -0
- /package/dist/{chunk-GXAZ4Q2S.js.map → chunk-QYLEE4UQ.js.map} +0 -0
- /package/dist/{chunk-AZNE2H6D.js.map → chunk-RQHIN3PY.js.map} +0 -0
- /package/dist/{chunk-USX5YIKB.js.map → chunk-STDAR7M7.js.map} +0 -0
- /package/dist/{chunk-LN7CWLAU.js.map → chunk-T64HVBAP.js.map} +0 -0
- /package/dist/{chunk-YPYSC3TX.js.map → chunk-V33ETCF6.js.map} +0 -0
- /package/dist/{chunk-J72ZY7NU.js.map → chunk-VWHWWIFS.js.map} +0 -0
- /package/dist/{chunk-QVZZE6VH.js.map → chunk-WBMT3C6A.js.map} +0 -0
- /package/dist/{chunk-N2Y6COEM.js.map → chunk-WPPAXTW6.js.map} +0 -0
- /package/dist/{chunk-W4V7P5HP.js.map → chunk-XV7PJVQO.js.map} +0 -0
- /package/dist/{chunk-FGGLVCHI.js.map → chunk-Y2ZGT4W7.js.map} +0 -0
- /package/dist/{chunk-WYOHQJKC.js.map → chunk-YJX3K5LK.js.map} +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
/**
|
|
3
|
+
* recordTurnUnlocked snapshot retention — issue: the per-turn mutation built
|
|
4
|
+
* `priorTurns` + `activeTurnsSnapshot` + `retainedTurnsSnapshot` (two full
|
|
5
|
+
* `copyBufferTurn` maps) on EVERY admitted turn, even when the decision path
|
|
6
|
+
* consumes none of them: a plain `keep_buffering` turn with no surprise probe
|
|
7
|
+
* eligible branch discards all three. The mutation now builds the snapshots
|
|
8
|
+
* only when the decision path needs them (`keep_buffering` under an
|
|
9
|
+
* eligible surprise branch, or any extraction decision), so steady-state
|
|
10
|
+
* buffering performs no per-turn snapshot copies.
|
|
11
|
+
*
|
|
12
|
+
* Harness: an instance-level spy wraps the (private) recordTurnUnlocked and
|
|
13
|
+
* captures each AddTurnMutationResult, so tests assert on exactly what the
|
|
14
|
+
* mutation returned — no production instrumentation.
|
|
15
|
+
*/
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
|
|
18
|
+
import { type BufferSurpriseProbe, SmartBuffer } from "./buffer.js";
|
|
19
|
+
import { parseConfig } from "./config.js";
|
|
20
|
+
import type { BufferState, BufferTurn } from "./types.js";
|
|
21
|
+
|
|
22
|
+
class FakeStorage {
|
|
23
|
+
public saved: BufferState | null = null;
|
|
24
|
+
public saveCount = 0;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly initial: BufferState) {}
|
|
27
|
+
|
|
28
|
+
async loadBuffer(): Promise<BufferState> {
|
|
29
|
+
return structuredClone(this.initial);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async saveBuffer(state: BufferState): Promise<void> {
|
|
33
|
+
this.saveCount += 1;
|
|
34
|
+
this.saved = structuredClone(state);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeTurn(sessionKey: string, content: string): BufferTurn {
|
|
39
|
+
return {
|
|
40
|
+
role: "user",
|
|
41
|
+
content,
|
|
42
|
+
timestamp: "2026-04-20T12:00:00.000Z",
|
|
43
|
+
sessionKey,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emptyBuffer(): BufferState {
|
|
48
|
+
return { turns: [], lastExtractionAt: null, extractionCount: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type MutationCapture = {
|
|
52
|
+
priorTurns: BufferTurn[];
|
|
53
|
+
activeTurnsSnapshot: BufferTurn[];
|
|
54
|
+
retainedTurnsSnapshot: BufferTurn[];
|
|
55
|
+
decision: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type RecordSpy = (bufferKey: string, turn: BufferTurn) => Promise<MutationCapture>;
|
|
59
|
+
|
|
60
|
+
function spyMutations(buffer: SmartBuffer): MutationCapture[] {
|
|
61
|
+
const captures: MutationCapture[] = [];
|
|
62
|
+
// SAFETY: recordTurnUnlocked is a TS-private prototype method with a stable
|
|
63
|
+
// runtime shape; the spy re-exposes the identical signature on the instance.
|
|
64
|
+
const original = Object.getPrototypeOf(buffer).recordTurnUnlocked as RecordSpy;
|
|
65
|
+
Object.defineProperty(buffer, "recordTurnUnlocked", {
|
|
66
|
+
value: async (bufferKey: string, turn: BufferTurn): Promise<MutationCapture> => {
|
|
67
|
+
const result = await original.call(buffer, bufferKey, turn);
|
|
68
|
+
captures.push(result);
|
|
69
|
+
return result;
|
|
70
|
+
},
|
|
71
|
+
configurable: true,
|
|
72
|
+
writable: true,
|
|
73
|
+
});
|
|
74
|
+
return captures;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fixedScoreProbe(score: number | null): BufferSurpriseProbe & { calls: number } {
|
|
78
|
+
const probe = {
|
|
79
|
+
calls: 0,
|
|
80
|
+
async scoreTurn(_key: string, _turn: BufferTurn, _prior: readonly BufferTurn[]): Promise<number | null> {
|
|
81
|
+
probe.calls += 1;
|
|
82
|
+
return score;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return probe;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("plain keep_buffering turn performs no snapshot copies", async () => {
|
|
89
|
+
const storage = new FakeStorage(emptyBuffer());
|
|
90
|
+
const config = parseConfig({
|
|
91
|
+
bufferMaxTurns: 5,
|
|
92
|
+
triggerMode: "smart",
|
|
93
|
+
bufferSaveDebounceMs: 0,
|
|
94
|
+
});
|
|
95
|
+
const buffer = new SmartBuffer(config, storage);
|
|
96
|
+
const captures = spyMutations(buffer);
|
|
97
|
+
|
|
98
|
+
const decision = await buffer.addTurn("sess-1", makeTurn("sess-1", "ordinary content"));
|
|
99
|
+
|
|
100
|
+
assert.equal(decision, "keep_buffering");
|
|
101
|
+
assert.equal(captures.length, 1);
|
|
102
|
+
assert.equal(captures[0].priorTurns.length, 0, "no priorTurns copy for a decision that never reads it");
|
|
103
|
+
assert.equal(captures[0].activeTurnsSnapshot.length, 0, "no active-turn snapshot copies while buffering");
|
|
104
|
+
assert.equal(captures[0].retainedTurnsSnapshot.length, 0, "no retained-turn snapshot copies while buffering");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("extraction decisions still carry full snapshots", async () => {
|
|
108
|
+
const storage = new FakeStorage(emptyBuffer());
|
|
109
|
+
const config = parseConfig({
|
|
110
|
+
bufferMaxTurns: 2,
|
|
111
|
+
triggerMode: "every_n",
|
|
112
|
+
bufferSaveDebounceMs: 0,
|
|
113
|
+
});
|
|
114
|
+
const buffer = new SmartBuffer(config, storage);
|
|
115
|
+
const captures = spyMutations(buffer);
|
|
116
|
+
|
|
117
|
+
await buffer.addTurn("sess-1", makeTurn("sess-1", "one"));
|
|
118
|
+
const outcome = await buffer.addTurnWithOutcome("sess-1", makeTurn("sess-1", "two"));
|
|
119
|
+
|
|
120
|
+
assert.equal(outcome.decision, "extract_batch");
|
|
121
|
+
assert.notEqual(outcome.extractionTurns, undefined);
|
|
122
|
+
assert.equal(outcome.extractionTurns?.length, 2);
|
|
123
|
+
const last = captures[captures.length - 1];
|
|
124
|
+
assert.equal(last.decision, "extract_batch");
|
|
125
|
+
assert.equal(last.priorTurns.length, 1, "prior turn kept for extraction decisions");
|
|
126
|
+
assert.equal(last.activeTurnsSnapshot.length, 2, "active snapshot kept for extraction decisions");
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
last.activeTurnsSnapshot.map((t) => t.content),
|
|
129
|
+
["one", "two"]
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("surprise-eligible keep_buffering still builds the snapshots the probe path reads", async () => {
|
|
134
|
+
const storage = new FakeStorage(emptyBuffer());
|
|
135
|
+
const probe = fixedScoreProbe(null);
|
|
136
|
+
const config = parseConfig({
|
|
137
|
+
bufferSurpriseTriggerEnabled: true,
|
|
138
|
+
bufferMaxTurns: 5,
|
|
139
|
+
triggerMode: "smart",
|
|
140
|
+
bufferSaveDebounceMs: 0,
|
|
141
|
+
});
|
|
142
|
+
const buffer = new SmartBuffer(config, storage, probe);
|
|
143
|
+
const captures = spyMutations(buffer);
|
|
144
|
+
|
|
145
|
+
const decision = await buffer.addTurn("sess-1", makeTurn("sess-1", "ordinary content"));
|
|
146
|
+
|
|
147
|
+
assert.equal(decision, "keep_buffering");
|
|
148
|
+
assert.equal(probe.calls, 1, "the surprise probe must still be consulted");
|
|
149
|
+
const capture = captures[0];
|
|
150
|
+
assert.equal(capture.priorTurns.length, 0, "first turn has no priors");
|
|
151
|
+
assert.equal(capture.activeTurnsSnapshot.length, 1, "snapshot kept while the surprise branch may promote");
|
|
152
|
+
assert.equal(capture.retainedTurnsSnapshot.length, 0, "nothing retained yet");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("surprise promotion still extracts from a consistent snapshot", async () => {
|
|
156
|
+
const storage = new FakeStorage(emptyBuffer());
|
|
157
|
+
const probe = fixedScoreProbe(0.99);
|
|
158
|
+
const config = parseConfig({
|
|
159
|
+
bufferSurpriseTriggerEnabled: true,
|
|
160
|
+
bufferMaxTurns: 5,
|
|
161
|
+
triggerMode: "smart",
|
|
162
|
+
bufferSurpriseThreshold: 0.5,
|
|
163
|
+
bufferSaveDebounceMs: 0,
|
|
164
|
+
});
|
|
165
|
+
const buffer = new SmartBuffer(config, storage, probe);
|
|
166
|
+
|
|
167
|
+
const outcome = await buffer.addTurnWithOutcome("sess-1", makeTurn("sess-1", "novel"));
|
|
168
|
+
|
|
169
|
+
assert.equal(outcome.decision, "extract_now");
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
outcome.extractionTurns?.map((t) => t.content),
|
|
172
|
+
["novel"]
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("keep_buffering under every_n never builds snapshots even with a probe present", async () => {
|
|
177
|
+
const storage = new FakeStorage(emptyBuffer());
|
|
178
|
+
const probe = fixedScoreProbe(0.99);
|
|
179
|
+
const config = parseConfig({
|
|
180
|
+
bufferSurpriseTriggerEnabled: true,
|
|
181
|
+
bufferMaxTurns: 5,
|
|
182
|
+
triggerMode: "every_n",
|
|
183
|
+
bufferSaveDebounceMs: 0,
|
|
184
|
+
});
|
|
185
|
+
const buffer = new SmartBuffer(config, storage, probe);
|
|
186
|
+
const captures = spyMutations(buffer);
|
|
187
|
+
|
|
188
|
+
const decision = await buffer.addTurn("sess-1", makeTurn("sess-1", "ordinary content"));
|
|
189
|
+
|
|
190
|
+
assert.equal(decision, "keep_buffering");
|
|
191
|
+
assert.equal(probe.calls, 0, "surprise branch is smart-mode only");
|
|
192
|
+
assert.equal(captures[0].activeTurnsSnapshot.length, 0);
|
|
193
|
+
assert.equal(captures[0].retainedTurnsSnapshot.length, 0);
|
|
194
|
+
assert.equal(captures[0].priorTurns.length, 0);
|
|
195
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
/**
|
|
3
|
+
* mergeTurns identity comparison — issue: the claim-merge hot path
|
|
4
|
+
* (buffer-drain-claims.ts `mergeIntoClaim`) deduped incoming turns with
|
|
5
|
+
* `merged.some(isEqual)`: O(existing × incoming) per observe while a claim
|
|
6
|
+
* only grows during a stalled drain. The identity comparison now goes
|
|
7
|
+
* through a Set of identity keys built from the exact `bufferTurnsEqual`
|
|
8
|
+
* fields, so the merge is O(n + m) with byte-identical merge semantics:
|
|
9
|
+
* dedupe identical turns (including duplicates within `incoming`), append
|
|
10
|
+
* genuinely-new turns in arrival order, existing entries always win.
|
|
11
|
+
*
|
|
12
|
+
* The identity key must be injective over `bufferTurnsEqual` equality
|
|
13
|
+
* classes — including the undefined-vs-null distinctions on optional fields
|
|
14
|
+
* (`providerThreadId`) and the plan-identity triple (#3251).
|
|
15
|
+
*/
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
|
|
18
|
+
import { bufferTurnIdentityKey, bufferTurnsEqual, mergeBufferTurnsByIdentity } from "./buffer-turn-helpers.js";
|
|
19
|
+
import type { ScopeProfileLayerResolution } from "./namespaces/scope-profiles.js";
|
|
20
|
+
import type { BufferTurn } from "./types.js";
|
|
21
|
+
|
|
22
|
+
function turn(overrides: Partial<BufferTurn> & { content: string }): BufferTurn {
|
|
23
|
+
return { role: "user", timestamp: "2026-11-01T00:00:00.000Z", ...overrides };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("bufferTurnIdentityKey matches bufferTurnsEqual exactly (same key ⇔ equal)", () => {
|
|
27
|
+
const base = turn({ content: "hello", sessionKey: "s1" });
|
|
28
|
+
const same = turn({ content: "hello", sessionKey: "s1" });
|
|
29
|
+
const different = [
|
|
30
|
+
turn({ content: "hello", sessionKey: "s2" }),
|
|
31
|
+
turn({ content: "world", sessionKey: "s1" }),
|
|
32
|
+
turn({ content: "hello", sessionKey: "s1", role: "assistant" as const }),
|
|
33
|
+
turn({ content: "hello", sessionKey: "s1", timestamp: "2026-11-02T00:00:00.000Z" }),
|
|
34
|
+
turn({ content: "hello", sessionKey: "s1", providerThreadId: null }),
|
|
35
|
+
turn({ content: "hello", sessionKey: "s1", providerThreadId: "t1" }),
|
|
36
|
+
turn({ content: "hello", sessionKey: "s1", turnFingerprint: "fp" }),
|
|
37
|
+
turn({ content: "hello", sessionKey: "s1", persistProcessedFingerprint: true }),
|
|
38
|
+
turn({ content: "hello", sessionKey: "s1", persistProcessedFingerprint: false }),
|
|
39
|
+
turn({ content: "hello", sessionKey: "s1", sourceConnector: "cli" }),
|
|
40
|
+
turn({ content: "hello", sessionKey: "s1", originRole: "user" as const }),
|
|
41
|
+
turn({ content: "hello", sessionKey: "s1", ambientCapture: true }),
|
|
42
|
+
turn({ content: "hello", sessionKey: "s1", ambientCapture: false }),
|
|
43
|
+
turn({ content: "hello", sessionKey: "s1", sourceValidAt: "2026-01-01T00:00:00.000Z" }),
|
|
44
|
+
turn({ content: "hello", sessionKey: "s1", acceptedCapture: true }),
|
|
45
|
+
turn({ content: "hello", sessionKey: "s1", acceptedCapture: false }),
|
|
46
|
+
turn({ content: "hello", sessionKey: "s1", writeNamespaceOverride: "ns" }),
|
|
47
|
+
turn({ content: "hello", sessionKey: "s1", principalOverride: "p1" }),
|
|
48
|
+
turn({
|
|
49
|
+
content: "hello",
|
|
50
|
+
sessionKey: "s1",
|
|
51
|
+
codingContext: { projectId: "proj", branch: "main", rootPath: "/r", defaultBranch: "main" },
|
|
52
|
+
}),
|
|
53
|
+
turn({
|
|
54
|
+
content: "hello",
|
|
55
|
+
sessionKey: "s1",
|
|
56
|
+
codingContext: { projectId: "proj", branch: "dev", rootPath: "/r", defaultBranch: "main" },
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
assert.equal(bufferTurnIdentityKey(base), bufferTurnIdentityKey(same));
|
|
61
|
+
assert.equal(bufferTurnsEqual(base, same), true);
|
|
62
|
+
for (const other of different) {
|
|
63
|
+
assert.notEqual(
|
|
64
|
+
bufferTurnIdentityKey(base),
|
|
65
|
+
bufferTurnIdentityKey(other),
|
|
66
|
+
`key must differ for ${JSON.stringify(other)}`
|
|
67
|
+
);
|
|
68
|
+
assert.equal(bufferTurnsEqual(base, other), false);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("bufferTurnIdentityKey: undefined and null providerThreadId are distinct classes", () => {
|
|
73
|
+
const absent = turn({ content: "x" });
|
|
74
|
+
const explicitNull = turn({ content: "x", providerThreadId: null });
|
|
75
|
+
const present = turn({ content: "x", providerThreadId: "t" });
|
|
76
|
+
const keys = new Set([
|
|
77
|
+
bufferTurnIdentityKey(absent),
|
|
78
|
+
bufferTurnIdentityKey(explicitNull),
|
|
79
|
+
bufferTurnIdentityKey(present),
|
|
80
|
+
]);
|
|
81
|
+
assert.equal(keys.size, 3);
|
|
82
|
+
assert.equal(bufferTurnsEqual(absent, explicitNull), false);
|
|
83
|
+
assert.equal(bufferTurnsEqual(absent, present), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("bufferTurnIdentityKey: null and undefined scope plan share the empty identity (#3251)", () => {
|
|
87
|
+
const noPlan = turn({ content: "x" });
|
|
88
|
+
const nullPlan = turn({ content: "x", scopeProfileWritePlan: null });
|
|
89
|
+
assert.equal(bufferTurnIdentityKey(noPlan), bufferTurnIdentityKey(nullPlan));
|
|
90
|
+
assert.equal(bufferTurnsEqual(noPlan, nullPlan), true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("mergeBufferTurnsByIdentity appends genuinely-new turns in arrival order", () => {
|
|
94
|
+
const existing = [turn({ content: "a" }), turn({ content: "b" })];
|
|
95
|
+
const incoming = [turn({ content: "c" }), turn({ content: "d" })];
|
|
96
|
+
const merged = mergeBufferTurnsByIdentity(existing, incoming);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
merged.map((t) => t.content),
|
|
99
|
+
["a", "b", "c", "d"]
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("mergeBufferTurnsByIdentity dedupes turns identical to an existing entry", () => {
|
|
104
|
+
const existing = [turn({ content: "a" }), turn({ content: "b" })];
|
|
105
|
+
const incoming = [turn({ content: "b" }), turn({ content: "c" })];
|
|
106
|
+
const merged = mergeBufferTurnsByIdentity(existing, incoming);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
merged.map((t) => t.content),
|
|
109
|
+
["a", "b", "c"]
|
|
110
|
+
);
|
|
111
|
+
// The kept entry is the EXISTING object, not the incoming copy.
|
|
112
|
+
assert.equal(merged[1], existing[1]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("mergeBufferTurnsByIdentity dedupes duplicates within incoming (first wins)", () => {
|
|
116
|
+
const existing: BufferTurn[] = [];
|
|
117
|
+
const first = turn({ content: "dup" });
|
|
118
|
+
const second = turn({ content: "dup" });
|
|
119
|
+
const merged = mergeBufferTurnsByIdentity(existing, [first, second]);
|
|
120
|
+
assert.equal(merged.length, 1);
|
|
121
|
+
assert.equal(merged[0], first);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("mergeBufferTurnsByIdentity keeps existing entries verbatim, duplicates included", () => {
|
|
125
|
+
const dupA = turn({ content: "a" });
|
|
126
|
+
const dupB = turn({ content: "a" });
|
|
127
|
+
const existing = [dupA, dupB];
|
|
128
|
+
const merged = mergeBufferTurnsByIdentity(existing, [turn({ content: "a" }), turn({ content: "b" })]);
|
|
129
|
+
assert.equal(merged.length, 3, "existing duplicates are preserved; incoming duplicate dropped");
|
|
130
|
+
assert.equal(merged[0], dupA);
|
|
131
|
+
assert.equal(merged[1], dupB);
|
|
132
|
+
assert.equal(merged[2].content, "b");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("mergeBufferTurnsByIdentity keeps existing order and does not mutate inputs", () => {
|
|
136
|
+
const existing = [turn({ content: "b" }), turn({ content: "a" })];
|
|
137
|
+
const incoming = [turn({ content: "a" }), turn({ content: "c" })];
|
|
138
|
+
const merged = mergeBufferTurnsByIdentity(existing, incoming);
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
merged.map((t) => t.content),
|
|
141
|
+
["b", "a", "c"]
|
|
142
|
+
);
|
|
143
|
+
assert.equal(existing.length, 2);
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
existing.map((t) => t.content),
|
|
146
|
+
["b", "a"]
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Identity fields only: scopeProfilePlanIdentity reads profileId, writeLayer
|
|
151
|
+
// and writeNamespace; the remaining members are inert for identity tests.
|
|
152
|
+
function pinnedPlan(namespace: string): NonNullable<BufferTurn["scopeProfileWritePlan"]> {
|
|
153
|
+
const layer: ScopeProfileLayerResolution = {
|
|
154
|
+
id: "userProject",
|
|
155
|
+
kind: "user-project",
|
|
156
|
+
namespace,
|
|
157
|
+
readable: true,
|
|
158
|
+
writable: true,
|
|
159
|
+
promotable: false,
|
|
160
|
+
reason: "identity-test layer",
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
profileId: "p",
|
|
164
|
+
profile: {
|
|
165
|
+
readOrder: ["userProject"],
|
|
166
|
+
writeDefault: "userProject",
|
|
167
|
+
promotionTargets: [],
|
|
168
|
+
autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
|
|
169
|
+
},
|
|
170
|
+
baseNamespace: namespace,
|
|
171
|
+
writeLayer: "userProject",
|
|
172
|
+
writeNamespace: namespace,
|
|
173
|
+
readNamespaces: [namespace],
|
|
174
|
+
layers: [layer],
|
|
175
|
+
promotionTargets: [],
|
|
176
|
+
warnings: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test("mergeBufferTurnsByIdentity stays consistent with bufferTurnsEqual on scope-pinned turns", () => {
|
|
181
|
+
const pinned = turn({
|
|
182
|
+
content: "cap",
|
|
183
|
+
acceptedCapture: true,
|
|
184
|
+
writeNamespaceOverride: "ns-a",
|
|
185
|
+
scopeProfileWritePlan: pinnedPlan("ns-a"),
|
|
186
|
+
});
|
|
187
|
+
const samePinned = turn({
|
|
188
|
+
content: "cap",
|
|
189
|
+
acceptedCapture: true,
|
|
190
|
+
writeNamespaceOverride: "ns-a",
|
|
191
|
+
scopeProfileWritePlan: pinnedPlan("ns-a"),
|
|
192
|
+
});
|
|
193
|
+
const otherPinned = turn({
|
|
194
|
+
content: "cap",
|
|
195
|
+
acceptedCapture: true,
|
|
196
|
+
writeNamespaceOverride: "ns-b",
|
|
197
|
+
scopeProfileWritePlan: pinnedPlan("ns-b"),
|
|
198
|
+
});
|
|
199
|
+
assert.equal(bufferTurnsEqual(pinned, samePinned), true);
|
|
200
|
+
assert.equal(bufferTurnsEqual(pinned, otherPinned), false);
|
|
201
|
+
const merged = mergeBufferTurnsByIdentity([pinned], [samePinned, otherPinned]);
|
|
202
|
+
assert.equal(merged.length, 2);
|
|
203
|
+
assert.equal(merged[0], pinned);
|
|
204
|
+
assert.equal(merged[1], otherPinned);
|
|
205
|
+
});
|
|
@@ -169,6 +169,68 @@ export function bufferTurnsEqual(left: BufferTurn | undefined, right: BufferTurn
|
|
|
169
169
|
);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Canonical identity key for a turn, injective over `bufferTurnsEqual`
|
|
174
|
+
* equality classes. Every compared field is a primitive (or the #3251 plan
|
|
175
|
+
* identity string), so set-membership replaces pairwise comparison and turn
|
|
176
|
+
* merges run O(n + m) instead of O(n × m). JSON.stringify of a string is
|
|
177
|
+
* injective (it escapes control characters), and the raw `\u0001` sentinels
|
|
178
|
+
* distinguish undefined from null — a distinction `===` makes and plain
|
|
179
|
+
* stringification would lose.
|
|
180
|
+
*/
|
|
181
|
+
export function bufferTurnIdentityKey(turn: BufferTurn): string {
|
|
182
|
+
const opt = (value: string | null | undefined): string =>
|
|
183
|
+
value === undefined ? "\u0001undefined" : value === null ? "\u0001null" : JSON.stringify(value);
|
|
184
|
+
const optFlag = (value: boolean | undefined): string =>
|
|
185
|
+
value === undefined ? "\u0001undefined" : String(value);
|
|
186
|
+
const ctx = turn.codingContext;
|
|
187
|
+
return JSON.stringify([
|
|
188
|
+
turn.role,
|
|
189
|
+
turn.content,
|
|
190
|
+
turn.timestamp,
|
|
191
|
+
opt(turn.sessionKey),
|
|
192
|
+
opt(turn.sessionOwnerPrincipal),
|
|
193
|
+
opt(turn.logicalSessionKey),
|
|
194
|
+
turn.providerThreadId === undefined ? "\u0001undefined" : turn.providerThreadId === null ? "\u0001null" : JSON.stringify(turn.providerThreadId),
|
|
195
|
+
opt(turn.turnFingerprint),
|
|
196
|
+
optFlag(turn.persistProcessedFingerprint),
|
|
197
|
+
opt(turn.sourceConnector),
|
|
198
|
+
opt(turn.originRole),
|
|
199
|
+
optFlag(turn.ambientCapture),
|
|
200
|
+
opt(turn.sourceValidAt),
|
|
201
|
+
optFlag(turn.acceptedCapture),
|
|
202
|
+
opt(ctx?.projectId),
|
|
203
|
+
opt(ctx?.rootPath),
|
|
204
|
+
opt(ctx?.branch),
|
|
205
|
+
opt(ctx?.defaultBranch),
|
|
206
|
+
opt(turn.writeNamespaceOverride),
|
|
207
|
+
opt(turn.principalOverride),
|
|
208
|
+
opt(turn.scopeProfileWritePlan ? scopeProfilePlanIdentity(turn.scopeProfileWritePlan) : undefined),
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Identity-dedupe `incoming` turns into `existing` (same contract as the
|
|
214
|
+
* claim-merge `mergeTurns`): identical turns are dropped — the EXISTING
|
|
215
|
+
* entry is kept — genuinely-new turns append in arrival order, and
|
|
216
|
+
* duplicates within `incoming` collapse to their first occurrence. O(n + m)
|
|
217
|
+
* via `bufferTurnIdentityKey` membership instead of pairwise equality.
|
|
218
|
+
*/
|
|
219
|
+
export function mergeBufferTurnsByIdentity(
|
|
220
|
+
existing: readonly BufferTurn[],
|
|
221
|
+
incoming: readonly BufferTurn[],
|
|
222
|
+
): BufferTurn[] {
|
|
223
|
+
const merged = [...existing];
|
|
224
|
+
const seen = new Set(merged.map(bufferTurnIdentityKey));
|
|
225
|
+
for (const turn of incoming) {
|
|
226
|
+
const key = bufferTurnIdentityKey(turn);
|
|
227
|
+
if (seen.has(key)) continue;
|
|
228
|
+
seen.add(key);
|
|
229
|
+
merged.push(turn);
|
|
230
|
+
}
|
|
231
|
+
return merged;
|
|
232
|
+
}
|
|
233
|
+
|
|
172
234
|
/** Replace only the processed snapshot; callers without one retain legacy whole-slot semantics. */
|
|
173
235
|
export function selectRetainedDeferredTurns(
|
|
174
236
|
existing: readonly BufferTurn[],
|
package/src/buffer.ts
CHANGED
|
@@ -646,16 +646,16 @@ export class SmartBuffer {
|
|
|
646
646
|
private async recordTurnUnlocked(bufferKey: string, turn: BufferTurn): Promise<AddTurnMutationResult> {
|
|
647
647
|
await this.loadUnlocked();
|
|
648
648
|
const entry = this.entryFor(bufferKey);
|
|
649
|
-
const priorTurns = entry.turns.slice();
|
|
650
|
-
entry.turns.push(turn);
|
|
651
|
-
const activeTurnsSnapshot = entry.turns.map(copyBufferTurn);
|
|
652
|
-
const retainedTurnsSnapshot = (entry.retainedTurns ?? []).map(copyBufferTurn);
|
|
653
|
-
if (bufferKey === "default") {
|
|
654
|
-
this.state.turns = entry.turns;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
649
|
const signal = scanSignals(turn.content, this.config.highSignalPatterns);
|
|
650
|
+
entry.turns.push(turn);
|
|
658
651
|
const decision = this.evaluate(entry, signal.level);
|
|
652
|
+
// Snapshots serve only extraction decisions and surprise-eligible keep_buffering turns (probe, smart mode, sub-high signal); steady-state buffering copies nothing.
|
|
653
|
+
const surpriseEligible = this.surpriseProbe !== null && this.config.triggerMode === "smart" && signal.level !== "high";
|
|
654
|
+
const snapshotsNeeded = decision !== "keep_buffering" || surpriseEligible;
|
|
655
|
+
const priorTurns = snapshotsNeeded ? entry.turns.slice(0, -1) : [];
|
|
656
|
+
const activeTurnsSnapshot = snapshotsNeeded ? entry.turns.map(copyBufferTurn) : [];
|
|
657
|
+
const retainedTurnsSnapshot = snapshotsNeeded ? (entry.retainedTurns ?? []).map(copyBufferTurn) : [];
|
|
658
|
+
if (bufferKey === "default") this.state.turns = entry.turns;
|
|
659
659
|
const turnCountInWindow = entry.turns.length;
|
|
660
660
|
|
|
661
661
|
this.persistence.markDirty(bufferKey);
|
|
@@ -115,10 +115,27 @@ export class ExtractionQueueCoordinator {
|
|
|
115
115
|
* Enqueue a task and start the processor when capacity is free. This is
|
|
116
116
|
* the production entry point — the orchestrator's `queueBufferedExtraction`
|
|
117
117
|
* builds each task closure and hands it here with its buffer key.
|
|
118
|
+
*
|
|
119
|
+
* Per-key coalescing: a queued-not-started task for key K is superseded by
|
|
120
|
+
* a newer enqueue for K instead of stacking another full turn snapshot
|
|
121
|
+
* behind it (extraction reads live state at run time, so the newest
|
|
122
|
+
* closure is the authoritative one). Keyless tasks are independent work
|
|
123
|
+
* and never coalesce.
|
|
118
124
|
*/
|
|
119
125
|
enqueue(task: ExtractionQueueTask, options?: ExtractionQueueEnqueueOptions): boolean {
|
|
120
126
|
if (!this.accepting) return false;
|
|
121
|
-
const
|
|
127
|
+
const key = options?.key;
|
|
128
|
+
if (key !== undefined) {
|
|
129
|
+
const queuedAt = this.queue.findIndex((entry) => entry.key === key);
|
|
130
|
+
if (!options?.front && queuedAt >= 0) {
|
|
131
|
+
this.queue[queuedAt] = { run: task, key };
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (options?.front && queuedAt >= 0) {
|
|
135
|
+
this.queue.splice(queuedAt, 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const entry: QueueEntry = { run: task, key };
|
|
122
139
|
if (options?.front) {
|
|
123
140
|
this.preemptRunning(options.key);
|
|
124
141
|
this.queue.unshift(entry);
|
|
@@ -35,6 +35,148 @@ function createCoordinator(): ExtractionQueueCoordinator {
|
|
|
35
35
|
return new ExtractionQueueCoordinator();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ── Per-key coalescing: a queued-not-started task is superseded by a newer
|
|
39
|
+
// enqueue for the same key instead of stacking another full snapshot ────────
|
|
40
|
+
|
|
41
|
+
test("a newer enqueue for a queued-not-started key replaces the queued task", async () => {
|
|
42
|
+
const coordinator = createCoordinator();
|
|
43
|
+
const busy = Promise.withResolvers<void>();
|
|
44
|
+
const releaseBusy = Promise.withResolvers<void>();
|
|
45
|
+
const ran: string[] = [];
|
|
46
|
+
coordinator.enqueue(async () => {
|
|
47
|
+
busy.resolve();
|
|
48
|
+
await releaseBusy.promise;
|
|
49
|
+
});
|
|
50
|
+
await busy.promise;
|
|
51
|
+
|
|
52
|
+
coordinator.enqueue(async () => {
|
|
53
|
+
ran.push("stale");
|
|
54
|
+
}, { key: "s1" });
|
|
55
|
+
coordinator.enqueue(async () => {
|
|
56
|
+
ran.push("fresh");
|
|
57
|
+
}, { key: "s1" });
|
|
58
|
+
|
|
59
|
+
assert.equal(coordinator.length, 1, "the stale snapshot must be replaced, not stacked");
|
|
60
|
+
|
|
61
|
+
releaseBusy.resolve();
|
|
62
|
+
assert.equal(await coordinator.waitForIdle(5_000), true);
|
|
63
|
+
assert.deepEqual(ran, ["fresh"], "only the newest task for the key runs");
|
|
64
|
+
assert.equal(coordinator.isProcessing, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("coalescing keeps the superseded entry's position for other keys", async () => {
|
|
68
|
+
const coordinator = createCoordinator();
|
|
69
|
+
const busy = Promise.withResolvers<void>();
|
|
70
|
+
const releaseBusy = Promise.withResolvers<void>();
|
|
71
|
+
const ran: string[] = [];
|
|
72
|
+
coordinator.enqueue(async () => {
|
|
73
|
+
busy.resolve();
|
|
74
|
+
await releaseBusy.promise;
|
|
75
|
+
});
|
|
76
|
+
await busy.promise;
|
|
77
|
+
|
|
78
|
+
coordinator.enqueue(async () => {
|
|
79
|
+
ran.push("k1");
|
|
80
|
+
}, { key: "k1" });
|
|
81
|
+
coordinator.enqueue(async () => {
|
|
82
|
+
ran.push("k2-stale");
|
|
83
|
+
}, { key: "k2" });
|
|
84
|
+
coordinator.enqueue(async () => {
|
|
85
|
+
ran.push("k2-fresh");
|
|
86
|
+
}, { key: "k2" });
|
|
87
|
+
coordinator.enqueue(async () => {
|
|
88
|
+
ran.push("k3");
|
|
89
|
+
}, { key: "k3" });
|
|
90
|
+
|
|
91
|
+
releaseBusy.resolve();
|
|
92
|
+
assert.equal(await coordinator.waitForIdle(5_000), true);
|
|
93
|
+
assert.deepEqual(ran, ["k1", "k2-fresh", "k3"], "k2 keeps its queue slot; the newest payload wins");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a front enqueue supersedes a queued same-key task and keeps preemption", async () => {
|
|
97
|
+
const coordinator = createCoordinator();
|
|
98
|
+
const busy = Promise.withResolvers<void>();
|
|
99
|
+
const releaseBusy = Promise.withResolvers<void>();
|
|
100
|
+
const ran: string[] = [];
|
|
101
|
+
let preempted = false;
|
|
102
|
+
coordinator.enqueue(async (preempt) => {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
preempt?.arm(() => {
|
|
105
|
+
preempted = true;
|
|
106
|
+
controller.abort();
|
|
107
|
+
});
|
|
108
|
+
busy.resolve();
|
|
109
|
+
try {
|
|
110
|
+
await releaseBusy.promise;
|
|
111
|
+
} finally {
|
|
112
|
+
preempt?.disarm();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
await busy.promise;
|
|
116
|
+
|
|
117
|
+
coordinator.enqueue(async () => {
|
|
118
|
+
ran.push("queued-stale");
|
|
119
|
+
}, { key: "s1" });
|
|
120
|
+
coordinator.enqueue(async () => {
|
|
121
|
+
ran.push("flush-fresh");
|
|
122
|
+
}, { key: "s1", front: true });
|
|
123
|
+
|
|
124
|
+
assert.equal(coordinator.length, 1, "the superseded queued task must be dropped");
|
|
125
|
+
|
|
126
|
+
releaseBusy.resolve();
|
|
127
|
+
assert.equal(await coordinator.waitForIdle(5_000), true);
|
|
128
|
+
assert.equal(preempted, true, "front enqueue still preempts the running automatic work");
|
|
129
|
+
assert.deepEqual(ran, ["flush-fresh"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("keyless tasks never coalesce", async () => {
|
|
133
|
+
const coordinator = createCoordinator();
|
|
134
|
+
const busy = Promise.withResolvers<void>();
|
|
135
|
+
const releaseBusy = Promise.withResolvers<void>();
|
|
136
|
+
const ran: string[] = [];
|
|
137
|
+
coordinator.enqueue(async () => {
|
|
138
|
+
busy.resolve();
|
|
139
|
+
await releaseBusy.promise;
|
|
140
|
+
});
|
|
141
|
+
await busy.promise;
|
|
142
|
+
|
|
143
|
+
coordinator.enqueue(async () => {
|
|
144
|
+
ran.push("first");
|
|
145
|
+
});
|
|
146
|
+
coordinator.enqueue(async () => {
|
|
147
|
+
ran.push("second");
|
|
148
|
+
});
|
|
149
|
+
assert.equal(coordinator.length, 2, "tasks without a key are independent work");
|
|
150
|
+
|
|
151
|
+
releaseBusy.resolve();
|
|
152
|
+
assert.equal(await coordinator.waitForIdle(5_000), true);
|
|
153
|
+
assert.deepEqual(ran, ["first", "second"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a coalesced enqueue of a running key waits behind the active task", async () => {
|
|
157
|
+
const coordinator = createCoordinator();
|
|
158
|
+
const busy = Promise.withResolvers<void>();
|
|
159
|
+
const releaseBusy = Promise.withResolvers<void>();
|
|
160
|
+
const order: string[] = [];
|
|
161
|
+
coordinator.enqueue(async () => {
|
|
162
|
+
busy.resolve();
|
|
163
|
+
await releaseBusy.promise;
|
|
164
|
+
order.push("running");
|
|
165
|
+
}, { key: "s1" });
|
|
166
|
+
await busy.promise;
|
|
167
|
+
|
|
168
|
+
coordinator.enqueue(async () => {
|
|
169
|
+
order.push("queued-first");
|
|
170
|
+
}, { key: "s1" });
|
|
171
|
+
coordinator.enqueue(async () => {
|
|
172
|
+
order.push("queued-second");
|
|
173
|
+
}, { key: "s1" });
|
|
174
|
+
|
|
175
|
+
releaseBusy.resolve();
|
|
176
|
+
assert.equal(await coordinator.waitForIdle(5_000), true);
|
|
177
|
+
assert.deepEqual(order, ["running", "queued-second"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
38
180
|
// ── Issue #549 ─────────────────────────────────────────────────────────────
|
|
39
181
|
|
|
40
182
|
test("processQueue logs an AbortError task at debug, not error (#549)", async () => {
|