niceeval 0.10.3-canary.6 → 0.11.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 (122) hide show
  1. package/bin/niceeval.js +9 -4
  2. package/dist/agents/types.d.ts +5 -4
  3. package/dist/context/turn-errors.d.ts +27 -23
  4. package/dist/i18n/en.d.ts +15 -0
  5. package/dist/i18n/en.js +16 -1
  6. package/dist/i18n/zh-CN.d.ts +16 -1
  7. package/dist/i18n/zh-CN.js +16 -1
  8. package/dist/o11y/derive.js +28 -24
  9. package/dist/o11y/types.d.ts +8 -5
  10. package/dist/report/components/attempt-detail/UsageTable.js +4 -7
  11. package/dist/report/components/attempt-detail/compute.d.ts +2 -2
  12. package/dist/report/components/attempt-detail/compute.js +2 -6
  13. package/dist/report/components/attempt-detail/faces.js +7 -7
  14. package/dist/report/components/attempt-detail/index.js +0 -3
  15. package/dist/report/components/entity-lists/EvalList.js +0 -0
  16. package/dist/report/components/metric-views/compute.js +1 -1
  17. package/dist/report/model/types.d.ts +3 -4
  18. package/dist/results/locator.js +0 -0
  19. package/dist/results/select.d.ts +6 -0
  20. package/dist/results/select.js +8 -0
  21. package/dist/runner/feedback/sink.d.ts +26 -1
  22. package/dist/runner/fingerprint.d.ts +23 -0
  23. package/dist/runner/types.d.ts +105 -7
  24. package/dist/sandbox/errors.d.ts +29 -0
  25. package/dist/sandbox/resolve.d.ts +9 -0
  26. package/dist/shared/failure-class.d.ts +91 -0
  27. package/dist/types.d.ts +1 -0
  28. package/dist/util.d.ts +3 -2
  29. package/dist/util.js +31 -5
  30. package/docs-site/zh/explanation/runner.mdx +35 -0
  31. package/docs-site/zh/reference/cli.mdx +10 -1
  32. package/docs-site/zh/reference/events.mdx +4 -4
  33. package/docs-site/zh/troubleshooting/debugging.mdx +11 -0
  34. package/docs-site/zh/tutorials/viewing-results.mdx +35 -3
  35. package/package.json +28 -24
  36. package/src/agents/ai-sdk.test.ts +26 -0
  37. package/src/agents/ai-sdk.ts +7 -4
  38. package/src/agents/index.ts +5 -3
  39. package/src/agents/langgraph.test.ts +30 -0
  40. package/src/agents/langgraph.ts +5 -2
  41. package/src/agents/openai-compat.test.ts +35 -0
  42. package/src/agents/openai-compat.ts +16 -4
  43. package/src/agents/sdk-streams.test.ts +66 -0
  44. package/src/agents/sdk-streams.ts +3 -1
  45. package/src/agents/types.ts +5 -4
  46. package/src/cli.ts +81 -16
  47. package/src/context/context.test.ts +34 -0
  48. package/src/context/context.ts +14 -5
  49. package/src/context/send-retry.test.ts +86 -0
  50. package/src/context/send-retry.ts +37 -12
  51. package/src/context/session.ts +24 -0
  52. package/src/context/turn-errors.test.ts +124 -17
  53. package/src/context/turn-errors.ts +60 -50
  54. package/src/define.ts +5 -0
  55. package/src/i18n/en.ts +22 -1
  56. package/src/i18n/zh-CN.ts +22 -1
  57. package/src/index.ts +8 -0
  58. package/src/o11y/cost.ts +4 -2
  59. package/src/o11y/derive.test.ts +40 -0
  60. package/src/o11y/derive.ts +28 -22
  61. package/src/o11y/otlp/sandbox-receiver.test.ts +201 -0
  62. package/src/o11y/otlp/sandbox-receiver.ts +73 -27
  63. package/src/o11y/parsers/bub.test.ts +30 -0
  64. package/src/o11y/parsers/bub.ts +5 -2
  65. package/src/o11y/parsers/codex.test.ts +19 -0
  66. package/src/o11y/parsers/codex.ts +5 -2
  67. package/src/o11y/types.ts +8 -5
  68. package/src/report/components/attempt-detail/UsageTable.tsx +4 -6
  69. package/src/report/components/attempt-detail/attempt-components.test.tsx +5 -8
  70. package/src/report/components/attempt-detail/compute.ts +2 -7
  71. package/src/report/components/attempt-detail/faces.ts +7 -7
  72. package/src/report/components/attempt-detail/index.tsx +0 -3
  73. package/src/report/components/entity-lists/EvalList.tsx +0 -0
  74. package/src/report/components/metric-views/compute.ts +1 -1
  75. package/src/report/model/types.ts +3 -4
  76. package/src/results/format.ts +9 -2
  77. package/src/results/index.ts +2 -0
  78. package/src/results/locator.ts +0 -0
  79. package/src/results/open.ts +132 -21
  80. package/src/results/select.ts +12 -0
  81. package/src/results/skipped-notice.ts +0 -0
  82. package/src/runner/attempt.test.ts +116 -0
  83. package/src/runner/attempt.ts +89 -8
  84. package/src/runner/discover.ts +21 -3
  85. package/src/runner/feedback/coordinator.ts +24 -2
  86. package/src/runner/feedback/eval-conclusions.ts +6 -3
  87. package/src/runner/feedback/human.test.ts +300 -6
  88. package/src/runner/feedback/human.ts +132 -30
  89. package/src/runner/feedback/json.test.ts +127 -2
  90. package/src/runner/feedback/json.ts +51 -3
  91. package/src/runner/feedback/reducer.test.ts +328 -29
  92. package/src/runner/feedback/reducer.ts +84 -9
  93. package/src/runner/feedback/sink.ts +39 -1
  94. package/src/runner/fingerprint.ts +49 -19
  95. package/src/runner/gate-lease.test.ts +510 -0
  96. package/src/runner/gate-lease.ts +350 -0
  97. package/src/runner/lock.test.ts +454 -0
  98. package/src/runner/lock.ts +288 -0
  99. package/src/runner/report.test.ts +1 -0
  100. package/src/runner/run.test.ts +2044 -9
  101. package/src/runner/run.ts +825 -61
  102. package/src/runner/teardown-registry.ts +20 -78
  103. package/src/runner/types.ts +103 -7
  104. package/src/sandbox/errors.test.ts +72 -0
  105. package/src/sandbox/errors.ts +83 -0
  106. package/src/sandbox/keep-registry.ts +22 -46
  107. package/src/sandbox/resolve.test.ts +100 -0
  108. package/src/sandbox/resolve.ts +84 -27
  109. package/src/shared/entry-file-store.test.ts +149 -0
  110. package/src/shared/entry-file-store.ts +117 -0
  111. package/src/shared/failure-class.test.ts +137 -0
  112. package/src/shared/failure-class.ts +175 -0
  113. package/src/show/index.ts +5 -6
  114. package/src/show/render.test.ts +116 -15
  115. package/src/show/render.ts +124 -35
  116. package/src/types.ts +9 -0
  117. package/src/util.ts +31 -4
  118. package/src/view/app/App.tsx +5 -1
  119. package/src/view/client-dist/app.js +1 -1
  120. package/src/view/data.ts +5 -9
  121. package/src/view/shared/types.ts +7 -1
  122. package/src/view/view-report.test.ts +54 -0
@@ -1,6 +1,7 @@
1
1
  // cases: docs/engineering/testing/unit/experiments-runner.md
2
2
  // 表驱动测试:RunFeedbackEvent 序列 → RunFeedbackState。核心断言是「任何时刻」都满足
3
- // total = reused + running + queued + completed(见 docs/feature/experiments/cli.md 的守恒公式)——
3
+ // total = reused + running + elsewhere + queued + passed + failed + errored + skipped
4
+ //(见 docs/feature/experiments/cli.md 的守恒公式)——
4
5
  // 每处理一个事件就断言一次,不是只在序列末尾断言。辅助不变量 active.size === running 一并核对
5
6
  // (active map 按设计只存「正在运行」的 attempt,见 ../types.ts 的 ActiveAttempt 注释)。
6
7
 
@@ -17,6 +18,9 @@ function ref(evalId: string, attempt = 0, experimentId?: string): AttemptRef {
17
18
  return experimentId ? { experimentId, evalId, attempt } : { evalId, attempt };
18
19
  }
19
20
 
21
+ /** 恒等式的八个互斥桶。 */
22
+ const BUCKETS = ["reused", "running", "elsewhere", "queued", "passed", "failed", "errored", "skipped"] as const;
23
+
20
24
  /** 依次喂事件,每一步都断言守恒公式与 active/running 一致,返回最终状态供调用方追加断言。 */
21
25
  function replay(events: readonly RunFeedbackEvent[]): RunFeedbackState {
22
26
  let state = createInitialRunFeedbackState();
@@ -24,24 +28,32 @@ function replay(events: readonly RunFeedbackEvent[]): RunFeedbackState {
24
28
  state = reduceRunFeedback(state, event);
25
29
  expect(
26
30
  state.total,
27
- `after event #${i} (${event.type}): total should equal reused+running+queued+completed`,
28
- ).toBe(state.reused + state.running + state.queued + state.completed);
31
+ `after event #${i} (${event.type}): total should equal reused+running+elsewhere+queued+passed+failed+errored+skipped`,
32
+ ).toBe(
33
+ state.reused +
34
+ state.running +
35
+ state.elsewhere +
36
+ state.queued +
37
+ state.passed +
38
+ state.failed +
39
+ state.errored +
40
+ state.skipped,
41
+ );
29
42
  expect(
30
43
  state.active.size,
31
44
  `after event #${i} (${event.type}): active map size should equal running count`,
32
45
  ).toBe(state.running);
33
- // 四个桶(reused/running/queued/completed)本身永远非负 —— 负数意味着某个事件把不存在的
34
- // attempt 又"完成"了一次,是 reducer 或 emitter 的 bug,不该被吞掉。
35
- expect(state.reused, `after event #${i}: reused must not go negative`).toBeGreaterThanOrEqual(0);
36
- expect(state.running, `after event #${i}: running must not go negative`).toBeGreaterThanOrEqual(0);
37
- expect(state.queued, `after event #${i}: queued must not go negative`).toBeGreaterThanOrEqual(0);
38
- expect(state.completed, `after event #${i}: completed must not go negative`).toBeGreaterThanOrEqual(0);
46
+ // 八个桶本身永远非负 —— 负数意味着某个事件把不存在的 attempt 又"完成"(或"迁移")了一次,
47
+ // 是 reducer 或 emitter 的 bug,不该被吞掉。
48
+ for (const bucket of BUCKETS) {
49
+ expect(state[bucket], `after event #${i}: ${bucket} must not go negative`).toBeGreaterThanOrEqual(0);
50
+ }
39
51
  }
40
52
  return state;
41
53
  }
42
54
 
43
55
  describe("reduceRunFeedback: 守恒公式", () => {
44
- it("普通运行:plan → start × N → complete × N,计数逐步从 queued 转移到 completed", () => {
56
+ it("普通运行:plan → start × N → complete × N,计数逐步从 queued 转移到 verdict 结局项", () => {
45
57
  const a = ref("memory/a");
46
58
  const b = ref("memory/b");
47
59
  const c = ref("memory/c");
@@ -56,7 +68,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
56
68
  { type: "attempt:complete", at: 5, identity: b, who: "codex", verdict: "passed", tokenCount: 25, estimatedCostUSD: 0.2 },
57
69
  { type: "attempt:complete", at: 6, identity: c, who: "codex", verdict: "passed", estimatedCostUSD: 0.05 },
58
70
  ]);
59
- expect(state).toMatchObject({ total: 3, reused: 0, running: 0, queued: 0, completed: 3 });
71
+ expect(state).toMatchObject({ total: 3, reused: 0, running: 0, queued: 0, passed: 3, failed: 0, errored: 0, skipped: 0 });
60
72
  expect(state.estimatedCostUSD).toBeCloseTo(0.35, 5);
61
73
  expect(state.newTokenCount).toBe(40);
62
74
  expect(state.active.size).toBe(0);
@@ -110,8 +122,11 @@ describe("reduceRunFeedback: 守恒公式", () => {
110
122
  },
111
123
  });
112
124
  // plan 之后立刻(在任何 attempt 事件之前)守恒公式就应成立:2 个 reused + 3 个 queued。
113
- expect(state1.total).toBe(state1.reused + state1.running + state1.queued + state1.completed);
114
- expect(state1).toMatchObject({ total: 5, reused: 2, running: 0, queued: 3, completed: 0 });
125
+ // 携入结果的 verdict 留在 reused,不摊进四项结局:plan 带 reusedFailures 也一样。
126
+ expect(state1.total).toBe(
127
+ state1.reused + state1.running + state1.queued + state1.passed + state1.failed + state1.errored + state1.skipped,
128
+ );
129
+ expect(state1).toMatchObject({ total: 5, reused: 2, running: 0, queued: 3, passed: 0, failed: 0, errored: 0, skipped: 0 });
115
130
 
116
131
  const a = ref("memory/a", 0, "compare/bub-e2b");
117
132
  const b = ref("memory/b", 0, "compare/bub-e2b");
@@ -125,7 +140,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
125
140
  { type: "attempt:complete", at: 5, identity: b, who: "bub-e2b", verdict: "passed" },
126
141
  { type: "attempt:complete", at: 5, identity: c, who: "bub-e2b", verdict: "passed" },
127
142
  ]);
128
- expect(final).toMatchObject({ total: 5, reused: 2, running: 0, queued: 0, completed: 3 });
143
+ expect(final).toMatchObject({ total: 5, reused: 2, running: 0, queued: 0, passed: 3, failed: 0, errored: 0, skipped: 0 });
129
144
  });
130
145
 
131
146
  it("并发完成:多个 active attempt 以任意顺序结束,active map 逐条精确摘除", () => {
@@ -154,15 +169,31 @@ describe("reduceRunFeedback: 守恒公式", () => {
154
169
  ];
155
170
  for (const { identity, verdict } of order) {
156
171
  state = reduceRunFeedback(state, { type: "attempt:complete", at: 2, identity, who: "codex", verdict });
157
- expect(state.total).toBe(state.reused + state.running + state.queued + state.completed);
172
+ expect(state.total).toBe(
173
+ state.reused + state.running + state.queued + state.passed + state.failed + state.errored + state.skipped,
174
+ );
158
175
  expect(state.active.size).toBe(state.running);
159
176
  // 每次完成后,已完成的那个 identity 必须已经从 active 里摘除,其余仍在的必须还在。
160
177
  expect(state.active.has(encodeAttemptKey(identity))).toBe(false);
161
178
  }
162
- expect(state).toMatchObject({ running: 0, completed: 4, queued: 0 });
179
+ // 落项由事件携带的 verdict 决定:同一批完成事件里换 verdict,落项跟着变,不是恒落同一项。
180
+ expect(state).toMatchObject({ running: 0, queued: 0, passed: 2, failed: 1, errored: 1, skipped: 0 });
181
+ });
182
+
183
+ it("attempt:complete 的 skipped verdict 落 skipped,不并进 passed", () => {
184
+ const a = ref("memory/a");
185
+ const b = ref("memory/b");
186
+ const state = replay([
187
+ { type: "plan", at: 0, plan: { shape: { evals: 2, configs: 1, totalAttempts: 2, maxConcurrency: 2 }, reused: 0, reusedFailures: [] } },
188
+ { type: "attempt:start", at: 1, identity: a, who: "codex", phase: "eval.run" },
189
+ { type: "attempt:start", at: 1, identity: b, who: "codex", phase: "eval.run" },
190
+ { type: "attempt:complete", at: 2, identity: a, who: "codex", verdict: "skipped" },
191
+ { type: "attempt:complete", at: 3, identity: b, who: "codex", verdict: "passed" },
192
+ ]);
193
+ expect(state).toMatchObject({ total: 2, running: 0, queued: 0, passed: 1, failed: 0, errored: 0, skipped: 1 });
163
194
  });
164
195
 
165
- it("early exit:未派发的重复轮次折进 completed,不产生 failures/diagnostics;按 (experiment, eval) 累计 earlyExitByEval", () => {
196
+ it("early exit:未派发的重复轮次折进 skipped(不冒充 passed),不产生 failures/diagnostics;按 (experiment, eval) 累计 earlyExitByEval", () => {
166
197
  const first = ref("memory/retry", 0);
167
198
  const retry1 = ref("memory/retry", 1);
168
199
  const retry2 = ref("memory/retry", 2);
@@ -173,7 +204,8 @@ describe("reduceRunFeedback: 守恒公式", () => {
173
204
  { type: "attempt:early-exit", at: 3, identity: retry1, who: "codex" },
174
205
  { type: "attempt:early-exit", at: 4, identity: retry2, who: "codex" },
175
206
  ]);
176
- expect(state).toMatchObject({ total: 3, reused: 0, running: 0, queued: 0, completed: 3 });
207
+ // 1 条真跑出 passed,2 条省略的轮次进 skipped —— 省了多少轮直接读得出来。
208
+ expect(state).toMatchObject({ total: 3, reused: 0, running: 0, queued: 0, passed: 1, failed: 0, errored: 0, skipped: 2 });
177
209
  expect(state.failures).toEqual([]);
178
210
  expect(state.diagnostics).toEqual([]);
179
211
  // 无 experimentId 的裸 run:evalConclusionKey 用 "" 占位空 experimentId 段。
@@ -227,7 +259,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
227
259
  });
228
260
  expect(state.failures).toHaveLength(1);
229
261
  expect(state.failures[0]?.reason).toContain("sandbox-rate-limit");
230
- expect(state).toMatchObject({ total: 1, running: 0, completed: 1, queued: 0 });
262
+ expect(state).toMatchObject({ total: 1, running: 0, queued: 0, passed: 0, failed: 0, errored: 1, skipped: 0 });
231
263
 
232
264
  // 同一 locator 再来一次(比如 coordinator 重放/去抖动),覆盖而不是变成两条。
233
265
  state = reduceRunFeedback(state, {
@@ -244,7 +276,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
244
276
  expect(state.failures[0]?.reason).toBe("updated reason");
245
277
  });
246
278
 
247
- it("budget:每次因预算跳过一个 attempt 就折进 completed,诊断按 experimentId 去重并累加 count", () => {
279
+ it("budget:每次因预算跳过一个 attempt 就折进 skipped,诊断按 experimentId 去重并累加 count", () => {
248
280
  const a = ref("memory/a", 0, "regression/codex");
249
281
  const b = ref("memory/b", 0, "regression/codex");
250
282
  let state = createInitialRunFeedbackState();
@@ -256,7 +288,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
256
288
  state = reduceRunFeedback(state, { type: "attempt:start", at: 1, identity: a, who: "regression/codex", phase: "eval.run" });
257
289
  state = reduceRunFeedback(state, { type: "attempt:start", at: 1, identity: b, who: "regression/codex", phase: "eval.run" });
258
290
  state = reduceRunFeedback(state, { type: "attempt:complete", at: 2, identity: a, who: "regression/codex", verdict: "passed" });
259
- expect(state).toMatchObject({ total: 4, running: 1, queued: 2, completed: 1 });
291
+ expect(state).toMatchObject({ total: 4, running: 1, queued: 2, passed: 1, skipped: 0 });
260
292
 
261
293
  state = reduceRunFeedback(state, {
262
294
  type: "budget-exhausted",
@@ -265,7 +297,7 @@ describe("reduceRunFeedback: 守恒公式", () => {
265
297
  spent: 25.1,
266
298
  unstarted: 1,
267
299
  });
268
- expect(state).toMatchObject({ total: 4, running: 1, queued: 1, completed: 2 });
300
+ expect(state).toMatchObject({ total: 4, running: 1, queued: 1, passed: 1, skipped: 1 });
269
301
  expect(state.diagnostics).toHaveLength(1);
270
302
  expect(state.diagnostics[0]).toMatchObject({ key: "budget-exhausted:regression/codex", count: 1, severity: "warning" });
271
303
  expect(state.diagnostics[0]?.data).toMatchObject({ experimentId: "regression/codex", spent: 25.1, unstarted: 1 });
@@ -277,14 +309,14 @@ describe("reduceRunFeedback: 守恒公式", () => {
277
309
  spent: 25.31,
278
310
  unstarted: 2,
279
311
  });
280
- expect(state).toMatchObject({ total: 4, running: 1, queued: 0, completed: 3 });
312
+ expect(state).toMatchObject({ total: 4, running: 1, queued: 0, passed: 1, skipped: 2 });
281
313
  // 去重:同一个 experimentId 仍然只有一条诊断,count 累加到 2,data 更新到最新一次的值。
282
314
  expect(state.diagnostics).toHaveLength(1);
283
315
  expect(state.diagnostics[0]).toMatchObject({ count: 2 });
284
316
  expect(state.diagnostics[0]?.data).toMatchObject({ spent: 25.31, unstarted: 2 });
285
317
 
286
318
  state = reduceRunFeedback(state, { type: "attempt:complete", at: 5, identity: b, who: "regression/codex", verdict: "passed" });
287
- expect(state).toMatchObject({ total: 4, reused: 0, running: 0, queued: 0, completed: 4 });
319
+ expect(state).toMatchObject({ total: 4, reused: 0, running: 0, queued: 0, passed: 2, failed: 0, errored: 0, skipped: 2 });
288
320
  });
289
321
 
290
322
  it("interrupted:只追加一条去重诊断,不擅自改变 running/queued 计数(中断时的进行中 attempt 保持原状)", () => {
@@ -299,14 +331,17 @@ describe("reduceRunFeedback: 守恒公式", () => {
299
331
  state = reduceRunFeedback(state, { type: "attempt:start", at: 1, identity: a, who: "codex", phase: "eval.run" });
300
332
  const beforeInterrupt = state;
301
333
  state = reduceRunFeedback(state, { type: "interrupted", at: 2 });
302
- expect(state).toMatchObject({ total: 3, running: 1, queued: 2, completed: 0 });
334
+ expect(state).toMatchObject({ total: 3, running: 1, queued: 2, passed: 0, failed: 0, errored: 0, skipped: 0 });
303
335
  expect(state.diagnostics).toHaveLength(1);
304
336
  expect(state.diagnostics[0]).toMatchObject({ key: "interrupted", severity: "warning", count: 1 });
305
337
  // interrupted 只追加诊断,counts/active 与中断前完全一致(除了 diagnostics 数组本身)。
306
338
  expect(state.total).toBe(beforeInterrupt.total);
307
339
  expect(state.running).toBe(beforeInterrupt.running);
308
340
  expect(state.queued).toBe(beforeInterrupt.queued);
309
- expect(state.completed).toBe(beforeInterrupt.completed);
341
+ expect(state.passed).toBe(beforeInterrupt.passed);
342
+ expect(state.failed).toBe(beforeInterrupt.failed);
343
+ expect(state.errored).toBe(beforeInterrupt.errored);
344
+ expect(state.skipped).toBe(beforeInterrupt.skipped);
310
345
  expect(state.active).toBe(beforeInterrupt.active);
311
346
 
312
347
  // 再来一次 interrupted(理论上不该发生,防御性验证不会重复追加成两条)。
@@ -390,6 +425,25 @@ describe("reduceRunFeedback: 守恒公式", () => {
390
425
  expect(state.diagnostics[0]).toMatchObject({ key: "memory-warmup-degraded", count: 3 });
391
426
  });
392
427
 
428
+ it("diagnostic:code 原样带进 DiagnosticNotice;折叠仍按 key —— 同 code 不同身份是两条", () => {
429
+ let state = createInitialRunFeedbackState();
430
+ for (const evalId of ["memory/a", "memory/b", "memory/a"]) {
431
+ state = reduceRunFeedback(state, {
432
+ type: "diagnostic",
433
+ at: 1,
434
+ key: `dispatch-halted:eval:compare/codex|${evalId}`,
435
+ code: "dispatch-halted",
436
+ severity: "error",
437
+ message: `eval halted: ${evalId} needs a seeded fixture`,
438
+ data: { experimentId: "compare/codex", scope: "eval", evalId },
439
+ });
440
+ }
441
+ expect(state.diagnostics).toHaveLength(2);
442
+ expect(state.diagnostics.map((d) => d.code)).toEqual(["dispatch-halted", "dispatch-halted"]);
443
+ expect(state.diagnostics[0]).toMatchObject({ key: "dispatch-halted:eval:compare/codex|memory/a", count: 2 });
444
+ expect(state.diagnostics[1]).toMatchObject({ key: "dispatch-halted:eval:compare/codex|memory/b", count: 1 });
445
+ });
446
+
393
447
  it("phase 变化清空上一个 phase 的 detail,不把旧阶段的次要文本带进新阶段", () => {
394
448
  const a = ref("memory/a");
395
449
  let state = createInitialRunFeedbackState();
@@ -418,7 +472,7 @@ describe("reduceRunFeedback: judge 预检运行级行", () => {
418
472
  it("started 建行、done 清行;预检期间 attempt 全程保持 queued(计数不变量不受预检影响)", () => {
419
473
  const afterStart = replay([plan, { type: "precheck", at: 1, status: "started" }]);
420
474
  // 复现用户看到的「0 running · 1 queued」——预检不动计数,这行才是它停在 queued 的解释。
421
- expect(afterStart).toMatchObject({ total: 1, running: 0, queued: 1, completed: 0 });
475
+ expect(afterStart).toMatchObject({ total: 1, running: 0, queued: 1, passed: 0, failed: 0, errored: 0, skipped: 0 });
422
476
  expect(afterStart.activePrecheck).toEqual({ startedAt: 1 });
423
477
 
424
478
  const afterDone = replay([
@@ -427,7 +481,7 @@ describe("reduceRunFeedback: judge 预检运行级行", () => {
427
481
  { type: "precheck", at: 15, status: "done", durationMs: 14 },
428
482
  ]);
429
483
  expect(afterDone.activePrecheck).toBeUndefined();
430
- expect(afterDone).toMatchObject({ total: 1, running: 0, queued: 1, completed: 0 });
484
+ expect(afterDone).toMatchObject({ total: 1, running: 0, queued: 1, passed: 0, failed: 0, errored: 0, skipped: 0 });
431
485
  });
432
486
 
433
487
  it("plan 事件清空残留的预检行(reducer 复用于多次 run 时不带上一次的预检状态)", () => {
@@ -452,7 +506,7 @@ describe("reduceRunFeedback: 实验级钩子", () => {
452
506
  plan,
453
507
  { type: "experiment-hook", at: 1, experimentId: "compare/bub-e2b", hook: "setup", status: "started" },
454
508
  ]);
455
- expect(afterStart).toMatchObject({ total: 2, running: 0, queued: 2, completed: 0 });
509
+ expect(afterStart).toMatchObject({ total: 2, running: 0, queued: 2, passed: 0, failed: 0, errored: 0, skipped: 0 });
456
510
  expect(afterStart.experimentHooks.get("compare/bub-e2b")).toMatchObject({
457
511
  experimentId: "compare/bub-e2b",
458
512
  hook: "setup",
@@ -503,3 +557,248 @@ describe("reduceRunFeedback: 实验级钩子", () => {
503
557
  expect(state.experimentHooks.size).toBe(0);
504
558
  });
505
559
  });
560
+
561
+ // cases: docs/engineering/testing/unit/experiments-runner.md「用例锁与并发 Invocation」——
562
+ // 「等待语义……计入独立的 elsewhere 计数且与 queued 互斥、五项计数恒等式成立」与
563
+ // 「释放后续接……指纹匹配携入且计数 elsewhere 迁 reused、不匹配转 queued 自跑、runs 部分携入
564
+ // 部分补跑,三面都要有区分力场景」。lock.ts 本身(取锁/心跳/接管)与 run.ts 的调度接线不在
565
+ // 这里覆盖(run.test.ts / lock.test.ts 各自的范畴),这里只证 reducer 的纯状态归约。
566
+ describe("reduceRunFeedback: 用例锁等待(elsewhere)", () => {
567
+ const plan: RunFeedbackEvent = {
568
+ type: "plan",
569
+ at: 0,
570
+ plan: { shape: { evals: 2, configs: 1, totalAttempts: 5, maxConcurrency: 5 }, reused: 0, reusedFailures: [] },
571
+ };
572
+
573
+ it("started 把撞锁的 attempt 数从 queued 移入 elsewhere,与 queued 互斥;五项恒等式成立", () => {
574
+ const afterStart = replay([
575
+ plan,
576
+ {
577
+ type: "lock-wait",
578
+ at: 1,
579
+ experimentId: "compare/codex",
580
+ evalId: "memory/retention",
581
+ status: "started",
582
+ holderPid: 41267,
583
+ holderHost: "mba.local",
584
+ attempts: 3,
585
+ },
586
+ ]);
587
+ expect(afterStart).toMatchObject({ total: 5, reused: 0, running: 0, elsewhere: 3, queued: 2, passed: 0, failed: 0, errored: 0, skipped: 0 });
588
+ expect(afterStart.lockWaits.get("compare/codex")?.waiting.get("memory/retention")).toMatchObject({
589
+ startedAt: 1,
590
+ holderPid: 41267,
591
+ holderHost: "mba.local",
592
+ });
593
+ });
594
+
595
+ it("resolved 全部携入(carried):elsewhere 迁入 reused,不落回 queued", () => {
596
+ const afterResolved = replay([
597
+ plan,
598
+ {
599
+ type: "lock-wait",
600
+ at: 1,
601
+ experimentId: "compare/codex",
602
+ evalId: "memory/retention",
603
+ status: "started",
604
+ attempts: 3,
605
+ holderPid: 1,
606
+ holderHost: "h",
607
+ },
608
+ {
609
+ type: "lock-wait",
610
+ at: 20,
611
+ experimentId: "compare/codex",
612
+ evalId: "memory/retention",
613
+ status: "resolved",
614
+ carried: 3,
615
+ dispatched: 0,
616
+ waitedMs: 19_000,
617
+ },
618
+ ]);
619
+ expect(afterResolved).toMatchObject({ total: 5, reused: 3, running: 0, elsewhere: 0, queued: 2, passed: 0, failed: 0, errored: 0, skipped: 0 });
620
+ // 等待条目从 waiting 里摘除,但窗口聚合状态(供非 TTY 收尾行读取)仍保留在 map 里。
621
+ expect(afterResolved.lockWaits.get("compare/codex")?.waiting.size).toBe(0);
622
+ expect(afterResolved.lockWaits.get("compare/codex")).toMatchObject({ resolvedCarried: 3, resolvedDispatched: 0 });
623
+ });
624
+
625
+ it("resolved 转自跑(dispatched):elsewhere 迁入 queued,照常经 running 落进 verdict 结局项", () => {
626
+ const a = ref("memory/retention", 0, "compare/codex");
627
+ const state = replay([
628
+ plan,
629
+ {
630
+ type: "lock-wait",
631
+ at: 1,
632
+ experimentId: "compare/codex",
633
+ evalId: "memory/retention",
634
+ status: "started",
635
+ attempts: 3,
636
+ holderPid: 1,
637
+ holderHost: "h",
638
+ },
639
+ {
640
+ type: "lock-wait",
641
+ at: 20,
642
+ experimentId: "compare/codex",
643
+ evalId: "memory/retention",
644
+ status: "resolved",
645
+ carried: 0,
646
+ dispatched: 3,
647
+ waitedMs: 19_000,
648
+ },
649
+ { type: "attempt:start", at: 21, identity: a, who: "compare/codex", phase: "eval.run" },
650
+ { type: "attempt:complete", at: 25, identity: a, who: "compare/codex", verdict: "passed" },
651
+ ]);
652
+ // plan 初始 5 - 3(撞锁的)= 2 个 queued;3 个从 elsewhere 转来的 attempt 也都进了 queued
653
+ // (合计 5),其中 1 个真的被派发跑完,4 个仍在 queued。
654
+ expect(state).toMatchObject({ total: 5, reused: 0, elsewhere: 0, queued: 4, running: 0, passed: 1, failed: 0, errored: 0, skipped: 0 });
655
+ });
656
+
657
+ it("runs 部分携入部分补跑:同一 resolved 事件里 carried 与 dispatched 同时非零", () => {
658
+ const afterResolved = replay([
659
+ plan,
660
+ {
661
+ type: "lock-wait",
662
+ at: 1,
663
+ experimentId: "compare/codex",
664
+ evalId: "memory/retention",
665
+ status: "started",
666
+ attempts: 3,
667
+ holderPid: 1,
668
+ holderHost: "h",
669
+ },
670
+ {
671
+ type: "lock-wait",
672
+ at: 20,
673
+ experimentId: "compare/codex",
674
+ evalId: "memory/retention",
675
+ status: "resolved",
676
+ carried: 2,
677
+ dispatched: 1,
678
+ waitedMs: 19_000,
679
+ },
680
+ ]);
681
+ // 原本 5 - 3(撞锁的)= 2 个 queued,加上这次转自跑的 1 个 = 3。
682
+ expect(afterResolved).toMatchObject({ elsewhere: 0, reused: 2, queued: 3, total: 5 });
683
+ });
684
+
685
+ it("同一实验多个用例各自撞锁:elsewhere 累加,各自 resolved 各自结算", () => {
686
+ const state = replay([
687
+ plan,
688
+ {
689
+ type: "lock-wait",
690
+ at: 1,
691
+ experimentId: "compare/codex",
692
+ evalId: "memory/a",
693
+ status: "started",
694
+ attempts: 1,
695
+ holderPid: 1,
696
+ holderHost: "h",
697
+ },
698
+ {
699
+ type: "lock-wait",
700
+ at: 2,
701
+ experimentId: "compare/codex",
702
+ evalId: "memory/b",
703
+ status: "started",
704
+ attempts: 1,
705
+ holderPid: 1,
706
+ holderHost: "h",
707
+ },
708
+ ]);
709
+ expect(state.elsewhere).toBe(2);
710
+ expect(state.lockWaits.get("compare/codex")?.waiting.size).toBe(2);
711
+
712
+ const afterOneResolves = replay([
713
+ plan,
714
+ {
715
+ type: "lock-wait",
716
+ at: 1,
717
+ experimentId: "compare/codex",
718
+ evalId: "memory/a",
719
+ status: "started",
720
+ attempts: 1,
721
+ holderPid: 1,
722
+ holderHost: "h",
723
+ },
724
+ {
725
+ type: "lock-wait",
726
+ at: 2,
727
+ experimentId: "compare/codex",
728
+ evalId: "memory/b",
729
+ status: "started",
730
+ attempts: 1,
731
+ holderPid: 1,
732
+ holderHost: "h",
733
+ },
734
+ { type: "lock-wait", at: 5, experimentId: "compare/codex", evalId: "memory/a", status: "resolved", carried: 1, dispatched: 0, waitedMs: 4_000 },
735
+ ]);
736
+ // 只有 memory/a 解决了;memory/b 仍在等,窗口(waiting)非空。
737
+ expect(afterOneResolves.elsewhere).toBe(1);
738
+ expect(afterOneResolves.reused).toBe(1);
739
+ expect(afterOneResolves.lockWaits.get("compare/codex")?.waiting.size).toBe(1);
740
+ expect(afterOneResolves.lockWaits.get("compare/codex")?.waiting.has("memory/b")).toBe(true);
741
+ });
742
+
743
+ it("resolved 事件在没有对应等待条目时静默忽略(防御,不把计数推负)", () => {
744
+ const state = replay([plan, { type: "lock-wait", at: 1, experimentId: "compare/codex", evalId: "memory/ghost", status: "resolved", carried: 1, dispatched: 0 }]);
745
+ expect(state).toMatchObject({ elsewhere: 0, reused: 0, queued: 5 });
746
+ });
747
+
748
+ it("plan 事件清空残留的 elsewhere 计数与等待行(reducer 复用于多次 run 时不带上一次的状态)", () => {
749
+ let state = createInitialRunFeedbackState();
750
+ state = reduceRunFeedback(state, plan);
751
+ state = reduceRunFeedback(state, {
752
+ type: "lock-wait",
753
+ at: 1,
754
+ experimentId: "compare/codex",
755
+ evalId: "memory/retention",
756
+ status: "started",
757
+ attempts: 1,
758
+ holderPid: 1,
759
+ holderHost: "h",
760
+ });
761
+ expect(state.elsewhere).toBe(1);
762
+ state = reduceRunFeedback(state, plan);
763
+ expect(state.elsewhere).toBe(0);
764
+ expect(state.lockWaits.size).toBe(0);
765
+ });
766
+
767
+ it("窗口关闭后重新撞锁:非 TTY 聚合计数(resolvedCarried/resolvedDispatched)从零重新累计", () => {
768
+ let state = createInitialRunFeedbackState();
769
+ state = reduceRunFeedback(state, plan);
770
+ state = reduceRunFeedback(state, {
771
+ type: "lock-wait",
772
+ at: 1,
773
+ experimentId: "compare/codex",
774
+ evalId: "memory/a",
775
+ status: "started",
776
+ attempts: 1,
777
+ holderPid: 1,
778
+ holderHost: "h",
779
+ });
780
+ state = reduceRunFeedback(state, {
781
+ type: "lock-wait",
782
+ at: 5,
783
+ experimentId: "compare/codex",
784
+ evalId: "memory/a",
785
+ status: "resolved",
786
+ carried: 1,
787
+ dispatched: 0,
788
+ waitedMs: 4_000,
789
+ });
790
+ expect(state.lockWaits.get("compare/codex")).toMatchObject({ resolvedCarried: 1, resolvedDispatched: 0 });
791
+ // 新的一批用例再次撞锁:上一窗口的累计不该带进这一窗口。
792
+ state = reduceRunFeedback(state, {
793
+ type: "lock-wait",
794
+ at: 10,
795
+ experimentId: "compare/codex",
796
+ evalId: "memory/b",
797
+ status: "started",
798
+ attempts: 1,
799
+ holderPid: 1,
800
+ holderHost: "h",
801
+ });
802
+ expect(state.lockWaits.get("compare/codex")).toMatchObject({ resolvedCarried: 0, resolvedDispatched: 0 });
803
+ });
804
+ });