@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
@@ -525,3 +525,296 @@ test("issue#250 sleep still adjudicates normally when llmAudit.enabled === false
525
525
  assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
526
526
  store.close();
527
527
  });
528
+ // ------------------------------------------------------------ issue #529: input budget
529
+
530
+ // seedSimilarPairs:造 n 条同类型、两两相似度都不低于阈值的记忆,且每条只可能与
531
+ // 一条配成对(相似度按 i 递减)。用来验证「池子大于单批上限」时分批是否真的分开了。
532
+ // 这些向量不是正交的——cosine 只在 [1,0,0] 与 [s, sin, 0] 之间算,因此给每对单独
533
+ // 一个角度,与 seedConflictPair 同一套构造。
534
+ function seedSimilarPairs(service, vectorIndex, n) {
535
+ const mems = [];
536
+ for (let i = 0; i < n; i++) {
537
+ const m = makeMemory(service, `主题${i}`, `内容${i}`, "project");
538
+ mems.push(m);
539
+ }
540
+ // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1(> 0.85),findPotentialConflicts
541
+ // 会生成 C(n,2) 对,去重阶段按相似度留下 n/2 对(每记忆至多一对)。
542
+ for (const m of mems) vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
543
+ return mems;
544
+ }
545
+
546
+ // 记录**冲突相位**每次往返送进去的候选对数(user 消息里每个候选行以 "id=" 开头)。
547
+ // 必须按 purpose 过滤:同一轮里 pattern 相位也会调 LLM,把它的往返算进来会让
548
+ // 「送了几批」这个断言凭空多一批。
549
+ function countingCtx(responder, batchSizes = []) {
550
+ let call = 0;
551
+ const ctx = {
552
+ logger: { warn: () => {}, info: () => {} },
553
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
554
+ llm: {
555
+ async *stream(options) {
556
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
557
+ let reply = "[]";
558
+ if (options.purpose === "sleep-conflict") {
559
+ batchSizes.push((userText.match(/id=/g) ?? []).length);
560
+ reply = responder(userText, ++call);
561
+ }
562
+ yield { type: "text-delta", index: 0, text: reply };
563
+ yield { type: "finish", reason: { kind: "stop" } };
564
+ }
565
+ }
566
+ };
567
+ return { ctx, batchSizes, calls: () => call };
568
+ }
569
+
570
+ test("issue#529 conflict pairs are sent in bounded batches, not one giant prompt", async () => {
571
+ const { service, store, vectorIndex } = setup();
572
+ seedSimilarPairs(service, vectorIndex, 8);
573
+ const { ctx, batchSizes } = countingCtx(() => "[]");
574
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
575
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
576
+ assert.ok(batchSizes.length > 1, `expected several batches, got ${batchSizes.length}`);
577
+ assert.ok(batchSizes.every((n) => n > 0 && n <= 4), `每批不超过 2 对(每对 2 个 id): ${JSON.stringify(batchSizes)}`);
578
+ assert.deepEqual(batchSizes.slice(-2), [4, 4], "每对 2 个 id ⇒ 2 对 = 4 个 id 行");
579
+ assert.equal(result.phases.conflicts.batches, batchSizes.length, "审计里的批次数与真实往返次数一致");
580
+ store.close();
581
+ });
582
+
583
+ test("issue#529 a failing batch does not kill the whole run (degraded, not failed)", async () => {
584
+ const { service, store, vectorIndex } = setup();
585
+ seedSimilarPairs(service, vectorIndex, 8);
586
+ const { ctx } = countingCtx((_text, n) => {
587
+ // 第一批吐空(stream 正常结束但无文本 ⇒ llm failed),后面的批正常。
588
+ return n === 1 ? "" : "[]";
589
+ });
590
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
591
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
592
+ assert.equal(result.phases.conflicts.status, "degraded", "坏批只记 degraded,不判死整轮");
593
+ assert.ok(result.phases.conflicts.batches > 1, "后面的批照样跑完了");
594
+ store.close();
595
+ });
596
+
597
+ test("issue#529 a judged pair is not re-sent while neither side changed (keep leaves a trace)", async () => {
598
+ const { service, store, vectorIndex } = setup();
599
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
600
+ const config = baseConfig();
601
+ // 第一轮:判 keep(= 模型说这两条不冲突)。这曾经是彻底的 no-op。
602
+ const first = await runSleep(mockCtx(() => "[]"), service, config, { warn: () => {} }, { embedder, vectorIndex }, null);
603
+ assert.equal(first.phases.conflicts.status, "noop", "没有动作 ⇒ noop");
604
+ const ledger = service.listConflictReviewed();
605
+ assert.equal(ledger.length, 1, "判 keep 也要在台账里留一行");
606
+ assert.equal(ledger[0].outcome, "keep");
607
+ assert.deepEqual([ledger[0].memory_a, ledger[0].memory_b].sort(), [a.id, b.id].sort());
608
+
609
+ // 第二轮:两侧都没动 ⇒ 这对已经在台账里,不该再送 LLM。
610
+ const secondCtx = countingCtx(() => "[]");
611
+ const second = await runSleep(secondCtx.ctx, service, config, secondCtx.ctx.logger, { embedder, vectorIndex }, null);
612
+ assert.equal(secondCtx.calls(), 0, "同一对、两侧都没更新 ⇒ 不再送审");
613
+ assert.equal(second.phases.conflicts.ledgerSkipped, 1);
614
+
615
+ // 第三轮:动一下其中一侧(内容更新 ⇒ updated_at 前进),它就该重新入池。
616
+ // service.update 会把该行的向量作废并异步重算;测试里的 mock embedder 只支持
617
+ // 「一条一条 embed」的形式(embedSingle),批量 embed 的返回形状不是它覆盖的范围,
618
+ // 所以这里按既有夹具的做法手工补一次向量——被测的是台账判据,不是回填路径。
619
+ service.update(b.id, { content: "内容B 关于主题X(改)" });
620
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
621
+ const thirdCtx = countingCtx(() => "[]");
622
+ const third = await runSleep(thirdCtx.ctx, service, config, thirdCtx.ctx.logger, { embedder, vectorIndex }, null);
623
+ assert.equal(thirdCtx.calls(), 1, "任一侧更新过 ⇒ 重新送审");
624
+ assert.equal(third.phases.conflicts.ledgerSkipped, 0);
625
+ store.close();
626
+ });
627
+
628
+ test("issue#529 per-run cap bounds the pairs adjudicated in one run", async () => {
629
+ const { service, store, vectorIndex } = setup();
630
+ seedSimilarPairs(service, vectorIndex, 12);
631
+ const { ctx, calls } = countingCtx(() => "[]");
632
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
633
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
634
+ assert.ok(calls() <= 2, `每轮最多 4 对 ÷ 每批 2 对 = 2 次往返,实际 ${calls()}`);
635
+ assert.ok(result.phases.conflicts.perRunSkipped > 0, "超出本轮预算的对要被记下来");
636
+ store.close();
637
+ });
638
+
639
+ test("issue#529 user activity between batches aborts instead of reporting noop", async () => {
640
+ const { service, store, vectorIndex } = setup();
641
+ seedSimilarPairs(service, vectorIndex, 12);
642
+ const controller = new AbortController();
643
+ // 第一批返回之后用户恢复活动(noteWrite → abort)。此时第二、三批根本没送出去,
644
+ // 把它们算成「看过、没冲突」(noop)是这一相位最容易犯的谎。
645
+ const { ctx } = countingCtx(() => {
646
+ controller.abort();
647
+ return "[]";
648
+ });
649
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 12 });
650
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, controller.signal);
651
+ assert.equal(result.phases.conflicts.status, "aborted", "中途被打断不是 noop");
652
+ assert.equal(result.phases.conflicts.batches, 6, "计划批次数照实记(不是实际送出的批次数)");
653
+ // 整轮层也要如实:deriveStatus 此前只认 failed/error,aborted 相位会被静默折成
654
+ // noop,审计行于是把「没跑完」记成「跑过、没什么事」——与相位层同一个谎。
655
+ assert.equal(result.status, "aborted", "整轮状态沿用相位的 aborted,不折回 noop");
656
+ store.close();
657
+ });
658
+
659
+ test("issue#529 failed batches still report pairs/batches (audit used to lose them)", async () => {
660
+ const { service, store, vectorIndex } = setup();
661
+ seedSimilarPairs(service, vectorIndex, 4);
662
+ const { ctx } = countingCtx(() => "");
663
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
664
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
665
+ const phase = result.phases.conflicts;
666
+ assert.equal(phase.status, "failed", "全部批次失败 ⇒ 整轮 failed");
667
+ assert.ok(Number.isInteger(phase.pairs) && phase.pairs > 0, "失败轮也带 pairs");
668
+ assert.ok(Number.isInteger(phase.batches) && phase.batches > 0, "失败轮也带 batches");
669
+ store.close();
670
+ });
671
+
672
+
673
+ // ------------------------------------------- issue #536 review: CodeRabbit 三条
674
+
675
+ // 把一条记忆的 created_at / updated_at 一起回拨。update 决策有 24h 保护期,
676
+ // 台账判据比的是 updated_at——两处都要动,只改一个会让用例互相干扰。
677
+ function backdate(store, id, hours) {
678
+ const ts = new Date(Date.now() - hours * 3600000).toISOString();
679
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ts, ts, id);
680
+ }
681
+
682
+ // 造一对向量完全相同的记忆(similarity = 1)。
683
+ function seedIdenticalPair(service, vectorIndex, dir, tag) {
684
+ const a = makeMemory(service, `${tag}A`, `${tag} 内容A`, "project");
685
+ const b = makeMemory(service, `${tag}B`, `${tag} 内容B`, "project");
686
+ vectorIndex.saveEmbedding(a.id, dir);
687
+ vectorIndex.saveEmbedding(b.id, dir);
688
+ return { a, b };
689
+ }
690
+
691
+ test("issue#536 review ①: an already-judged pair must not eat a memory's pairing slot", async () => {
692
+ const { service, store, vectorIndex } = setup();
693
+ // a-b 相似度 1.0,a-c 与 b-c 都是 0.9:按相似度排序先去重的旧写法会先选中 a-b,
694
+ // 再把 a-b 当"已审"丢掉,于是 a 的名额被一个注定被过滤的对占住,a-c 进不来。
695
+ const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
696
+ const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
697
+ const c = makeMemory(service, "主题X变体", "内容C 关于主题X", "project");
698
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
699
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
700
+ vectorIndex.saveEmbedding(c.id, [0.9, Math.sqrt(1 - 0.81), 0]);
701
+ // a-b 已经判过(keep),且两侧此后都没动过 ⇒ 本轮不该再送。
702
+ service.saveConflictReviewed({
703
+ run_id: "seed",
704
+ memory_a: a.id,
705
+ memory_b: b.id,
706
+ outcome: "keep",
707
+ judged_at: new Date(Date.now() + 60000).toISOString()
708
+ });
709
+
710
+ const { ctx, calls } = countingCtx(() => "[]");
711
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
712
+ assert.equal(calls(), 1, "台账预筛必须发生在去重之前,a 要能和 c 配对送出去");
713
+ assert.equal(result.phases.conflicts.ledgerSkipped, 1, "被台账挡下的对数照实记");
714
+ store.close();
715
+ });
716
+
717
+ test("issue#536 review ②: reflectionUpdateMaxPerRun stays a whole-run cap across batches", async () => {
718
+ const { service, store, vectorIndex } = setup();
719
+ // 两对向量正交,互不干扰 ⇒ 去重后正好两对,每批一对。
720
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
721
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
722
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
723
+
724
+ // 每批都提一条 update:整轮上限 1,所以只有先跑的那批能落地。
725
+ const { ctx, calls } = countingCtx((text) => {
726
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
727
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
728
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
729
+ });
730
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 1 });
731
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
732
+ assert.equal(calls(), 2, "两对各自成批,都要送");
733
+ const updated = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
734
+ assert.equal(updated.length, 1, "update 是整轮上限,不是每批上限——只能落地一条");
735
+ assert.equal(result.phases.conflicts.batches, 2);
736
+ store.close();
737
+ });
738
+
739
+ test("issue#536 review ③: a decision that never landed must not be recorded as judged", async () => {
740
+ const { service, store, vectorIndex } = setup();
741
+ const { a } = seedConflictPair(service, vectorIndex, 1.0);
742
+ // 提一条"不改任何字段"的 update:validateDecisions 会把它跳过(skipInvalid),
743
+ // 该条决策没有落地。若照旧把整批送审对都写进台账,这一对就会被判成"已审"
744
+ // 而永远不再送——沉默地永久压制一个真实冲突。
745
+ const { ctx, calls } = countingCtx(() =>
746
+ JSON.stringify([{ action: "update", ids: [a.id], content: "内容A 关于主题X" }])
747
+ );
748
+ const config = baseConfig({ reflectionUpdateMinAgeHours: 0 });
749
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
750
+ assert.equal(calls(), 1, "这一对确实被送出去判过");
751
+ assert.equal(service.listConflictReviewed().length, 0, "没落地的决策不能记成已审");
752
+ assert.ok(result.phases.conflicts.skipped.length > 0, "跳过明细照常进审计");
753
+ store.close();
754
+ });
755
+
756
+ test("issue#536 review ④: judged_at lives in the store's clock domain, not the wall clock", async () => {
757
+ const { service, store, vectorIndex } = setup();
758
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
759
+ // 造一次「库里的单调时钟比墙钟快」——src/store.js 的 nowIso 在同一毫秒内的连续
760
+ // 写入上逐次 +1ms,高频写入的库完全可能领先墙钟几毫秒(CI 的 test-host-legacy
761
+ // 腿抓到过这次真 flake)。这里直接把两侧推到墙钟之后,等价于那个状态。
762
+ const ahead = new Date(Date.now() + 5000).toISOString();
763
+ for (const id of [a.id, b.id]) {
764
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ahead, ahead, id);
765
+ }
766
+
767
+ // 第一轮判 keep ⇒ 记台账。
768
+ const first = await runSleep(mockCtx(() => "[]"), service, baseConfig(), { warn: () => {} }, { embedder, vectorIndex }, null);
769
+ assert.equal(first.phases.conflicts.status, "noop");
770
+ const ledger = service.listConflictReviewed();
771
+ assert.equal(ledger.length, 1, "判 keep 要留一行");
772
+ assert.ok(
773
+ ledger[0].judged_at >= ahead,
774
+ "judged_at 不能早于被测行自己的 updated_at(" + ledger[0].judged_at + " vs " + ahead + ")"
775
+ );
776
+
777
+ // 第二轮:两侧确实没动过 ⇒ 不许重送。若 judged_at 取了墙钟,这里会读成
778
+ // 「判过之后又被改过」,判 keep 的死循环原样复活。
779
+ const second = countingCtx(() => "[]");
780
+ await runSleep(second.ctx, service, baseConfig(), second.ctx.logger, { embedder, vectorIndex }, null);
781
+ assert.equal(second.calls(), 0, "时钟领先墙钟时,已审的对仍然不许重送");
782
+ store.close();
783
+ });
784
+
785
+ test("issue#536 review ⑤: a batch that throws does not take the rest of the run with it", async () => {
786
+ const { service, store, vectorIndex } = setup();
787
+ // 两对正交向量 ⇒ 两批。让**第一批**的 LLM 往返直接抛(不是返回错误:返回错误那条
788
+ // 通路本来就有失败返回,早被前面的用例覆盖了)。要验的是:adjudicateBatch 内部
789
+ // 任何未被兜住的异常,都只折损它自己那一批,后面的批次照跑、之前的汇总不丢。
790
+ // applyDecisions 把 service.update 的异常吞进 failures(decisions.js:532-540),
791
+ // 所以打桩 service 的方法测不到这条守卫,必须在 LLM 这一层抛。
792
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
793
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
794
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
795
+ const { ctx, calls } = countingCtx((text) => {
796
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
797
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
798
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
799
+ });
800
+ const realStream = ctx.llm.stream;
801
+ let thrown = false;
802
+ ctx.llm.stream = async function* (options) {
803
+ if (!thrown && options.purpose === "sleep-conflict") {
804
+ thrown = true;
805
+ throw new Error("boom inside adjudicateBatch");
806
+ }
807
+ yield* realStream.call(this, options);
808
+ };
809
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 2 });
810
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
811
+ assert.equal(thrown, true, "第一批确实抛了,否则这条用例什么也没验");
812
+ assert.equal(calls(), 1, "第一批抛错后第二批仍然要跑");
813
+ assert.notEqual(result.phases.conflicts.status, "failed", "只要有一批活下来,就不是整轮 failed");
814
+ // 抛的是第一批(哪个 pair 先送取决于排序,所以两条都问,只要求"恰好一条活下来"):
815
+ // 断言落在结果上,不落在顺序上,免得排序一变这条用例就假红。
816
+ const landed = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
817
+ assert.equal(landed.length, 1, "第二批照常落地,且只落地一批");
818
+ assert.equal(result.phases.conflicts.batches, 2, "抛错的批次不吞掉轮次的形状");
819
+ store.close();
820
+ });