@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -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
+ });
@@ -119,6 +119,14 @@ test("重复跑是幂等的:已有目标的占位行就不再插第二行", ()
119
119
  test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中英两处", () => {
120
120
  const sameMinor = checkSupportTable(securityDoc, version);
121
121
  assert.equal(sameMinor.ok, true, `真实 SECURITY.md 应与 package.json ${version} 一致:${sameMinor.problems.join(";")}`);
122
+ if (version.includes("-")) {
123
+ // 预发布版不占支持表的格:beta 不是受支持的稳定版本,挪过去等于对外宣称它已活跃维护。
124
+ // 显式断言 skipped,而不是让它悄悄走 ok 分支——否则「不检查」和「检查通过」长得一样。
125
+ assert.equal(sameMinor.skipped, "prerelease",
126
+ `预发布版 ${version} 应显式标 skipped=prerelease,实际 ${sameMinor.skipped}`);
127
+ } else {
128
+ assert.equal(sameMinor.skipped, null, `稳定版 ${version} 不该被当成预发布版跳过`);
129
+ }
122
130
 
123
131
  const nextMinor = `${version.split(".")[0]}.${Number(version.split(".")[1]) + 1}.0`;
124
132
  const crossing = checkSupportTable(securityDoc, nextMinor);
@@ -128,6 +136,18 @@ test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中
128
136
  "每条问题都要写出期望值,否则人不知道改成什么:" + crossing.problems.join(";"));
129
137
  });
130
138
 
139
+ test("预发布版不把支持表首行挪到自己头上(beta 不是受支持的稳定版)", () => {
140
+ const [major, minor] = "0.14.0-beta.1".split(".");
141
+ const betaRow = `${major}.${minor}.x`;
142
+ const r = checkSupportTable(securityDoc, "0.14.0-beta.1");
143
+ assert.equal(r.ok, true);
144
+ assert.equal(r.expected, betaRow, "expected 仍按 major.minor 算,只是不拿来要求表");
145
+ const body = securityDoc.slice(securityDoc.indexOf("## Supported Versions"));
146
+ const firstRow = body.split(/\r?\n/).filter((l) => /^\|\s*[\d<]/.test(l))[0].split("|")[1].trim();
147
+ assert.notEqual(firstRow, betaRow,
148
+ `支持表首行不该是 ${betaRow}——预发布版占格等于宣称它已在活跃维护`);
149
+ });
150
+
131
151
  test("SECURITY.md 支持版本表:节缺失或表格形状变了也报错(不能静默通过)", () => {
132
152
  assert.equal(checkSupportTable("# Security\n\n## 别的节\n", "0.9.0").ok, false);
133
153
  const noRows = "## Supported Versions\r\n\r\n没有表格\r\n\r\n## 支持版本\r\n\r\n没有表格\r\n";
@@ -67,6 +67,19 @@ test("findSection:版本号里的点被转义过,不会当成通配", () =>
67
67
  assert.equal(findSection(text, "0.8.16"), null, "点若被当通配就会误命中 0x8x16");
68
68
  });
69
69
 
70
+ test("findSection:预发布版本号(带 -beta.N)能取到自己的小节", () => {
71
+ // 预发布版本号带 `-` 与额外的点。此前入口正则只认 x.y.z,release job 抽正文这步直接
72
+ // exit 1,预发布版根本发不出去;findSection 也只转义了点,元字符会被当语义用。
73
+ const text = SAMPLE.replace(
74
+ "## [0.8.16] - 2026-10-12",
75
+ "## [0.14.0-beta.1] - 2026-11-11\n\n- 预发布条目\n\n## [0.8.16] - 2026-10-12"
76
+ );
77
+ const sec = findSection(text, "0.14.0-beta.1");
78
+ assert.ok(sec.includes("预发布条目"), "预发布小节要能取到");
79
+ assert.equal(sec.includes("旧条目"), false, "不能越过下一版标题");
80
+ assert.equal(findSection(text, "0.14.0"), null, "0.14.0 不该误命中 0.14.0-beta.1(反之亦然)");
81
+ });
82
+
70
83
  test("parseBullet:拆出标题与正文;非该形状返回 null 由调用方原样保留", () => {
71
84
  assert.deepEqual(parseBullet("- **标题**:正文在这里"), { title: "标题", body: "正文在这里" });
72
85
  assert.deepEqual(parseBullet("- **只有标题**"), { title: "只有标题", body: "" });
@@ -238,3 +251,15 @@ test("入口:版本号格式不对时直接拒绝", () => {
238
251
  assert.notEqual(res.status, 0, "只接受 x.y.z");
239
252
  assert.match(res.stderr, /usage: release-notes\.mjs/, "要说清用法,别静默退出");
240
253
  });
254
+
255
+ test("入口:预发布版本号不被当成格式错误(否则 beta 版发不出去)", () => {
256
+ // 反向验证上面那条守卫没有收紧过头:0.14.0-beta.1 是合法发布版本,此前会被
257
+ // 「格式不对」拦下。这里走到 findSection,报的必须是「找不到小节」而不是"usage"。
258
+ const root = mkdtempSync(join(tmpdir(), "mneme-release-notes-"));
259
+ mkdirSync(join(root, "dsh-mneme"), { recursive: true });
260
+ writeFileSync(join(root, "dsh-mneme", "CHANGELOG.md"), SAMPLE, "utf8");
261
+ const res = spawnSync(process.execPath, [scriptPath, "0.14.0-beta.1"], { cwd: root, encoding: "utf8" });
262
+ assert.notEqual(res.status, 0, "样例 CHANGELOG 里没有这一版,应当非零退出");
263
+ assert.doesNotMatch(res.stderr, /usage: release-notes\.mjs/, "不许当成格式错误:" + res.stderr);
264
+ assert.match(res.stderr, /changelog section not found: 0\.14\.0-beta\.1/, "要报找不到小节");
265
+ });