@modusensus/dsh-mneme 0.9.4 → 0.10.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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
@@ -0,0 +1,315 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createVectorIndex } from "../src/vector-index.js";
6
+ import { createSleepScheduler, runSleep } from "../src/dream/sleep.js";
7
+
8
+ // Issue #529(续排):本轮被预算截断(conflict 相位还有没进这一轮的对)且送出去的每一对
9
+ // 都落地了时,调度器不该等满一个 sleepMinIntervalHours(默认 8h)才跑下一轮——池子没清空,
10
+ // 剩下的对只能等到下一次到点,等于把"分批"变成"隔夜慢慢分批"。
11
+ //
12
+ // 这一组用例锁的是续排语义,防的回归是:
13
+ // ① 判据拿 status 反推 → 全判 keep 的轮次 applied 恒为 0、status 是 noop(永远到不了 ok),
14
+ // 续排在最该追的场景静默失效;
15
+ // ② 只把冷却项改成 0、不在回调里补挂闹钟 → 续排只追一轮就断,剩下的对又回到隔夜等;
16
+ // ③ 不限制连击 → 用户持续写入时池子进得比追得快,变成永不落地的每五分钟一轮,
17
+ // 而审计上每轮都"有产出",肉眼很难发现;
18
+ // ④ 失败轮也追 → 与连败退避抢同一只闹钟,退避被自己踩空。
19
+
20
+ const embedder = {
21
+ embedSingle: async () => [1, 0, 0],
22
+ embed: async () => [1, 0, 0],
23
+ schedule: () => {},
24
+ modelHash: "mock#1",
25
+ dimension: 3
26
+ };
27
+
28
+ function mockCtx(responder) {
29
+ let call = 0;
30
+ return {
31
+ logger: { warn: () => {}, info: () => {} },
32
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
33
+ llm: {
34
+ async *stream(options) {
35
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
36
+ let reply = "[]";
37
+ if (options.purpose === "sleep-conflict") reply = responder(userText, ++call);
38
+ yield { type: "text-delta", index: 0, text: reply };
39
+ yield { type: "finish", reason: { kind: "stop" } };
40
+ }
41
+ }
42
+ };
43
+ }
44
+
45
+ function setup() {
46
+ const store = createStore(":memory:");
47
+ const service = createService({ store, mirror: null, config: {} });
48
+ const vectorIndex = createVectorIndex({ store });
49
+ service.setEmbedder(embedder);
50
+ service.setVectorIndex(vectorIndex);
51
+ return { store, service, vectorIndex };
52
+ }
53
+
54
+ // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1,findPotentialConflicts 生成 C(n,2) 对,
55
+ // 去重阶段按相似度留下 n/2 对(每记忆至多一对)。
56
+ function seedSimilarPairs(service, vectorIndex, n) {
57
+ for (let i = 0; i < n; i++) {
58
+ const m = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 }).memory;
59
+ vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
60
+ }
61
+ }
62
+
63
+ function baseConfig(overrides = {}) {
64
+ return {
65
+ sleepModeEnabled: true,
66
+ sleepIdleMinutes: 5,
67
+ sleepMinIntervalHours: 8,
68
+ sleepConflictStrictness: "normal",
69
+ sleepArchiveDays: 30,
70
+ sleepCompressDays: 90,
71
+ sleepPatternMinMemories: 100,
72
+ sleepMaxPatternPerRun: 3,
73
+ ...overrides
74
+ };
75
+ }
76
+
77
+ // 与 test/sleep-failure-backoff.test.js 同款房型:时钟与定时器全部注入。
78
+ function makeHarness(overrides = {}) {
79
+ let nowMs = 10_000_000;
80
+ const timers = [];
81
+ let seq = 1;
82
+ const store = createStore(":memory:");
83
+ const service = createService({ store, mirror: null, config: {} });
84
+ let runs = 0;
85
+ let outcome = () => ({ ok: true, status: "noop" });
86
+ // 运行**期间**要发生的事(写入 / 耗时)——用来复现"跑完那一刻已挂着的表只剩几秒"。
87
+ let duringRun = null;
88
+ const sched = createSleepScheduler({
89
+ service,
90
+ config: { sleepModeEnabled: true, sleepIdleMinutes: 5, sleepMinIntervalHours: 1, ...overrides },
91
+ logger: { warn: () => {} },
92
+ onRun: async () => {
93
+ runs++;
94
+ if (duringRun) await duringRun();
95
+ return outcome();
96
+ },
97
+ now: () => nowMs,
98
+ setTimeoutFn: (fn, delay) => {
99
+ const t = { id: seq++, at: nowMs + delay, fn };
100
+ timers.push(t);
101
+ return t.id;
102
+ },
103
+ clearTimeoutFn: (id) => {
104
+ const i = timers.findIndex((t) => t.id === id);
105
+ if (i >= 0) timers.splice(i, 1);
106
+ }
107
+ });
108
+ const fireNext = async () => {
109
+ timers.sort((a, b) => a.at - b.at);
110
+ const t = timers.shift();
111
+ if (!t) throw new Error("no armed timer to fire");
112
+ nowMs = t.at;
113
+ await t.fn();
114
+ };
115
+ return {
116
+ sched,
117
+ store,
118
+ timers,
119
+ fireNext,
120
+ runs: () => runs,
121
+ now: () => nowMs,
122
+ setOutcome: (fn) => { outcome = fn; },
123
+ setDuringRun: (fn) => { duringRun = fn; },
124
+ write: () => { nowMs += 1000; sched.noteWrite(); },
125
+ advance: (ms) => { nowMs += ms; }
126
+ };
127
+ }
128
+
129
+ const MIN = 60_000;
130
+ const HOUR = 3_600_000;
131
+
132
+ test("issue#529 续排:clean + 还有没进这一轮的对 ⇒ 下一个闹钟钉在静默窗口,不等最小间隔", async () => {
133
+ const h = makeHarness();
134
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
135
+ h.write();
136
+ await h.fireNext();
137
+ assert.equal(h.runs(), 1, "静默到点起跑一轮");
138
+ assert.equal(h.timers.length, 1, "关键:追 backlog 的轮次跑完必须自己补挂闹钟(此前一条都不挂)");
139
+ const delay = h.timers[0].at - h.now();
140
+ assert.equal(delay, 5 * MIN + 1000, "下限是一个静默窗口,不是 1 秒也不是 1 小时");
141
+
142
+ await h.fireNext();
143
+ assert.equal(h.runs(), 2, "关键:闹钟真的起跑了第二轮(只补挂不起跑等于没追)");
144
+ h.store.close();
145
+ });
146
+
147
+ test("issue#529 续排:不 clean 时不追,间隔回到最小间隔", async () => {
148
+ const h = makeHarness();
149
+ h.setOutcome(() => ({ ok: true, status: "degraded", catchup: false, backlog: true }));
150
+ h.write();
151
+ await h.fireNext();
152
+ assert.equal(h.runs(), 1);
153
+ assert.equal(h.timers.length, 0, "有对没落地 ⇒ 不补挂短闹钟,等下一次写入重新起算");
154
+ const t = h.now();
155
+ assert.equal(h.sched.shouldRun(t + HOUR - 1), false, "冷却仍是基数间隔");
156
+ assert.equal(h.sched.shouldRun(t + HOUR), true);
157
+ h.store.close();
158
+ });
159
+
160
+ test("issue#529 续排:运行期间用户写入挂的表要在收尾时重挂,不能剩几秒就开下一批", async () => {
161
+ const h = makeHarness();
162
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
163
+ h.write();
164
+ // 运行一开始就按静默窗口挂好了表(at = 起跑时刻 + 5min + 1s)。
165
+ const armedAt = h.timers[0].at;
166
+ h.setDuringRun(async () => {
167
+ // 运行早期用户又写了一次:noteWrite 撤表重挂,新表从**那一刻**起算。
168
+ h.advance(MIN);
169
+ h.write();
170
+ // 这一轮本身跑了很久(LLM 往返 + 落库),早就越过那只表的到点时刻。真实环境里
171
+ // 这只表此刻已经可以立即触发——收尾若不重挂,下一批就是"几秒后开跑",
172
+ // 「让库安静一个静默窗口再追」形同虚设。
173
+ h.advance(10 * MIN);
174
+ });
175
+ await h.fireNext();
176
+ assert.equal(h.runs(), 1);
177
+ assert.ok(h.now() > armedAt, "前置:这一轮确实跑过了原来那只表的到点时刻");
178
+ assert.equal(h.timers.length, 1, "收尾必须只剩(重挂的)一只表");
179
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "关键:从**收尾这一刻**重算整一个静默窗口,不是沿用运行期挂的旧表");
180
+ h.store.close();
181
+ });
182
+
183
+ test("issue#529 续排:台账写入失败要让这一轮不 clean,不能每个静默窗口重问同一个冲突", async () => {
184
+ const { service, store, vectorIndex } = setup();
185
+ seedSimilarPairs(service, vectorIndex, 8);
186
+ // 留痕写不进去:这一对既没被记成"已审",下一轮还会原样回到候选池。若仍判 clean,
187
+ // 续排会一个静默窗口问一次 LLM、直到连击上限,而审计上每轮都是干净的成功轮。
188
+ service.saveConflictReviewed = () => {
189
+ throw new Error("ledger unavailable");
190
+ };
191
+ const ctx = mockCtx(() => "[]");
192
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
193
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
194
+ assert.ok(result.phases.conflicts.ledgerFailed > 0, "前置:确实有留痕写入失败");
195
+ assert.equal(result.phases.conflicts.clean, false, "关键:没记上账就不算落地");
196
+ assert.equal(result.catchup, false, "不 clean ⇒ 不追下一批,回到正常节奏");
197
+ store.close();
198
+ });
199
+
200
+ test("issue#529 续排:失败轮不追(与连败退避互斥)", async () => {
201
+ const h = makeHarness({ sleepFailureBackoff: true });
202
+ h.setOutcome(() => ({ ok: false, status: "failed", catchup: true, backlog: true }));
203
+ h.write();
204
+ await h.fireNext();
205
+ // 失败本身仍然要补挂(#187:不补挂就永远等不到下一轮),但补的是**退避后的**长表,
206
+ // 不是续排那张静默窗口的短表——这才是"互斥"的可观测形态。
207
+ assert.equal(h.timers.length, 1, "失败轮按 #187 仍补挂一张表");
208
+ assert.equal(h.timers[0].at - h.now(), 2 * HOUR + 1000, "补的是 1h × 2^1 的退避表,不是 5 分钟的续排表");
209
+ const t = h.now();
210
+ assert.equal(h.sched.shouldRun(t + 2 * HOUR - 1), false, "退避仍然生效(1h 基数 × 2^1)");
211
+ assert.equal(h.sched.shouldRun(t + 2 * HOUR), true);
212
+ h.store.close();
213
+ });
214
+
215
+ test("issue#529 续排:连击到上限即收手,闹钟回到正常间隔", async () => {
216
+ const h = makeHarness();
217
+ // 用 runSleep 的真实返回形状:全判 keep 的一轮 status 是 noop,而 ok 只在
218
+ // ok/degraded 时为真——桩里写 ok:true 会让 maybeSchedule 走进与真实路径不同的分支
219
+ // (!ran 那条),于是「到顶后没有定时器」测的其实是桩的偏差,不是实现的行为。
220
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
221
+ h.write();
222
+ await h.fireNext();
223
+ // 24 是实现里的 CATCHUP_STREAK_CAP:每轮都 clean 但池子永远追不完时,必须自己刹住。
224
+ for (let i = 2; i <= 24; i++) {
225
+ assert.equal(h.timers.length, 1, `第 ${i} 轮之前应当有一只已挂好的闹钟`);
226
+ await h.fireNext();
227
+ }
228
+ assert.equal(h.runs(), 24, "连追 24 轮");
229
+ const t = h.now();
230
+ assert.equal(h.sched.shouldRun(t + 5 * MIN), false, "收手后回到最小间隔,不再按静默窗口放行");
231
+ assert.equal(h.sched.shouldRun(t + HOUR), true);
232
+ // 收手后既不能是「一直按静默窗口重排」(空转链),也不能是「什么都不挂」
233
+ // (#187:CD 到期成了没有监听者的时刻)。正确形态是回到正常的最小间隔。
234
+ assert.equal(h.timers.length, 1, "按 #187 补挂一张表,但不能是续排的短表");
235
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "补的是正常最小间隔,不是 5 分钟的续排表");
236
+ h.store.close();
237
+ });
238
+
239
+ test("issue#529 续排:全判 keep 的一轮 status=noop 仍是 clean,续排判据不依赖 status", async () => {
240
+ const { service, store, vectorIndex } = setup();
241
+ seedSimilarPairs(service, vectorIndex, 8);
242
+ const ctx = mockCtx(() => "[]");
243
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
244
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
245
+ assert.equal(result.phases.conflicts.applied, 0, "全判 keep ⇒ 没有任何落地");
246
+ assert.equal(result.phases.conflicts.status, "noop", "关键:这一轮永远到不了 ok(拿 status 当判据就死在这里)");
247
+ assert.ok(result.phases.conflicts.perRunSkipped > 0, "前置:本轮确实被预算截断");
248
+ assert.equal(result.phases.conflicts.clean, true, "送出去的每一对都留了台账 ⇒ clean");
249
+ assert.equal(result.catchup, true, "关键:noop 轮次也要能追");
250
+ assert.equal(result.backlog, true);
251
+ store.close();
252
+ });
253
+
254
+ test("issue#529 续排:坏批让这一轮不 clean,即使还有 backlog 也不追", async () => {
255
+ const { service, store, vectorIndex } = setup();
256
+ seedSimilarPairs(service, vectorIndex, 8);
257
+ let call = 0;
258
+ // 第一批吐空(stream 正常结束但没有 JSON 数组 ⇒ 这批 failed),后面的批正常。
259
+ const ctx = mockCtx(() => (++call === 1 ? "" : "[]"));
260
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
261
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
262
+ assert.equal(result.backlog, true, "前置:确实有对没进这一轮");
263
+ assert.equal(result.phases.conflicts.clean, false, "有批没走通 ⇒ 不 clean");
264
+ assert.equal(result.catchup, false, "关键:不 clean 时宁可少追一轮,也不要在失败状态里把间隔压到静默窗口");
265
+ store.close();
266
+ });
267
+
268
+ test("issue#529 续排:连击到顶收手之后,下一次正常触发要能重新起算(刹车不能变成永久闸)", async () => {
269
+ const h = makeHarness();
270
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
271
+ h.write();
272
+ await h.fireNext();
273
+ for (let i = 2; i <= 24; i++) await h.fireNext();
274
+ assert.equal(h.runs(), 24);
275
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "前置:到顶后已收手,闹钟回到正常间隔");
276
+
277
+ // 收手若只把 catchupWanted 置 false、**不把连击清零**,这个进程此后每一轮都会被同一道闸
278
+ // 挡下(streak 只增不减),"等下一个静默窗口重新起算"就成了一句空话。
279
+ h.write();
280
+ for (let i = 0; i < 5 && h.runs() < 25; i++) await h.fireNext();
281
+ assert.equal(h.runs(), 25, "关键:收手之后仍能被正常触发救回来");
282
+ assert.equal(h.timers.length, 1, "重新起算后又该补挂续排闹钟");
283
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "补的是静默窗口的短表,说明连击已从 0 重新算");
284
+ h.store.close();
285
+ });
286
+
287
+ test("issue#529 续排:一轮抛错要把连击一并清零,不能把上一轮的进度带进下一次起算", async () => {
288
+ const h = makeHarness();
289
+ // 同上面两条:runSleep 在 status==="noop" 时给 ok:false,桩要照它写。
290
+ const clean = () => ({ ok: false, status: "noop", catchup: true, backlog: true });
291
+ h.setOutcome(clean);
292
+ h.write();
293
+ await h.fireNext();
294
+ // 先追到 23(上限 24 的前一格):此时连击若不被清掉,下一轮 clean 就会立刻撞上限。
295
+ for (let i = 2; i <= 23; i++) await h.fireNext();
296
+ assert.equal(h.runs(), 23);
297
+
298
+ h.setOutcome(() => {
299
+ throw new Error("boom");
300
+ });
301
+ h.write();
302
+ await h.fireNext();
303
+ assert.equal(h.runs(), 24, "第 24 轮抛错");
304
+ assert.equal(h.timers.length, 1, "抛错仍按 #187 补挂一张表(补的是退避长表)");
305
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "抛错后回落到最小间隔,续排的短表不再挂");
306
+
307
+ // 关键:抛错回合若不把 catchupStreak 清零,接下来这一轮 clean 的连击会从 23 累到 24,
308
+ // 于是"刚被救回来"的进程立刻又被同一道闸挡下。
309
+ h.setOutcome(clean);
310
+ await h.fireNext();
311
+ assert.equal(h.runs(), 25, "抛错后的第一次干净轮次仍要真的跑");
312
+ assert.equal(h.timers.length, 1, "关键:连击已清零,这一轮又能挂上续排闹钟");
313
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000);
314
+ h.store.close();
315
+ });
@@ -0,0 +1,156 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createSleepScheduler } from "../src/dream/sleep.js";
6
+
7
+ // Issue #529(C 项):sleep 连续失败退避(sleepFailureBackoff,opt-in)。
8
+ //
9
+ // 报告里 C 的原案是「同一 snapshot_hash 连续失败就拉长间隔」,但 sleep 的
10
+ // snapshot_hash 是**跑完之后**对 phases 结果算的哈希(dream/sleep.js runSleep 里
11
+ // createHash(JSON.stringify(phases))),不是输入快照——同一批输入跑两次结果不同就
12
+ // 得到不同哈希,判据不成立。这里改用与 #292(autoDreamFailureBackoff)同构的
13
+ // 连续失败计数:有效间隔 = sleepMinIntervalHours × 2^连败数,成功清零,封顶 24h。
14
+ //
15
+ // 这些用例锁的是「退避语义」,防的回归是:
16
+ // ① 退避闸失效/写反 → 恒定失败的模型按 sleepMinIntervalHours 的固定节奏连发
17
+ // (#529 实测连续 23 轮 llm failed);
18
+ // ② 成功不清零 → 模型恢复后仍被旧失败拖着推迟,sleep 变相停摆;
19
+ // ③ 24h 封顶失效 → 指数无限翻倍(8h 基数 × 2^5 = 10 天),睡眠维护彻底静默;
20
+ // ④ 默认关路径被顺手改掉 → 未 opt-in 用户的重试间隔漂移;
21
+ // ⑤ noop 被当成失败 → LLM 通路正常、只是库里没活干的库被越退越长。
22
+ //
23
+ // 时钟与定时器全部注入(同 test/sleep-scheduler-rearm.test.js 的房型)。
24
+
25
+ function makeHarness(overrides = {}) {
26
+ let nowMs = 10_000_000;
27
+ const timers = [];
28
+ let seq = 1;
29
+ const store = createStore(":memory:");
30
+ const service = createService({ store, mirror: null, config: {} });
31
+ let runs = 0;
32
+ let outcome = () => ({ ok: false, status: "failed" });
33
+ const sched = createSleepScheduler({
34
+ service,
35
+ config: {
36
+ sleepModeEnabled: true,
37
+ sleepIdleMinutes: 5,
38
+ sleepMinIntervalHours: 1,
39
+ ...overrides
40
+ },
41
+ logger: { warn: () => {} },
42
+ onRun: async () => {
43
+ runs++;
44
+ return outcome();
45
+ },
46
+ now: () => nowMs,
47
+ setTimeoutFn: (fn, delay) => {
48
+ const t = { id: seq++, at: nowMs + delay, fn };
49
+ timers.push(t);
50
+ return t.id;
51
+ },
52
+ clearTimeoutFn: (id) => {
53
+ const i = timers.findIndex((t) => t.id === id);
54
+ if (i >= 0) timers.splice(i, 1);
55
+ }
56
+ });
57
+ const fireNext = async () => {
58
+ timers.sort((a, b) => a.at - b.at);
59
+ const t = timers.shift();
60
+ if (!t) throw new Error("no armed timer to fire");
61
+ nowMs = t.at;
62
+ await t.fn();
63
+ };
64
+ return {
65
+ sched,
66
+ store,
67
+ timers,
68
+ fireNext,
69
+ runs: () => runs,
70
+ now: () => nowMs,
71
+ setOutcome: (fn) => { outcome = fn; },
72
+ write: () => { nowMs += 1000; sched.noteWrite(); }
73
+ };
74
+ }
75
+
76
+ const HOUR = 3_600_000;
77
+
78
+ test("issue#529 默认关:失败一次后重试间隔仍是基数,不翻倍", async () => {
79
+ const h = makeHarness({ sleepFailureBackoff: false });
80
+ h.write();
81
+ await h.fireNext();
82
+ assert.equal(h.runs(), 1, "idle 到点起跑一次");
83
+ const t0 = h.now();
84
+ assert.equal(h.sched.shouldRun(t0 + HOUR - 1), false, "基数间隔内照旧不放行");
85
+ assert.equal(h.sched.shouldRun(t0 + HOUR), true, "关键:基数一到即放行——默认关不翻倍");
86
+ h.store.close();
87
+ });
88
+
89
+ test("issue#529 开启:连败把下次放行时刻指数推迟", async () => {
90
+ const h = makeHarness({ sleepFailureBackoff: true });
91
+ h.write();
92
+ await h.fireNext(); // 败 1 → 有效间隔 1h × 2^1 = 2h
93
+ assert.equal(h.runs(), 1);
94
+ const t1 = h.now();
95
+ assert.equal(h.sched.shouldRun(t1 + HOUR), false, "关键:基数到点不放行(现状会放行)");
96
+ assert.equal(h.sched.shouldRun(t1 + 2 * HOUR), true, "满 2h 才放行第 2 次");
97
+
98
+ await h.fireNext(); // 败 2 → 有效间隔 4h
99
+ assert.equal(h.runs(), 2);
100
+ const t2 = h.now();
101
+ assert.equal(h.sched.shouldRun(t2 + 3 * HOUR), false, "连败 2 次后 4h 内不放行");
102
+ assert.equal(h.sched.shouldRun(t2 + 4 * HOUR), true, "满 4h 放行第 3 次");
103
+ h.store.close();
104
+ });
105
+
106
+ test("issue#529 成功一次即清零、间隔回到基数", async () => {
107
+ const h = makeHarness({ sleepFailureBackoff: true });
108
+ h.write();
109
+ await h.fireNext(); // 败 1 → 2h
110
+ const t1 = h.now();
111
+ assert.equal(h.sched.shouldRun(t1 + HOUR), false, "前置:退避确实在挡");
112
+
113
+ h.setOutcome(() => ({ ok: true, status: "ok" }));
114
+ await h.fireNext(); // 成功 → 连败清零
115
+ const t2 = h.now();
116
+ assert.equal(h.sched.shouldRun(t2 + HOUR), true, "关键:清零后基数即放行(未清零要等 2h)");
117
+ h.store.close();
118
+ });
119
+
120
+ test("issue#529 封顶:连败 5 次推迟 24h 即止(未封顶需等 32h)", async () => {
121
+ const h = makeHarness({ sleepFailureBackoff: true });
122
+ h.write();
123
+ await h.fireNext();
124
+ for (let i = 2; i <= 5; i++) {
125
+ const wait = Math.min(HOUR * 2 ** (i - 1), 24 * HOUR); // 与实现同一口径的期望间隔
126
+ assert.equal(h.sched.shouldRun(h.now() + wait), true, `第 ${i} 次起跑前满 ${wait / HOUR}h`);
127
+ await h.fireNext();
128
+ }
129
+ assert.equal(h.runs(), 5);
130
+ const t = h.now();
131
+ assert.equal(h.sched.shouldRun(t + 24 * HOUR - 1), false, "关键:封顶后 24h 内仍不放行");
132
+ assert.equal(h.sched.shouldRun(t + 24 * HOUR), true, "封顶生效:24h 放行,不再翻倍");
133
+ h.store.close();
134
+ });
135
+
136
+ test("issue#529 noop 不算失败:没活干的库不该被退避越拖越稀", async () => {
137
+ const h = makeHarness({ sleepFailureBackoff: true });
138
+ h.setOutcome(() => ({ ok: false, status: "noop" }));
139
+ h.write();
140
+ await h.fireNext();
141
+ assert.equal(h.runs(), 1);
142
+ const t0 = h.now();
143
+ assert.equal(h.sched.shouldRun(t0 + HOUR), true, "关键:noop 后基数即放行(当作失败要等 2h)");
144
+ h.store.close();
145
+ });
146
+
147
+ test("issue#529 抛错同样计入连败", async () => {
148
+ const h = makeHarness({ sleepFailureBackoff: true });
149
+ h.setOutcome(() => { throw new Error("boom"); });
150
+ h.write();
151
+ await h.fireNext();
152
+ const t0 = h.now();
153
+ assert.equal(h.sched.shouldRun(t0 + HOUR), false, "关键:抛错也退避到 2h");
154
+ assert.equal(h.sched.shouldRun(t0 + 2 * HOUR), true);
155
+ h.store.close();
156
+ });