niceeval 0.11.4-canary.21 → 0.12.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 (54) hide show
  1. package/dist/report/assets/series-encoding.d.ts +36 -0
  2. package/dist/report/assets/series-encoding.js +104 -0
  3. package/dist/report/components/entity-lists/compute.js +5 -17
  4. package/dist/report/components/entity-lists/content.d.ts +2 -0
  5. package/dist/report/components/entity-lists/content.js +22 -7
  6. package/dist/report/definition/cell.d.ts +4 -0
  7. package/dist/report/definition/cell.js +5 -1
  8. package/dist/report/definition/primitives/chart.js +125 -25
  9. package/dist/report/definition/primitives.js +3 -2
  10. package/dist/report/definition/tree.d.ts +5 -2
  11. package/dist/report/definition/tree.js +1 -0
  12. package/dist/report/index.d.ts +1 -1
  13. package/dist/report/model/aggregate.d.ts +10 -1
  14. package/dist/report/model/aggregate.js +24 -0
  15. package/dist/report/model/locale.d.ts +0 -1
  16. package/dist/report/model/locale.js +0 -2
  17. package/dist/report/model/types.d.ts +8 -0
  18. package/dist/report/presentation.d.ts +53 -6
  19. package/dist/report/presentation.js +40 -5
  20. package/dist/report/react/index.d.ts +1 -1
  21. package/dist/report/slices/compute.js +52 -4
  22. package/dist/report/slices/content.d.ts +2 -1
  23. package/dist/report/slices/content.js +48 -15
  24. package/dist/report/slices/validate.js +23 -0
  25. package/package.json +1 -1
  26. package/src/report/assets/series-encoding.tsx +177 -0
  27. package/src/report/assets/styles.css +47 -5
  28. package/src/report/components/compute.test.ts +15 -9
  29. package/src/report/components/entity-lists/compute.ts +5 -19
  30. package/src/report/components/entity-lists/content.test.ts +13 -8
  31. package/src/report/components/entity-lists/content.ts +24 -7
  32. package/src/report/definition/cell.ts +8 -1
  33. package/src/report/definition/primitives/chart.tsx +197 -32
  34. package/src/report/definition/primitives.tsx +8 -3
  35. package/src/report/definition/tree.ts +6 -2
  36. package/src/report/index.ts +11 -1
  37. package/src/report/model/aggregate.ts +26 -0
  38. package/src/report/model/locale.ts +0 -2
  39. package/src/report/model/types.ts +8 -0
  40. package/src/report/presentation.test.tsx +179 -19
  41. package/src/report/presentation.ts +114 -11
  42. package/src/report/react/index.tsx +11 -1
  43. package/src/report/slices/compute.ts +59 -4
  44. package/src/report/slices/content.ts +55 -16
  45. package/src/report/slices/delta-table.test.ts +138 -3
  46. package/src/report/slices/validate.test.ts +29 -0
  47. package/src/report/slices/validate.ts +16 -0
  48. package/src/runner/gate-lease.test.ts +38 -14
  49. package/src/runner/lock.test.ts +40 -16
  50. package/src/sandbox/compose.ts +3 -1
  51. package/src/sandbox/dockerfile-build.ts +4 -1
  52. package/src/sandbox/runtime.ts +4 -1
  53. package/src/sandbox/vercel.test.ts +12 -3
  54. package/src/show/index.ts +10 -9
@@ -1,11 +1,57 @@
1
1
  // 指标视图 TableContent 投影:compute *Data → Table 原语可直接消费的形状。
2
2
 
3
3
  import type { Cell, TableContent, TableContentRow } from "../definition/cell.ts";
4
- import type { DeltaData, StabilityMatrixCell, StabilityMatrixData } from "../model/types.ts";
4
+ import type { DeltaData, StabilityMatrixCell, StabilityMatrixData, StaleConclusionReference } from "../model/types.ts";
5
5
  import type { AttemptLocator } from "../../record/locator.ts";
6
- import { localizedMessage } from "../model/locale.ts";
6
+ import { DEFAULT_REPORT_LOCALE, localizedMessage, type ReportLocale } from "../model/locale.ts";
7
+ import { formatMetricValue, formatPlainNumber, formatPoints, formatTimeDistance, verdictMark } from "../model/format.ts";
7
8
 
8
- export function deltaTableContent(data: DeltaData): TableContent {
9
+ /**
10
+ * 带符号的美元/tokens 差值:复用 `formatMetricValue` 折终值(内建 unit 格式不分 locale,
11
+ * presentation.md「unit 决定格式」),只在外面补一个恒定的正负号——负值 `formatMetricValue`
12
+ * 已经自带 "-",这里只需要给非负值补 "+"。
13
+ */
14
+ function signedMetricText(value: number, unit: "tokens" | "$"): string {
15
+ const sign = value > 0 ? "+" : "";
16
+ return `${sign}${formatMetricValue(value, unit)}`;
17
+ }
18
+
19
+ /** 带符号的挣分差值("+4 pts" / "-4 pts");`formatPointsSuffix` 恒加 "+" 号,不适用于可能为负的差值。 */
20
+ function signedPointsText(value: number): string {
21
+ const sign = value > 0 ? "+" : value < 0 ? "-" : "";
22
+ const abs = Math.abs(value);
23
+ return `${sign}${formatPlainNumber(abs)} ${abs === 1 ? "pt" : "pts"}`;
24
+ }
25
+
26
+ /**
27
+ * 一格的判定读法(docs/feature/reports/show/compare.md):有数据时通过制只留判定符(`bare`,
28
+ * 历史执行再接相对时距)、计分制显示挣分;该条件缺席这道题时,记录里有过期结论参考才给
29
+ * `— <判定符> <时距>`(`—` 不被参考替换,它标记「这一格没有可聚合的结果」,参考只是附注),
30
+ * 没有参考就是纯 `notApplicable`。参考不进任何计数——它压根不出现在 `cells` 里,只在这一格
31
+ * 的显示上多一句附注。
32
+ */
33
+ function conditionVerdictCell(
34
+ cell: DeltaData["rows"][number]["cells"][string] | undefined,
35
+ reference: StaleConclusionReference | undefined,
36
+ locale: ReportLocale,
37
+ ): Cell {
38
+ if (!cell) {
39
+ if (!reference) return { kind: "notApplicable" };
40
+ const mark = verdictMark(reference.verdict === "skipped" ? "skipped" : reference.verdict);
41
+ return { kind: "text", text: `— ${mark} ${formatTimeDistance(reference.staleSinceMs, locale)}` };
42
+ }
43
+ if (cell.evaluationKind === "points") {
44
+ return { kind: "text", text: cell.totalScore !== undefined ? formatPoints(cell.totalScore) : "—" };
45
+ }
46
+ return {
47
+ kind: "verdict",
48
+ verdict: cell.verdict,
49
+ bare: true,
50
+ ...(cell.historical && cell.staleSinceMs !== undefined ? { staleSinceMs: cell.staleSinceMs } : {}),
51
+ };
52
+ }
53
+
54
+ export function deltaTableContent(data: DeltaData, locale: ReportLocale = DEFAULT_REPORT_LOCALE): TableContent {
9
55
  const conditionColumns = data.conditions.flatMap((condition) => [
10
56
  { key: `${condition}:verdict` },
11
57
  { key: `${condition}:tokens` },
@@ -25,31 +71,24 @@ export function deltaTableContent(data: DeltaData): TableContent {
25
71
  };
26
72
  for (const condition of data.conditions) {
27
73
  const cell = row.cells[condition];
28
- cells[`${condition}:verdict`] = cell
29
- ? cell.evaluationKind === "points"
30
- ? {
31
- kind: "text",
32
- text: cell.totalScore !== undefined ? String(cell.totalScore) : "—",
33
- }
34
- : { kind: "verdict", verdict: cell.verdict }
35
- : { kind: "notApplicable" };
74
+ cells[`${condition}:verdict`] = conditionVerdictCell(cell, row.references?.[condition], locale);
36
75
  cells[`${condition}:tokens`] =
37
76
  cell?.totalTokens !== undefined
38
- ? { kind: "text", text: String(cell.totalTokens) }
77
+ ? { kind: "metric", metric: { value: cell.totalTokens, unit: "tokens", basis: "eval", samples: 1, total: 1, refs: cell.attempts } }
39
78
  : { kind: "notApplicable" };
40
79
  cells[`${condition}:cost`] =
41
80
  cell?.totalCostUSD !== undefined
42
- ? { kind: "text", text: String(cell.totalCostUSD) }
81
+ ? { kind: "metric", metric: { value: cell.totalCostUSD, unit: "$", basis: "eval", samples: 1, total: 1, refs: cell.attempts } }
43
82
  : { kind: "notApplicable" };
44
83
  }
45
84
  for (const condition of data.conditions.slice(1)) {
46
85
  const delta = row.delta?.[condition];
47
86
  cells[`${condition}:Δscore`] =
48
- delta?.score !== undefined ? { kind: "text", text: String(delta.score) } : { kind: "notApplicable" };
87
+ delta?.score !== undefined ? { kind: "text", text: signedPointsText(delta.score) } : { kind: "notApplicable" };
49
88
  cells[`${condition}:Δtokens`] =
50
- delta?.tokens !== undefined ? { kind: "text", text: String(delta.tokens) } : { kind: "notApplicable" };
89
+ delta?.tokens !== undefined ? { kind: "text", text: signedMetricText(delta.tokens, "tokens") } : { kind: "notApplicable" };
51
90
  cells[`${condition}:Δcost`] =
52
- delta?.costUSD !== undefined ? { kind: "text", text: String(delta.costUSD) } : { kind: "notApplicable" };
91
+ delta?.costUSD !== undefined ? { kind: "text", text: signedMetricText(delta.costUSD, "$") } : { kind: "notApplicable" };
53
92
  }
54
93
  return { key: row.key, cells };
55
94
  }),
@@ -1,16 +1,21 @@
1
1
  // cases: docs/engineering/testing/unit/reports.md
2
- // 「show 的范围 × 切片正交」deltaTableData 判据段。
2
+ // 「show 的范围 × 切片正交」deltaTableData 判据段,与「对照矩阵的时效与过期结论参考」判据段。
3
3
  // deltaTableData(对照矩阵):配对身份是 eval id、翻转标记的数据面、逐行 Δ 为原始差值且缺失不为
4
4
  // 0、runs>1 的格内折叠(verdict 按默认报告口径、tokens/成本合计)、totals 与 pairedDelta 两个不同分母
5
5
  // 的口径(fixture 让两侧覆盖不同,抓出直接相减各自 totals 的错误算法)、混型分段、conditionsByFlag
6
- // 派生(单一可比性桶、0 候选空态、by 非 experiment 报错)。show/compare.md 示例数字复算作 fixture。
6
+ // 派生(单一可比性桶、0 候选空态、by 非 experiment 报错)、historical 格的 staleSinceMs 与缺席格的
7
+ // 过期结论参考(不进任何聚合)。show/compare.md 示例数字复算作 fixture。
7
8
 
8
- import { describe, expect, it } from "vitest";
9
+ import { describe, expect, it, vi } from "vitest";
9
10
  import type { EvalResult, ScoreEntry, Usage, Verdict } from "../../types.ts";
10
11
  import { completeEvidenceCoverage } from "../../assertions/coverage.ts";
11
12
  import type { AttemptHandle, Run } from "../../record/index.ts";
13
+ import { encodeAttemptLocator } from "../../record/locator.ts";
12
14
  import { attemptHandleOf, scopeOf } from "../components/scope.harness.ts";
15
+ import { makeSample } from "../../sample/index.ts";
13
16
  import { conditionsByFlag, deltaTableData } from "./compute.ts";
17
+ import { deltaTableContent } from "./content.ts";
18
+ import { formatCellText } from "../definition/cell.ts";
14
19
  import { validateDeltaData } from "./validate.ts";
15
20
 
16
21
  let seq = 0;
@@ -192,6 +197,136 @@ describe("deltaTableData", () => {
192
197
  expect(data.rows.find((r) => r.key === "q")!.cells["exp/cond"]!.historical).toBe(true);
193
198
  });
194
199
 
200
+ describe("对照矩阵的时效与过期结论参考(show/compare.md)", () => {
201
+ it("historical 格带 staleSinceMs;text 面经 formatTimeDistance 投影,不再是布尔叠加的 ↩ 符号", async () => {
202
+ vi.useFakeTimers();
203
+ vi.setSystemTime(new Date("2026-01-03T00:00:00.000Z"));
204
+ try {
205
+ // res() 默认 startedAt 是固定的 fixture 常量,时距断言需要显式指定 startedAt。
206
+ const older = snap("exp/cond", [res("q", "passed", { startedAt: "2026-01-01T00:00:00.000Z" })], {
207
+ runStartedAt: "2026-01-01T00:00:00.000Z",
208
+ });
209
+ const newer = snap("exp/cond", [res("q2", "passed")], { runStartedAt: "2026-01-02T00:00:00.000Z" });
210
+ const base = snap("exp/base", [res("q", "passed")]);
211
+ const data = await deltaTableData([base, older, newer], { by: "experiment", conditions: ["exp/base", "exp/cond"] });
212
+
213
+ const cell = data.rows.find((r) => r.key === "q")!.cells["exp/cond"]!;
214
+ expect(cell.historical).toBe(true);
215
+ expect(cell.staleSinceMs).toBe(2 * 86_400_000); // 2026-01-01 → 2026-01-03,恰好 2 天
216
+
217
+ const content = deltaTableContent(data, "en");
218
+ const row = content.rows.find((r) => r.key === "q")!;
219
+ const text = formatCellText(row.cells["exp/cond:verdict"], "en");
220
+ expect(text).not.toContain("↩");
221
+ expect(text).toBe("✓ 2d"); // 只留判定符 + 相对时距,不带判定词
222
+ } finally {
223
+ vi.useRealTimers();
224
+ }
225
+ });
226
+
227
+ it("同一格内折叠多条历史执行时取最旧一条的距今毫秒数", async () => {
228
+ vi.useFakeTimers();
229
+ vi.setSystemTime(new Date("2026-01-10T00:00:00.000Z"));
230
+ try {
231
+ const veryOld = snap("exp/cond", [res("q", "failed", { attempt: 0, startedAt: "2026-01-01T00:00:00.000Z" })], {
232
+ runStartedAt: "2026-01-01T00:00:00.000Z",
233
+ });
234
+ const lessOld = snap("exp/cond", [res("q", "passed", { attempt: 1, startedAt: "2026-01-08T00:00:00.000Z" })], {
235
+ runStartedAt: "2026-01-08T00:00:00.000Z",
236
+ });
237
+ const newest = snap("exp/cond", [res("other", "passed")], { runStartedAt: "2026-01-09T00:00:00.000Z" });
238
+ const base = snap("exp/base", [res("q", "passed")]);
239
+ const data = await deltaTableData([base, veryOld, lessOld, newest], { by: "experiment", conditions: ["exp/base", "exp/cond"] });
240
+
241
+ const cell = data.rows.find((r) => r.key === "q")!.cells["exp/cond"]!;
242
+ expect(cell.historical).toBe(true);
243
+ // 两条都历史执行(早于水位基准 newest);取更旧的 veryOld(2026-01-01)算距今,不是 lessOld(2026-01-08)。
244
+ expect(cell.staleSinceMs).toBe(9 * 86_400_000);
245
+ } finally {
246
+ vi.useRealTimers();
247
+ }
248
+ });
249
+
250
+ /**
251
+ * 区分力场景:evalId "b" 只在 exp/base 一份旧 configHash 快照下跑过(与当前基准不可比),
252
+ * 且那次判定是 "passed"——比 exp/base 真实结果(仅 "a" failed)更「好看」。把它计入聚合的
253
+ * 错误实现会让 exp/base 的 totals 多算出一次 passed、commonEvalIds 多算出一道共同题。
254
+ */
255
+ function referenceScope(opts: { fresh?: boolean } = {}): { scope: import("../../record/index.ts").Sample; staleForB: Run } {
256
+ const current = snap("exp/base", [res("a", "failed")]);
257
+ current.configHash = "cfg-current";
258
+ const cond = snap("exp/cond", [res("a", "passed"), res("b", "passed")]);
259
+ const staleForB = snap("exp/base", [res("b", "passed", { startedAt: "2026-01-01T00:00:00.000Z" })]);
260
+ staleForB.configHash = "cfg-old";
261
+ const scope = makeSample(
262
+ "current",
263
+ [current, cond],
264
+ [...current.attempts, ...cond.attempts],
265
+ [],
266
+ [],
267
+ opts.fresh ?? false,
268
+ [...current.attempts, ...cond.attempts, ...staleForB.attempts],
269
+ );
270
+ return { scope, staleForB };
271
+ }
272
+
273
+ it("缺席格带过期结论参考:判定符、locator 与相对时距俱全,text 面按 — ✓ 12d 形态渲染", async () => {
274
+ vi.useFakeTimers();
275
+ vi.setSystemTime(new Date("2026-01-13T00:00:00.000Z"));
276
+ try {
277
+ const { scope, staleForB } = referenceScope();
278
+ const data = await deltaTableData(scope, { by: "experiment", conditions: ["exp/base", "exp/cond"] });
279
+
280
+ const rowB = data.rows.find((r) => r.key === "b")!;
281
+ expect(rowB.cells["exp/base"]).toBeUndefined();
282
+ const reference = rowB.references?.["exp/base"];
283
+ expect(reference).toBeDefined();
284
+ expect(reference!.verdict).toBe("passed");
285
+ expect(reference!.locator).toBe(encodeAttemptLocator({ runId: staleForB.runId, evalId: "b", attempt: 0 }));
286
+ expect(reference!.staleSinceMs).toBe(12 * 86_400_000);
287
+
288
+ const content = deltaTableContent(data, "en");
289
+ const text = formatCellText(content.rows.find((r) => r.key === "b")!.cells["exp/base:verdict"], "en");
290
+ expect(text).toBe("— ✓ 12d");
291
+ } finally {
292
+ vi.useRealTimers();
293
+ }
294
+ });
295
+
296
+ it("参考不进 汇总、Δ 与配对覆盖三处聚合的任何一个数", async () => {
297
+ const { scope } = referenceScope();
298
+ const data = await deltaTableData(scope, { by: "experiment", conditions: ["exp/base", "exp/cond"] });
299
+
300
+ // 汇总:exp/base 只有 "a" 一道真实结果(failed),分母是 1、passed 是 0——参考的 "passed" 没被计入
301
+ // (错误实现会把它算成 passed:1、denominator:2)。
302
+ expect(data.totals["exp/base"]).toMatchObject({ passed: 0, denominator: 1 });
303
+ // 配对覆盖:共同题只有 "a","b" 因为 exp/base 侧没有真实结果而不算共同。
304
+ expect(data.pairedDelta["exp/cond"]!.commonEvalIds).toEqual(["a"]);
305
+ // Δ:"b" 这一行任一侧缺数据本就不产出 delta,参考不能让它凭空出现。
306
+ expect(data.rows.find((r) => r.key === "b")!.delta).toBeUndefined();
307
+ });
308
+
309
+ it("sample.fresh 为 true 时缺席格不带过期结论参考", async () => {
310
+ const { scope } = referenceScope({ fresh: true });
311
+ const data = await deltaTableData(scope, { by: "experiment", conditions: ["exp/base", "exp/cond"] });
312
+ expect(data.rows.find((r) => r.key === "b")!.references).toBeUndefined();
313
+ });
314
+
315
+ it("deltaTableContent 的文本形态:计分制显示挣分、tokens/成本经 formatMetricValue 折算,不落原始数字字符串", async () => {
316
+ const base = snap("exp/base", [res("q", "passed", { usage: usage(512_300, 0.71) })]);
317
+ const cond = snap("exp/cond", [res("q", "passed", { usage: usage(305_100, 0.44) })]);
318
+ const data = await deltaTableData(scopeOf([base, cond]), { by: "experiment", conditions: ["exp/base", "exp/cond"] });
319
+ const content = deltaTableContent(data, "en");
320
+ const row = content.rows.find((r) => r.key === "q")!;
321
+
322
+ expect(formatCellText(row.cells["exp/base:verdict"], "en")).toBe("✓"); // 通过制只留判定符,不带判定词
323
+ expect(formatCellText(row.cells["exp/base:tokens"], "en")).toBe("512.3k tokens");
324
+ expect(formatCellText(row.cells["exp/base:cost"], "en")).toBe("$0.71");
325
+ expect(formatCellText(row.cells["exp/cond:Δtokens"], "en")).toBe("-207.2k tokens");
326
+ expect(formatCellText(row.cells["exp/cond:Δcost"], "en")).toBe("-$0.27");
327
+ });
328
+ });
329
+
195
330
  it("空 rows 零输出;conditions 长度 < 2 或含重复值按完整用户反馈报错", async () => {
196
331
  const s = snap("exp/only", []);
197
332
  await expect(deltaTableData(scopeOf([s]), { by: "experiment", conditions: ["exp/only"] as unknown as [string, string] })).rejects.toThrow(
@@ -173,6 +173,35 @@ describe("validateDeltaData", () => {
173
173
  const bad = { ...valid, pairedDelta: { "agents-md": { commonEvalIds: "coding/a" } } };
174
174
  expect(validateDeltaData(bad)).toMatch(/"pairedDelta\.agents-md\.commonEvalIds"/);
175
175
  });
176
+
177
+ it("cells.<condition>.staleSinceMs 非数字报错;rows[i].references 缺 verdict 定位到该条件", () => {
178
+ const badStale = {
179
+ ...valid,
180
+ rows: [{ ...valid.rows[0], cells: { ...valid.rows[0].cells, baseline: { ...validDeltaCell, staleSinceMs: "2d" } } }],
181
+ };
182
+ expect(validateDeltaData(badStale)).toMatch(/"rows\[0\]\.cells\.baseline\.staleSinceMs"/);
183
+
184
+ const badReference = {
185
+ ...valid,
186
+ rows: [{ ...valid.rows[0], references: { baseline: { locator: "@1abcdef2", staleSinceMs: 1000 } } }],
187
+ };
188
+ expect(validateDeltaData(badReference)).toMatch(/"rows\[0\]\.references\.baseline\.verdict"/);
189
+ });
190
+
191
+ it("rows[i].references 合规时通过", () => {
192
+ const withReference = {
193
+ ...valid,
194
+ rows: [
195
+ {
196
+ key: "coding/b",
197
+ flipped: false,
198
+ cells: {},
199
+ references: { baseline: { locator: "@1abcdef2", verdict: "passed", staleSinceMs: 1_036_800_000 } },
200
+ },
201
+ ],
202
+ };
203
+ expect(validateDeltaData(withReference)).toBeNull();
204
+ });
176
205
  });
177
206
 
178
207
  describe("validateStabilityMatrixData", () => {
@@ -91,6 +91,15 @@ function deltaCellProblem(value: unknown, path: string): string | null {
91
91
  if (value.totalTokens !== undefined && typeof value.totalTokens !== "number") return `"${path}.totalTokens" must be a number`;
92
92
  if (value.totalCostUSD !== undefined && typeof value.totalCostUSD !== "number") return `"${path}.totalCostUSD" must be a number`;
93
93
  if (typeof value.historical !== "boolean") return `"${path}.historical" must be a boolean`;
94
+ if (value.staleSinceMs !== undefined && typeof value.staleSinceMs !== "number") return `"${path}.staleSinceMs" must be a number`;
95
+ return null;
96
+ }
97
+ /** 缺席格的过期结论参考(与 experiment-table 占位行同一份 StaleConclusionReference 形状)。 */
98
+ function staleReferenceProblem(value: unknown, path: string): string | null {
99
+ if (!isObject(value)) return `"${path}" must be an object { locator, verdict, staleSinceMs }`;
100
+ if (typeof value.locator !== "string") return `"${path}.locator" must be a string`;
101
+ if (typeof value.verdict !== "string") return `"${path}.verdict" must be a string`;
102
+ if (typeof value.staleSinceMs !== "number") return `"${path}.staleSinceMs" must be a number`;
94
103
  return null;
95
104
  }
96
105
  function deltaTotalsProblem(value: unknown, path: string): string | null {
@@ -148,6 +157,13 @@ export const validateDeltaData: Validator = (data) => {
148
157
  if (d.costUSD !== undefined && typeof d.costUSD !== "number") return `"${path}.delta.${condition}.costUSD" must be a number`;
149
158
  }
150
159
  }
160
+ if (row.references !== undefined) {
161
+ if (!isObject(row.references)) return `"${path}.references" must be an object`;
162
+ for (const [condition, reference] of Object.entries(row.references)) {
163
+ const problem = staleReferenceProblem(reference, `${path}.references.${condition}`);
164
+ if (problem !== null) return problem;
165
+ }
166
+ }
151
167
  return null;
152
168
  });
153
169
  if (rowsProblem !== null) return rowsProblem;
@@ -24,10 +24,33 @@ import {
24
24
  // 在任何测试调用 vi.useFakeTimers() 之前捕获真实的 setTimeout:vi.advanceTimersByTimeAsync 只
25
25
  // 推进假时钟触发到期的定时器回调,不等待回调里新发起的真实 fs I/O 真正 settle(踩坑同
26
26
  // lock.test.ts 顶部注释)。用真实 setTimeout 排一次宏任务放行真实 I/O。
27
+ // CI 并行时 fs 线程池会饿,flush 要给够真实 wall-clock。
27
28
  const realSetTimeout = globalThis.setTimeout;
28
29
  function realDelay(ms: number): Promise<void> {
29
30
  return new Promise((resolve) => realSetTimeout(resolve, ms));
30
31
  }
32
+ async function flushRealIo(rounds = 50, ms = 10): Promise<void> {
33
+ for (let i = 0; i < rounds; i += 1) {
34
+ await realDelay(ms);
35
+ }
36
+ }
37
+ async function stepAndFlush(advanceMs: number): Promise<void> {
38
+ await vi.advanceTimersByTimeAsync(advanceMs);
39
+ await flushRealIo();
40
+ }
41
+ async function stepUntil(
42
+ advanceMs: number,
43
+ predicate: () => boolean,
44
+ opts: { rounds?: number; ms?: number } = {},
45
+ ): Promise<void> {
46
+ await vi.advanceTimersByTimeAsync(advanceMs);
47
+ const rounds = opts.rounds ?? 100;
48
+ const ms = opts.ms ?? 20;
49
+ for (let i = 0; i < rounds; i += 1) {
50
+ if (predicate()) return;
51
+ await realDelay(ms);
52
+ }
53
+ }
31
54
 
32
55
  const EXP = "compare/bub-e2b";
33
56
 
@@ -355,28 +378,25 @@ describe("acquireGateSlot: 等待路径", () => {
355
378
  },
356
379
  },
357
380
  );
358
- resultPromise.then(() => {
359
- resolved = true;
360
- });
381
+ // 吞掉提前失败时的 reject,避免 afterEach 清目录后 in-flight 轮询变成 unhandled rejection。
382
+ resultPromise.then(
383
+ () => {
384
+ resolved = true;
385
+ },
386
+ () => {},
387
+ );
361
388
 
362
389
  const holders = await firstWaitStartPromise; // 纯微任务同步点,不依赖假时钟
363
390
  expect(holders).toHaveLength(1);
364
391
  expect(holders[0]!.pid).toBe(1);
365
392
  expect(waitStartCalls).toBe(1);
366
393
 
367
- async function stepAndFlush(ms: number): Promise<void> {
368
- await vi.advanceTimersByTimeAsync(ms);
369
- for (let i = 0; i < 10; i += 1) {
370
- await realDelay(5);
371
- }
372
- }
373
-
374
394
  await stepAndFlush(pollIntervalMs); // 累计 25s,仍在阈值内:继续等,onWaitStart 不重复触发
375
395
  expect(resolved).toBe(false);
376
396
  expect(waitStartCalls).toBe(1);
377
397
  expect(waitHolders).toHaveLength(1);
378
398
 
379
- await stepAndFlush(pollIntervalMs); // 累计 50s,越过 30s 阈值:接管取位
399
+ await stepUntil(pollIntervalMs, () => resolved); // 累计 50s,越过 30s 阈值:接管取位
380
400
  expect(resolved).toBe(true);
381
401
 
382
402
  const result = await resultPromise;
@@ -486,12 +506,14 @@ describe("acquireGateSlot / claim.release: 释放", () => {
486
506
  });
487
507
 
488
508
  describe("acquireGateSlot / claim.release: 释放与在飞心跳的竞态", () => {
489
- it("回归 memory/lock-heartbeat-resurrects-released-lock.md:释放时若有一次心跳已读完记录、还没写回,写回不会把已删的租约文件复活", async () => {
509
+ it(
510
+ "回归 memory/lock-heartbeat-resurrects-released-lock.md:释放时若有一次心跳已读完记录、还没写回,写回不会把已删的租约文件复活",
511
+ async () => {
490
512
  // 与用例锁(lock.ts)同一条竞态:心跳的「读—改—写」与 release() 的 `rm` 之间原本没有
491
513
  // 互斥。极短心跳周期(1ms)配合真实定时器重复多轮,让每轮释放大概率撞上一次在飞的
492
514
  // 心跳,能稳定复现(台账记录:修复前 40 次释放 39 次复活)。这里断言修复后不管重复
493
515
  // 多少轮,释放后租约目录始终为空。每轮用不同的 slot(经不同 experimentId)避免相邻
494
- // 轮次互相抢位排队,纯粹考验单条持有者的释放-心跳竞态。
516
+ // 轮次互相抢位排队,纯粹考验单条持有者的释放-心跳竞态。CI 并行时 40 轮可能超过默认 5s。
495
517
  const root = await makeRoot();
496
518
 
497
519
  for (let i = 0; i < 40; i += 1) {
@@ -508,7 +530,9 @@ describe("acquireGateSlot / claim.release: 释放与在飞心跳的竞态", () =
508
530
  }
509
531
 
510
532
  expect(await leaseFileCount(root)).toBe(0);
511
- });
533
+ },
534
+ 30_000,
535
+ );
512
536
  });
513
537
 
514
538
  describe("drainHeldGateLeases / pendingHeldGateLeaseCount", () => {
@@ -25,10 +25,35 @@ import {
25
25
  // 推进假时钟触发到期的定时器回调,不等待回调里新发起的真实 fs I/O(线程池完成通过事件循环的
26
26
  // poll 阶段回来,micro-task 级的 process.nextTick 不足以放行)真正 settle。用真实 setTimeout
27
27
  // 排一次宏任务,才能确定性地让真实 I/O 有机会在下一步推进前落地。
28
+ // CI 上 unit 项目并行时 fs 线程池会饿,flush 要给够真实 wall-clock,不能只靠 10×5ms。
28
29
  const realSetTimeout = globalThis.setTimeout;
29
30
  function realDelay(ms: number): Promise<void> {
30
31
  return new Promise((resolve) => realSetTimeout(resolve, ms));
31
32
  }
33
+ /** 推进假时钟后放行真实 fs I/O;rounds/ms 按 CI 并行饿载调高。 */
34
+ async function flushRealIo(rounds = 50, ms = 10): Promise<void> {
35
+ for (let i = 0; i < rounds; i += 1) {
36
+ await realDelay(ms);
37
+ }
38
+ }
39
+ async function stepAndFlush(advanceMs: number): Promise<void> {
40
+ await vi.advanceTimersByTimeAsync(advanceMs);
41
+ await flushRealIo();
42
+ }
43
+ /** 推进一轮后等谓词成立(仍用真实时间轮询),避免「I/O 晚几拍」的假失败。 */
44
+ async function stepUntil(
45
+ advanceMs: number,
46
+ predicate: () => boolean,
47
+ opts: { rounds?: number; ms?: number } = {},
48
+ ): Promise<void> {
49
+ await vi.advanceTimersByTimeAsync(advanceMs);
50
+ const rounds = opts.rounds ?? 100;
51
+ const ms = opts.ms ?? 20;
52
+ for (let i = 0; i < rounds; i += 1) {
53
+ if (predicate()) return;
54
+ await realDelay(ms);
55
+ }
56
+ }
32
57
 
33
58
  let roots: string[] = [];
34
59
  async function makeRoot(): Promise<string> {
@@ -310,31 +335,26 @@ describe("acquireCaseLock: 等待路径", () => {
310
335
  },
311
336
  },
312
337
  );
313
- resultPromise.then(() => {
314
- resolved = true;
315
- });
338
+ // 吞掉提前失败时的 reject,避免 afterEach 清目录后 in-flight 轮询变成 unhandled rejection。
339
+ resultPromise.then(
340
+ () => {
341
+ resolved = true;
342
+ },
343
+ () => {},
344
+ );
316
345
 
317
346
  const holder = await firstWaitStartPromise; // 纯微任务同步点,不依赖假时钟
318
347
  expect(holder.pid).toBe(1);
319
348
  expect(waitHolderPid).toBe(1);
320
349
  expect(waitStartCalls).toBe(1);
321
350
 
322
- async function stepAndFlush(ms: number): Promise<void> {
323
- await vi.advanceTimersByTimeAsync(ms);
324
- // 真实 setTimeout(0) 排宏任务,给真实 fs I/O 的线程池完成回调机会真正落地;多轮小
325
- // 步比一次性大延时更快、也更贴近"刚好够用"的量。
326
- for (let i = 0; i < 10; i += 1) {
327
- await realDelay(5);
328
- }
329
- }
330
-
331
351
  // 第一步推进 25s:仍在 30s 阈值内,应继续等待,onWaitStart 不重复触发
332
352
  await stepAndFlush(pollIntervalMs);
333
353
  expect(resolved).toBe(false);
334
354
  expect(waitStartCalls).toBe(1);
335
355
 
336
356
  // 第二步再推进 25s(累计 50s,越过 30s 阈值):应该取得并标记为接管
337
- await stepAndFlush(pollIntervalMs);
357
+ await stepUntil(pollIntervalMs, () => resolved);
338
358
  expect(resolved).toBe(true);
339
359
 
340
360
  const result = await resultPromise;
@@ -425,12 +445,14 @@ describe("acquireCaseLock / claim.release: 释放", () => {
425
445
  });
426
446
 
427
447
  describe("acquireCaseLock / claim.release: 释放与在飞心跳的竞态", () => {
428
- it("回归 memory/lock-heartbeat-resurrects-released-lock.md:释放时若有一次心跳已读完记录、还没写回,写回不会把已删的锁文件复活", async () => {
448
+ it(
449
+ "回归 memory/lock-heartbeat-resurrects-released-lock.md:释放时若有一次心跳已读完记录、还没写回,写回不会把已删的锁文件复活",
450
+ async () => {
429
451
  // 心跳的「读—改—写」与 release() 的 `rm` 之间原本没有互斥:心跳读完记录、还没来得及
430
452
  // writeEntryFile 写回时,release() 先把文件 rm 掉,随后心跳的写回会把原路径重新创建
431
453
  // 出来。极短心跳周期(1ms)让每次释放大概率撞上一次在飞的心跳,配合真实定时器重复
432
454
  // 多轮,能稳定复现(台账记录:修复前 40 次释放 39 次复活)。这里断言修复后不管重复
433
- // 多少轮,释放后锁目录始终为空。
455
+ // 多少轮,释放后锁目录始终为空。CI 并行时 40 轮真实 I/O 可能超过默认 5s。
434
456
  const root = await makeRoot();
435
457
  const niceevalRoot = join(root, ".niceeval");
436
458
 
@@ -448,7 +470,9 @@ describe("acquireCaseLock / claim.release: 释放与在飞心跳的竞态", () =
448
470
  }
449
471
 
450
472
  expect(await readdir(locksDirOf(niceevalRoot))).toEqual([]);
451
- });
473
+ },
474
+ 30_000,
475
+ );
452
476
  });
453
477
 
454
478
  describe("drainHeldCaseLocks / pendingHeldCaseLockCount", () => {
@@ -25,7 +25,6 @@ import type {
25
25
  SandboxResourceGroup,
26
26
  ServiceController,
27
27
  } from "./case-types.ts";
28
- import { classifyProvisionError, DockerSandbox } from "./docker.ts";
29
28
  import {
30
29
  computeBuildKey,
31
30
  digestOf,
@@ -866,6 +865,9 @@ export async function materializeDockerComposeProviderCase(
866
865
  throw abortError(opts.ctx.signal);
867
866
  }
868
867
 
868
+ // dockerode 是 optional peer:compose 实现在 materialize 热路径才加载 docker.ts。
869
+ const { classifyProvisionError, DockerSandbox } = await import("./docker.ts");
870
+
869
871
  // 构建:协调器 locator 命中时仍跑一次 compose build——BuildKit cache 很快,
870
872
  // 且把 BuildKey tag 对齐回本 eval 的 image: 插值名(避免多题共用 provider env 时串镜像)。
871
873
  const buildServices = collection.inspection.services.filter((s) => s.build !== undefined).map((s) => s.name);
@@ -5,7 +5,6 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { isAbsolute, resolve as resolvePath } from "node:path";
8
- import { Template } from "e2b";
9
8
  import type { SandboxBuildExecutionContext, SandboxBuildProvider, SandboxBuildWork } from "./build-coordinator.ts";
10
9
  import { detectDockerBuildPlatform, normalizeBuildPlatform } from "./compose.ts";
11
10
  import { computeCaseKey, type BuildKey, type CaseKey } from "./identity.ts";
@@ -202,6 +201,8 @@ async function defaultRunDockerBuild(
202
201
  }
203
202
 
204
203
  async function defaultE2BTemplateExists(name: string, signal: AbortSignal): Promise<boolean> {
204
+ // e2b 是 optional peer;仅 E2B Dockerfile 构建路径加载。
205
+ const { Template } = await import("e2b");
205
206
  return await Template.exists(name, { signal });
206
207
  }
207
208
 
@@ -216,6 +217,8 @@ async function defaultBuildE2BTemplate(
216
217
  if (details.target !== undefined) {
217
218
  throw new Error("E2B Dockerfile builds do not support a target stage; make the desired stage the final stage");
218
219
  }
220
+ // e2b 是 optional peer;仅 E2B Dockerfile 构建路径加载。
221
+ const { Template } = await import("e2b");
219
222
  const ignore = await dockerIgnorePatterns(details.contextDir);
220
223
  const template = Template({
221
224
  fileContextPath: details.contextDir,
@@ -1,7 +1,6 @@
1
1
  // ProviderModule 的唯一运行入口:core 只调用 plan 私绑的闭包,不解释 adapter 名或 JSON runtime input。
2
2
 
3
3
  import { randomUUID } from "node:crypto";
4
- import Docker from "dockerode";
5
4
  import { Data, Effect, Option, type Scope } from "effect";
6
5
  import type { ProvisionSlot } from "./retry.ts";
7
6
  import { withProvisionRetry } from "./retry.ts";
@@ -366,6 +365,8 @@ export function materializeDockerfileProviderPlan(
366
365
 
367
366
  async function defaultDockerImageExists(locator: string): Promise<boolean> {
368
367
  try {
368
+ // dockerode 是 optional peer;只在 Dockerfile Agent 缓存路径真查镜像时加载。
369
+ const { default: Docker } = await import("dockerode");
369
370
  await new Docker().getImage(locator).inspect();
370
371
  return true;
371
372
  } catch {
@@ -452,6 +453,8 @@ const liveDockerfileAgentImageProvisionServices: DockerfileAgentImageProvisionSe
452
453
  return { operations: sandbox, sandboxId: sandbox.sandboxId, stop: () => sandbox.stop() };
453
454
  },
454
455
  commit: async (sandboxId: string, derivedLocator: string) => {
456
+ // dockerode 是 optional peer;commit 派生镜像时才加载(与 keep/orphans 同模式)。
457
+ const { default: Docker } = await import("dockerode");
455
458
  await new Docker().getContainer(sandboxId).commit(dockerCommitReference(derivedLocator));
456
459
  },
457
460
  });