@modusensus/dsh-mneme 0.12.0 → 0.13.0

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.
Files changed (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
@@ -0,0 +1,369 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { scheduleBootBackfill } from "../src/semantic.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createVectorIndex } from "../src/vector-index.js";
7
+
8
+ // #604:boot 回填的续排此前完全没有用例覆盖 —— createSemantic 只被 index.js /
9
+ // serve.js 引用,闭包里的调度逻辑既测不到也看不见,于是「一次启动只跑一轮、撞
10
+ // cap 就停」活了很久:日志里写着 capped,没有任何出口能说明「它不会再自己跑」。
11
+ // 这里直接驱动调度器,用压缩后的等待时间,断言排了几轮、为什么停。
12
+
13
+ function setup({ count = 6, batchSize = 2, maxPerBoot = 2 } = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config: {} });
16
+ for (let i = 0; i < count; i++) {
17
+ service.saveWithDedupe({ type: "preference", title: `记忆${i}`, content: `内容${i}` });
18
+ }
19
+ const vectorIndex = createVectorIndex({ store });
20
+ const cfg = {
21
+ autoReindexOnBoot: true,
22
+ reindexMaxPerBoot: maxPerBoot,
23
+ reindexBatchSize: batchSize,
24
+ reindexRateLimitMs: 0
25
+ };
26
+ return { store, vectorIndex, cfg };
27
+ }
28
+
29
+ // 调度器用 setTimeout 串联,delay 压到 0 之后仍然要等事件循环转几圈。
30
+ // 轮询到状态稳定(连续两次读到同一个终态)或超时为止。
31
+ async function settle(statusOf, { timeoutMs = 3000 } = {}) {
32
+ const deadline = Date.now() + timeoutMs;
33
+ let last = null;
34
+ let stable = 0;
35
+ while (Date.now() < deadline) {
36
+ await new Promise((r) => setTimeout(r, 10));
37
+ const now = JSON.stringify(statusOf() ?? null);
38
+ if (now === last) {
39
+ stable++;
40
+ if (stable >= 3) return JSON.parse(now);
41
+ } else {
42
+ stable = 0;
43
+ last = now;
44
+ }
45
+ }
46
+ throw new Error(`scheduler did not settle within ${timeoutMs}ms (last=${last})`);
47
+ }
48
+
49
+ // settle() 只要求「连续三次读到同一个状态」,对几百行的长跑会提前返回 running。
50
+ // 需要终态的场景用这个:等到 state 变成 finished / error 再返回。
51
+ async function waitForFinished(statusOf, { timeoutMs = 10000 } = {}) {
52
+ const deadline = Date.now() + timeoutMs;
53
+ while (Date.now() < deadline) {
54
+ const now = statusOf();
55
+ if (now && (now.state === "finished" || now.state === "error")) return now;
56
+ await new Promise((r) => setTimeout(r, 10));
57
+ }
58
+ throw new Error(`scheduler never reached a terminal state within ${timeoutMs}ms`);
59
+ }
60
+
61
+ // 续排场景下「第一个终态」不是答案:第一轮 errored 之后还会再排一轮。
62
+ // 需要等到某个具体的 finishReason 出现才算数。
63
+ async function waitForReason(statusOf, reason, { timeoutMs = 10000 } = {}) {
64
+ const deadline = Date.now() + timeoutMs;
65
+ let last = null;
66
+ while (Date.now() < deadline) {
67
+ const now = statusOf();
68
+ if (now) last = now;
69
+ if (now && now.state === "finished" && now.finishReason === reason) return now;
70
+ await new Promise((r) => setTimeout(r, 10));
71
+ }
72
+ throw new Error(
73
+ `scheduler never reported "${reason}" within ${timeoutMs}ms (last=${JSON.stringify(last)})`
74
+ );
75
+ }
76
+
77
+ test("#604: a capped run continues inside the same boot until the backlog drains", async () => {
78
+ // 6 行、每轮最多 2 行 → 需要 3 轮。旧实现只跑第一轮就永久停手。
79
+ const { store, vectorIndex, cfg } = setup({ count: 6, batchSize: 2, maxPerBoot: 2 });
80
+ const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "m#1", dimension: 3 };
81
+
82
+ const scheduler = scheduleBootBackfill({
83
+ store, vectorIndex, logger: { info() {}, warn() {} }, cfg,
84
+ getEmbedder: () => embedder,
85
+ timing: { initialDelayMs: 0, continueDelayMs: 0 }
86
+ });
87
+
88
+ const status = await settle(() => scheduler.status());
89
+ scheduler.dispose();
90
+
91
+ assert.equal(store.needsEmbeddingFor("m#1").length, 0, "the whole backlog is cleared in one boot");
92
+ assert.equal(status.state, "finished");
93
+ assert.equal(status.finishReason, "drained", "the last run reports why it stopped");
94
+ assert.equal(status.continuation, 2, "two continuations after the first run (3 runs total)");
95
+ store.close();
96
+ });
97
+
98
+ test("#604: continuations stop at the cap instead of looping forever", async () => {
99
+ // 12 行、每轮 2 行、上限 2 次续排 → 只跑 3 轮(6 行),剩下的留到下次启动。
100
+ const { store, vectorIndex, cfg } = setup({ count: 12, batchSize: 2, maxPerBoot: 2 });
101
+ const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "m#1", dimension: 3 };
102
+ const warnings = [];
103
+
104
+ const scheduler = scheduleBootBackfill({
105
+ store, vectorIndex, cfg,
106
+ logger: { info() {}, warn: (m) => warnings.push(m) },
107
+ getEmbedder: () => embedder,
108
+ timing: { initialDelayMs: 0, continueDelayMs: 0, maxContinuations: 2 }
109
+ });
110
+
111
+ const status = await settle(() => scheduler.status());
112
+ scheduler.dispose();
113
+
114
+ assert.equal(status.finishReason, "capped");
115
+ assert.equal(status.continuation, 2, "the cap counts continuations, not runs");
116
+ assert.equal(store.needsEmbeddingFor("m#1").length, 6, "exactly the remainder is left queued");
117
+ assert.ok(
118
+ warnings.some((m) => m.includes("continuation limit")),
119
+ "hitting the cap is logged, not silent"
120
+ );
121
+ store.close();
122
+ });
123
+
124
+ test("#604: a stalled run does not reschedule — the next boot is the retry point", async () => {
125
+ // 全行永久失败 → finishReason stalled。立刻再排只会把同几行再烧一遍,
126
+ // 所以正确行为是停手,把重试留给下一次启动(与 #128 的契约一致)。
127
+ // 用 null 向量(empty-vector,对该行是永久性)而不是抛错:抛错属于
128
+ // `error:` 桶,是瞬时故障,走 errored 那条更短的续排路径,见下面两个用例。
129
+ const { store, vectorIndex, cfg } = setup({ count: 4, batchSize: 10, maxPerBoot: 100 });
130
+ let calls = 0;
131
+ const embedder = {
132
+ embedSingle: async () => { calls++; return null; },
133
+ modelHash: "m#1",
134
+ dimension: 3
135
+ };
136
+
137
+ const scheduler = scheduleBootBackfill({
138
+ store, vectorIndex, cfg,
139
+ logger: { info() {}, warn() {} },
140
+ getEmbedder: () => embedder,
141
+ timing: { initialDelayMs: 0, continueDelayMs: 0 }
142
+ });
143
+
144
+ const status = await settle(() => scheduler.status());
145
+ scheduler.dispose();
146
+
147
+ assert.equal(status.finishReason, "stalled");
148
+ assert.equal(status.continuation, 0, "no continuation was scheduled");
149
+ assert.equal(calls, 4, "each row was attempted once, total");
150
+ assert.equal(store.needsEmbeddingFor("m#1").length, 4, "rows stay queued for the next boot");
151
+ store.close();
152
+ });
153
+
154
+ test("#604: dispose cancels a pending continuation (no query against a closed store)", async () => {
155
+ // 这是 #365 CodeRabbit 记过的同一类事故:计时器没入册,卸载后还在跑。
156
+ // 断言方式是关库之后不再有任何新调用 —— 若续排漏出去,会在这里炸。
157
+ const { store, vectorIndex, cfg } = setup({ count: 8, batchSize: 2, maxPerBoot: 2 });
158
+ let calls = 0;
159
+ const embedder = {
160
+ embedSingle: async () => { calls++; return [1, 0, 0]; },
161
+ modelHash: "m#1",
162
+ dimension: 3
163
+ };
164
+
165
+ const scheduler = scheduleBootBackfill({
166
+ store, vectorIndex, cfg,
167
+ logger: { info() {}, warn() {} },
168
+ getEmbedder: () => embedder,
169
+ timing: { initialDelayMs: 0, continueDelayMs: 60 }
170
+ });
171
+
172
+ // 等第一轮跑完(status 有值),此时续排已经排上但还没触发。
173
+ const deadline = Date.now() + 2000;
174
+ while (scheduler.status() === null && Date.now() < deadline) {
175
+ await new Promise((r) => setTimeout(r, 5));
176
+ }
177
+ const callsAtDispose = calls;
178
+ scheduler.dispose();
179
+ store.close();
180
+
181
+ await new Promise((r) => setTimeout(r, 200));
182
+ assert.equal(calls, callsAtDispose, "no embed attempt happened after dispose");
183
+ });
184
+
185
+ test("#604: autoReindexOnBoot: false schedules nothing at all", async () => {
186
+ const { store, vectorIndex, cfg } = setup();
187
+ const scheduler = scheduleBootBackfill({
188
+ store, vectorIndex, cfg: { ...cfg, autoReindexOnBoot: false },
189
+ logger: { info() {}, warn() {} },
190
+ getEmbedder: () => ({ embedSingle: async () => [1, 0, 0], modelHash: "m#1", dimension: 3 }),
191
+ timing: { initialDelayMs: 0, continueDelayMs: 0 }
192
+ });
193
+
194
+ await new Promise((r) => setTimeout(r, 60));
195
+ assert.equal(scheduler.status(), null, "the switch is respected");
196
+ assert.equal(store.needsEmbeddingFor("m#1").length, 6, "nothing was embedded");
197
+ scheduler.dispose();
198
+ store.close();
199
+ });
200
+
201
+ test("#604: an embedder that is not ready yet is retried before the run", async () => {
202
+ const { store, vectorIndex, cfg } = setup({ count: 2, batchSize: 2, maxPerBoot: 10 });
203
+ let readyChecks = 0;
204
+ const embedder = {
205
+ get ready() { readyChecks++; return readyChecks > 2; },
206
+ embedSingle: async () => [1, 0, 0],
207
+ modelHash: "m#1",
208
+ dimension: 3
209
+ };
210
+
211
+ const scheduler = scheduleBootBackfill({
212
+ store, vectorIndex, cfg,
213
+ logger: { info() {}, warn() {} },
214
+ getEmbedder: () => embedder,
215
+ timing: { initialDelayMs: 0, continueDelayMs: 0, readyRetryDelayMs: 0 }
216
+ });
217
+
218
+ const status = await settle(() => scheduler.status());
219
+ scheduler.dispose();
220
+
221
+ assert.ok(readyChecks >= 3, "waited for ready instead of giving up on the first check");
222
+ assert.equal(status.finishReason, "drained");
223
+ assert.equal(store.needsEmbeddingFor("m#1").length, 0);
224
+ store.close();
225
+ });
226
+
227
+ test("#604: a dead prefix deeper than the pick window stops the scheduler instead of spinning", async () => {
228
+ // 回归 Ephor 实测的那条悬崖:死前缀 >= maxWindow(500) 时,每轮窗口都被死行
229
+ // 占满、被 attempted 截断,rows 永远非空、indexed 恒 0,于是 finishReason 落到
230
+ // capped、调度器继续续排。默认配置连跑 21 轮:每轮 indexed=0 / failed=500,
231
+ // 累计 provider 调用 10500 次,好行 0/10 嵌入。
232
+ const store = createStore(":memory:");
233
+ const service = createService({ store, mirror: null, config: {} });
234
+ for (let i = 0; i < 10; i++) {
235
+ service.saveWithDedupe({ type: "preference", title: `好行${i}`, content: `good-${i}` });
236
+ }
237
+ for (let i = 0; i < 500; i++) {
238
+ service.saveWithDedupe({ type: "preference", title: `坏行${i}`, content: `bad-${i}` });
239
+ }
240
+ const vectorIndex = createVectorIndex({ store });
241
+ const cfg = {
242
+ autoReindexOnBoot: true,
243
+ reindexMaxPerBoot: 500,
244
+ reindexBatchSize: 10,
245
+ reindexRateLimitMs: 0
246
+ };
247
+ let calls = 0;
248
+ const embedder = {
249
+ embedSingle: async (text) => {
250
+ calls++;
251
+ return String(text).includes("bad-") ? null : [1, 0, 0];
252
+ },
253
+ modelHash: "m#1",
254
+ dimension: 3
255
+ };
256
+
257
+ const scheduler = scheduleBootBackfill({
258
+ store, vectorIndex, cfg,
259
+ logger: { info() {}, warn() {} },
260
+ getEmbedder: () => embedder,
261
+ timing: { initialDelayMs: 0, continueDelayMs: 0 }
262
+ });
263
+
264
+ const status = await waitForFinished(() => scheduler.status());
265
+ scheduler.dispose();
266
+ assert.equal(status.finishReason, "stalled", "no progress is reported honestly");
267
+ assert.equal(status.continuation, 0, "no continuation: the next boot is the retry point");
268
+ assert.equal(calls, 500, "one run, one attempt per row — not 21 rounds of the same 500");
269
+ assert.equal(store.needsEmbeddingFor("m#1", 5000).length, 510, "everything stays queued");
270
+ store.close();
271
+ });
272
+
273
+ test("#604: a synchronous throw before the promise still publishes an error status", async () => {
274
+ // 旧实现的同步 catch 只写了日志、没写 lastBackfill,于是面板读到 null,
275
+ // 显示的是「从来没跑过」而不是「跑了但失败」—— 两种状态的含义完全不同。
276
+ const { store, vectorIndex, cfg } = setup({ count: 2 });
277
+ const scheduler = scheduleBootBackfill({
278
+ store, vectorIndex, cfg,
279
+ logger: { info() {}, warn() {} },
280
+ getEmbedder: () => { throw new Error("embedder blew up"); },
281
+ timing: { initialDelayMs: 0, continueDelayMs: 0, maxContinuations: 0 }
282
+ });
283
+
284
+ const status = await settle(() => scheduler.status());
285
+ scheduler.dispose();
286
+
287
+ assert.equal(status.state, "error", "the failure is visible instead of reading as never-ran");
288
+ assert.match(status.error, /embedder blew up/);
289
+ store.close();
290
+ });
291
+
292
+ test("#604: a transient provider outage retries inside the boot instead of waiting for the next start", async () => {
293
+ // 一轮 batch 10 刚好全撞上 503:indexed === 0,但这是运气不好,不是死行。
294
+ // 判成 stalled 会把重试推到下次启动(可能几小时后),而 provider 一秒后就好了。
295
+ // 判据在 failureReasons 里:抛错落在 `error:` 桶(瞬时),null 向量落在
296
+ // `empty-vector`(对该行是永久性)。
297
+ const { store, vectorIndex, cfg } = setup({ count: 10, batchSize: 10, maxPerBoot: 500 });
298
+ let calls = 0;
299
+ const embedder = {
300
+ embedSingle: async () => {
301
+ calls++;
302
+ // 第一轮的 10 次全挂,之后恢复。
303
+ if (calls <= 10) throw new Error("503 Service Unavailable");
304
+ return [1, 0, 0];
305
+ },
306
+ modelHash: "m#1",
307
+ dimension: 3
308
+ };
309
+
310
+ const scheduler = scheduleBootBackfill({
311
+ store, vectorIndex, cfg,
312
+ logger: { info() {}, warn() {} },
313
+ getEmbedder: () => embedder,
314
+ timing: { initialDelayMs: 0, continueDelayMs: 0 }
315
+ });
316
+
317
+ const status = await waitForReason(() => scheduler.status(), "drained");
318
+ scheduler.dispose();
319
+
320
+ assert.equal(status.finishReason, "drained", "the retry rode out the outage and finished");
321
+ assert.ok(status.continuation >= 1, "a retry actually happened inside this boot");
322
+ assert.equal(store.needsEmbeddingFor("m#1", 100).length, 0, "every row ended up embedded");
323
+ store.close();
324
+ });
325
+
326
+ test("#604: a provider that stays down stops after the small error budget", async () => {
327
+ // 反面:provider 一直挂。errored 值得重试,但不能用 capped 的 20 轮预算陪跑,
328
+ // 否则「瞬时」这个借口就变成了另一个活锁。
329
+ // 断言必须看「排了几轮」,不能只看「调用次数有没有超」——后者在调用一多就
330
+ // dispose,分辨不出 4 轮和 20 轮(变异自证时确实漏过这条)。
331
+ const { store, vectorIndex, cfg } = setup({ count: 10, batchSize: 10, maxPerBoot: 500 });
332
+ let calls = 0;
333
+ let runs = 0;
334
+ const embedder = {
335
+ embedSingle: async () => {
336
+ calls++;
337
+ throw new Error("503 Service Unavailable");
338
+ },
339
+ modelHash: "m#1",
340
+ dimension: 3
341
+ };
342
+
343
+ const scheduler = scheduleBootBackfill({
344
+ store, vectorIndex, cfg,
345
+ logger: { info() {}, warn() {} },
346
+ getEmbedder: () => embedder,
347
+ onStatus: (s) => { if (s.state === "running") runs++; },
348
+ timing: { initialDelayMs: 0, continueDelayMs: 0, maxErrorContinuations: 3 }
349
+ });
350
+
351
+ // 等到调用数不再增长(调度器真的停手),而不是等到某个固定上限。
352
+ const deadline = Date.now() + 10000;
353
+ let lastCalls = -1;
354
+ let quietSince = Date.now();
355
+ while (Date.now() < deadline) {
356
+ await new Promise((r) => setTimeout(r, 20));
357
+ if (calls !== lastCalls) { lastCalls = calls; quietSince = Date.now(); }
358
+ else if (Date.now() - quietSince > 300) break;
359
+ }
360
+ const status = scheduler.status();
361
+ scheduler.dispose();
362
+
363
+ assert.equal(status.finishReason, "errored", "an unreachable provider is not a dead row");
364
+ // 1 次初始 + 3 次重试 = 4 轮。若走 maxContinuations(20) 这里会是 21。
365
+ assert.equal(runs, 4, `the error budget is 3 retries, not the 20-round cap (runs=${runs})`);
366
+ assert.equal(calls, 40, `4 rounds × 10 rows (calls=${calls})`);
367
+ assert.equal(store.needsEmbeddingFor("m#1", 100).length, 10, "everything stays queued");
368
+ store.close();
369
+ });
@@ -199,6 +199,255 @@ test("dream (non-freeze): same-scope conflict is still auto-adjudicated (regress
199
199
  store.close();
200
200
  });
201
201
 
202
+ // Issue #603 第 1 层:freeze 分支此前绕过全部三个上限,且把「已在队列里的对」
203
+ // 当成新停车报进 frozen。下面三条锁住修完之后的三个性质。
204
+ test("#603 freeze: a pair already in the queue is not re-reported as a new freeze", async () => {
205
+ const { store, service, vectorIndex } = setup();
206
+ savePair(service, vectorIndex, "索引策略A", "索引策略B", { agent: "coder" }, { agent: "coder" });
207
+ const logger = { warn: () => {}, info: () => {} };
208
+
209
+ const first = await runSleep(llmCtx(), service, baseConfig({ conflictFreezeEnabled: true }), logger, { embedder, vectorIndex }, null);
210
+ assert.equal(first.phases.conflicts.frozen, 1, "第一次是真插入");
211
+ assert.equal(store.countConflictPending(), 1);
212
+
213
+ const second = await runSleep(llmCtx(), service, baseConfig({ conflictFreezeEnabled: true }), logger, { embedder, vectorIndex }, null);
214
+ assert.equal(second.phases.conflicts.frozen, 0, "第二次检出的是已入队的对,不算新停车");
215
+ assert.equal(second.status, "noop", "没有新停车就是 noop,不是 ok");
216
+ assert.equal(store.countConflictPending(), 1, "队列没有被重复写入");
217
+ store.close();
218
+ });
219
+
220
+ test("#603 freeze: cap is applied after filtering already-parked pairs, so the queue keeps growing", async () => {
221
+ const { store, service, vectorIndex } = setup();
222
+ // 三对互不相交的高相似记忆:贪心配对每轮都能检出三对。
223
+ savePair(service, vectorIndex, "队列A1", "队列A2", { agent: "coder" }, { agent: "coder" });
224
+ savePair(service, vectorIndex, "队列B1", "队列B2", { agent: "coder" }, { agent: "coder" });
225
+ savePair(service, vectorIndex, "队列C1", "队列C2", { agent: "coder" }, { agent: "coder" });
226
+ const logger = { warn: () => {}, info: () => {} };
227
+ // 每轮只处理一对,且远小于候选数——裸 slice 会让第一对永远占住这个名额。
228
+ const cfg = baseConfig({ conflictFreezeEnabled: true, sleepConflictMaxPairsPerRun: 1 });
229
+
230
+ await runSleep(llmCtx(), service, cfg, logger, { embedder, vectorIndex }, null);
231
+ assert.equal(store.countConflictPending(), 1);
232
+ await runSleep(llmCtx(), service, cfg, logger, { embedder, vectorIndex }, null);
233
+ assert.equal(store.countConflictPending(), 2, "第二对必须能轮到,不能被第一对饿死");
234
+ await runSleep(llmCtx(), service, cfg, logger, { embedder, vectorIndex }, null);
235
+ assert.equal(store.countConflictPending(), 3, "第三对同理");
236
+
237
+ const done = await runSleep(llmCtx(), service, cfg, logger, { embedder, vectorIndex }, null);
238
+ assert.equal(done.phases.conflicts.frozen, 0, "全部入队后不再虚报");
239
+ assert.equal(done.phases.conflicts.perRunSkipped, 0, "没有新对可处理,不算被截断");
240
+ store.close();
241
+ });
242
+
243
+ test("#603 freeze: the pending queue budget stops parking and says so", async () => {
244
+ const { store, service, vectorIndex } = setup();
245
+ savePair(service, vectorIndex, "预算A1", "预算A2", { agent: "coder" }, { agent: "coder" });
246
+ savePair(service, vectorIndex, "预算B1", "预算B2", { agent: "coder" }, { agent: "coder" });
247
+ savePair(service, vectorIndex, "预算C1", "预算C2", { agent: "coder" }, { agent: "coder" });
248
+ const warnings = [];
249
+ const logger = { warn: (m) => warnings.push(String(m)), info: () => {} };
250
+
251
+ const result = await runSleep(
252
+ llmCtx(), service,
253
+ baseConfig({ conflictFreezeEnabled: true, conflictFreezeMaxPending: 2 }),
254
+ logger, { embedder, vectorIndex }, null
255
+ );
256
+ assert.equal(store.countConflictPending(), 2, "只装到队列上限为止");
257
+ assert.equal(result.phases.conflicts.frozen, 2);
258
+ assert.equal(result.phases.conflicts.pendingSkipped, 1, "被队列上限挡下的对要如实报出来");
259
+ assert.ok(warnings.some((w) => /freeze queue full/.test(w)), "要留一条 warn,不能静默丢弃");
260
+
261
+ // 队列已满:再跑一轮不会新增,也不会把「满了」报成可续排的 backlog。
262
+ const again = await runSleep(
263
+ llmCtx(), service,
264
+ baseConfig({ conflictFreezeEnabled: true, conflictFreezeMaxPending: 2 }),
265
+ logger, { embedder, vectorIndex }, null
266
+ );
267
+ assert.equal(store.countConflictPending(), 2);
268
+ assert.equal(again.phases.conflicts.frozen, 0);
269
+ assert.equal(again.phases.conflicts.perRunSkipped, 0, "队列满不是 per-run 截断,不该触发续排");
270
+ store.close();
271
+ });
272
+
273
+ // Issue #603 第 2 层:sleep 停车时必须把候选对的相似度一起落库。此前这个数字只
274
+ // 活在 reason 文本里(「相似度 0.87」),面板靠正则回收、排序无从下手——同一轮
275
+ // 产出的对 created_at 全同,队列顺序等于随机。
276
+ test("#603 freeze: the parked row carries the similarity the detector measured", async () => {
277
+ const { store, service, vectorIndex } = setup();
278
+ savePair(service, vectorIndex, "排序A1", "排序A2", { agent: "coder" }, { agent: "coder" });
279
+
280
+ await runSleep(llmCtx(), service, baseConfig({ conflictFreezeEnabled: true }), { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null);
281
+
282
+ const [row] = store.listConflictPending();
283
+ assert.ok(row, "the pair must be parked");
284
+ // 两条记忆的向量都是 [1,0,0],余弦为 1。断言「是数字且等于 1」而不是「非空」:
285
+ // 前者能抓住「传了 undefined / 传了字符串」这类静默降级。
286
+ assert.equal(typeof row.similarity, "number", "similarity must be a number, not a string or undefined");
287
+ assert.equal(row.similarity, 1, "identical vectors are similarity 1");
288
+ store.close();
289
+ });
290
+
291
+ // 与上一条互补:拿不到相似度时必须是「未知」而不是 0。0 会被排到最后,等于替
292
+ // 用户断言「这两条毫不相似」——那是编造。
293
+ test("#603 freeze: a pair parked without a measured similarity stays unknown", async () => {
294
+ const { store, service } = setup();
295
+ // 直接走 service 的停车口(没有向量索引参与 = 没测过相似度)。
296
+ const a = service.saveWithDedupe({ type: "project", title: "未知A", content: "正文A" }).memory;
297
+ const b = service.saveWithDedupe({ type: "project", title: "未知B", content: "正文B" }).memory;
298
+ service.saveConflictPending({ run_id: "r1", memory_a: a.id, memory_b: b.id, reason: "人工停车" });
299
+
300
+ const [row] = store.listConflictPending();
301
+ assert.equal(row.similarity, undefined, "no measurement must read back as unknown, not 0");
302
+ store.close();
303
+ });
304
+
305
+ // Issue #603 第 4 层:自动裁决档。相似度极高的**同 scope** 对直接保留新条、归档
306
+ // 旧条,不进人工队列;跨 scope 对永不自动(归属是用户的决定)。
307
+ test("#603 auto-resolve: a near-duplicate same-scope pair is superseded without entering the queue", async () => {
308
+ const { store, service, vectorIndex } = setup();
309
+ const [first, second] = savePair(service, vectorIndex, "缓存策略A", "缓存策略B", { agent: "coder" }, { agent: "coder" });
310
+
311
+ const result = await runSleep(
312
+ llmCtx(), service,
313
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: true, conflictAutoResolveMinSimilarity: 0.95 }),
314
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
315
+ );
316
+ assert.equal(result.status, "ok");
317
+
318
+ // 队列必须是空的:这一对根本没进人工队列。
319
+ assert.equal(store.countConflictPending(), 0, "auto-resolved pairs must not reach the manual queue");
320
+ // 不猜 id 顺序,用归档状态反推哪一侧被取代。
321
+ const archivedIds = [first.id, second.id].filter((id) => store.getById(id).archived);
322
+ assert.equal(archivedIds.length, 1, "exactly one side is archived");
323
+ const loser = archivedIds[0];
324
+ const survivor = [first.id, second.id].find((id) => !store.getById(id).archived);
325
+ // 留下的那条正文一字不动(supersede 把注记写在败者侧,不污染幸存者)。
326
+ assert.doesNotMatch(store.getById(survivor).content, /已被取代|superseded by/,
327
+ "the survivor's body must stay clean");
328
+ // 败者侧带可追溯注记(#126 的原设计)。
329
+ assert.match(store.getById(loser).content, /已被取代|superseded by/,
330
+ "traceability lives on the archived side");
331
+ // 机器台账也要有一条:自动动作必须能事后回答「什么时候、被谁判的」。
332
+ const reviewed = typeof store.listConflictReviewed === "function" ? store.listConflictReviewed({ limit: 10 }) : [];
333
+ assert.ok(reviewed.length >= 1, "auto-resolve must leave a ledger row");
334
+ assert.equal(reviewed[0].outcome, "supersede");
335
+ store.close();
336
+ });
337
+
338
+ test("#603 auto-resolve: a cross-scope pair is never auto-adjudicated, even at similarity 1", async () => {
339
+ const { store, service, vectorIndex } = setup();
340
+ const [xNew, xOld] = savePair(service, vectorIndex, "部署清单A", "部署清单B", { agent: "coder" }, { agent: "writer" });
341
+
342
+ const result = await runSleep(
343
+ llmCtx(), service,
344
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: true, conflictAutoResolveMinSimilarity: 0.95 }),
345
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
346
+ );
347
+ assert.equal(result.status, "ok");
348
+
349
+ // 向量完全相同(余弦 1),但归属不同 → 只能停车等人判。
350
+ assert.equal(store.countConflictPending(), 1, "cross-scope pair still goes to the manual queue");
351
+ assert.equal(store.getById(xOld.id).archived, false, "cross-scope loser is never auto-archived");
352
+ assert.equal(store.getById(xNew.id).archived, false, "cross-scope winner is untouched too");
353
+ store.close();
354
+ });
355
+
356
+ test("#603 auto-resolve: with the switch off nothing is auto-adjudicated", async () => {
357
+ const { store, service, vectorIndex } = setup();
358
+ savePair(service, vectorIndex, "主题A", "主题B", { agent: "coder" }, { agent: "coder" });
359
+
360
+ const result = await runSleep(
361
+ llmCtx(), service,
362
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: false }),
363
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
364
+ );
365
+ assert.equal(result.status, "ok");
366
+ assert.equal(store.countConflictPending(), 1, "with the switch off, nothing is auto-adjudicated");
367
+ store.close();
368
+ });
369
+
370
+ // 自动裁决保留的是**信息量更大**的那条,不是更新的那条。
371
+ // 反例来自 live 库:>=0.95 的 20 对里 11 对(55%)新条比旧条短,平均短 132 字——
372
+ // 蒸馏压缩过的一版反而更新时间更晚,按「留新的」会把更完整的那条归档掉。
373
+ test("#603 auto-resolve: the longer copy survives even when the shorter one is newer", async () => {
374
+ const { store, service, vectorIndex } = setup();
375
+ // 先存长的那条(更旧),再存短的那条(更新):新条更短,正是实测里的多数情况。
376
+ const longBody = "缓存策略要按写入频率分三档:热数据走内存、温数据走 SSD、冷数据归档到对象存储。"
377
+ + "切换阈值由每小时访问次数决定,低于一次就降档,连续三天高于十次再升档。";
378
+ const shortBody = "缓存策略按写入频率分档。";
379
+ const older = service.saveWithDedupe({
380
+ type: "project", title: "缓存分层", content: longBody
381
+ }).memory;
382
+ const newer = service.saveWithDedupe({
383
+ type: "project", title: "缓存分层摘要", content: shortBody
384
+ }).memory;
385
+ vectorIndex.saveEmbedding(older.id, [1, 0, 0]);
386
+ vectorIndex.saveEmbedding(newer.id, [1, 0, 0]);
387
+ assert.ok(older.content.length > newer.content.length, "fixture: the older copy must be the longer one");
388
+
389
+ const result = await runSleep(
390
+ llmCtx(), service,
391
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: true, conflictAutoResolveMinSimilarity: 0.95 }),
392
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
393
+ );
394
+ assert.equal(result.status, "ok");
395
+ assert.equal(result.phases.conflicts.autoResolved, 1, "the pair is auto-adjudicated");
396
+
397
+ // 幸存者必须是长的那条:留新的会把三档策略削成一句话。
398
+ assert.equal(store.getById(older.id).archived, false, "the longer, older copy must survive");
399
+ assert.equal(store.getById(newer.id).archived, true, "the shorter, newer copy is the one archived");
400
+ store.close();
401
+ });
402
+
403
+ // 长度相同才是「信息量相当」,此时退回更新时间:新条胜出,且结论确定可重放。
404
+ test("#603 auto-resolve: equal-length copies fall back to the newer one, deterministically", async () => {
405
+ const { store, service, vectorIndex } = setup();
406
+ const body = "部署脚本先跑 migrate 再跑 smoke,任一步失败就回滚到上一个 tag。";
407
+ const older = service.saveWithDedupe({ type: "project", title: "部署甲", content: body }).memory;
408
+ const newer = service.saveWithDedupe({ type: "project", title: "部署乙", content: body }).memory;
409
+ vectorIndex.saveEmbedding(older.id, [1, 0, 0]);
410
+ vectorIndex.saveEmbedding(newer.id, [1, 0, 0]);
411
+
412
+ const result = await runSleep(
413
+ llmCtx(), service,
414
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: true, conflictAutoResolveMinSimilarity: 0.95 }),
415
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
416
+ );
417
+ assert.equal(result.status, "ok");
418
+ assert.equal(result.phases.conflicts.autoResolved, 1);
419
+ // 等长 → 比 updated_at:后存的赢。长度相等时不至于两边都"更完整",留新的是安全的。
420
+ assert.equal(store.getById(newer.id).archived, false, "equal length: the newer copy survives");
421
+ assert.equal(store.getById(older.id).archived, true);
422
+ store.close();
423
+ });
424
+
425
+ test("#603 auto-resolve: a failed adjudication falls back to the manual queue instead of vanishing", async () => {
426
+ const { store, service, vectorIndex } = setup();
427
+ savePair(service, vectorIndex, "回退A", "回退B", { agent: "coder" }, { agent: "coder" });
428
+
429
+ // 让 supersede 一定失败:把 applyDecisions 依赖的 update 打断。
430
+ const realUpdate = service.update.bind(service);
431
+ let calls = 0;
432
+ service.update = (...args) => {
433
+ calls++;
434
+ throw new Error("write refused");
435
+ };
436
+
437
+ const result = await runSleep(
438
+ llmCtx(), service,
439
+ baseConfig({ conflictFreezeEnabled: true, conflictAutoResolveEnabled: true, conflictAutoResolveMinSimilarity: 0.95 }),
440
+ { warn: () => {}, info: () => {} }, { embedder, vectorIndex }, null
441
+ );
442
+ service.update = realUpdate;
443
+
444
+ assert.ok(calls > 0, "the auto path did try to apply");
445
+ // 自动档没落地 → 这一对必须退回人工队列。丢掉它才是真正的静默丢失。
446
+ assert.equal(store.countConflictPending(), 1, "a failed auto-resolve must land in the manual queue");
447
+ assert.equal(result.status, "ok", "the run itself is not a failure");
448
+ store.close();
449
+ });
450
+
202
451
  test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope pair is adjudicated", async () => {
203
452
  const { store, service, vectorIndex } = setup();
204
453
  const [yNew, yOld] = savePair(service, vectorIndex, "缓存策略A", "缓存策略B", { agent: "coder" }, { agent: "coder" });
@@ -373,6 +373,20 @@ test("issue#126r: buildOutcome records a disposition for the new actions", () =>
373
373
  assert.equal(outcome.byId.d2, "differentiated");
374
374
  });
375
375
 
376
+ // #619 / Ephor 第七轮 ②:dream 那条路上,一个「只刷新不铸造」的轮此前 outcome.byId
377
+ // 是空对象——审计行看不出任何目标被处置过。#126 review 在 supersede 上踩过同一个
378
+ // 坑(注释就写在 buildOutcome 里),这次 create_merged 又漏了。刷新要有自己的名字,
379
+ // 不能冒充 created:一个是「新生了这条」,一个是「把既有那条重写了」。
380
+ test("issue#619r: buildOutcome names the create dispositions, refresh is not a birth", () => {
381
+ const outcome = buildOutcome([
382
+ { action: "create", id: "fresh-1", type: "pattern" },
383
+ { action: "create_merged", id: "42b75761", type: "pattern" }
384
+ ]);
385
+ assert.equal(outcome.byId["fresh-1"], "created");
386
+ assert.equal(outcome.byId["42b75761"], "created-refreshed", "刷新不是 created");
387
+ assert.deepEqual(outcome.byId, { "fresh-1": "created", "42b75761": "created-refreshed" });
388
+ });
389
+
376
390
  test("issue#533r: differentiate leaves the cached vectors alone (nothing changed to re-embed)", async () => {
377
391
  const { service, store, vectorIndex } = setup();
378
392
  const { a, b } = seedPair(service, vectorIndex, "向量甲", "向量乙");