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
@@ -0,0 +1,100 @@
1
+ // cases: docs/engineering/testing/unit/sandbox.md
2
+ // 覆盖 provisioning「对外的空间轴映射」的调度端接线(见
3
+ // docs/feature/sandbox/architecture.md#provisioning-失败与重试):`withDeterministicProvisionScope`
4
+ // 只在 provider 自身分类判定为 "unknown"(确定性)时才把可证明死因浮出为 FailureClass.scope;
5
+ // 瞬时失败(拒绝类/歧义类)重试耗尽后原样抛出,不附带 scope。这里直接注入 `work`/`classify`
6
+ // 闭包(与 retry.test.ts 对 withProvisionRetry 的做法同形),不需要经过真实 provider SDK
7
+ // ——provider 专属的错误形状已在 errors.test.ts 覆盖。
8
+ import { describe, expect, it } from "vitest";
9
+ import type { SandboxProvisionErrorKind } from "./errors.ts";
10
+ import { type ResolvedSandbox, withDeterministicProvisionScope } from "./resolve.ts";
11
+
12
+ function resolvedSandbox(environments?: Record<string, unknown>): ResolvedSandbox {
13
+ return {
14
+ provider: "docker",
15
+ exclusive: false,
16
+ ...(environments !== undefined ? { environments } : {}),
17
+ } as ResolvedSandbox;
18
+ }
19
+
20
+ function alwaysThrows(error: unknown): () => Promise<never> {
21
+ return async () => {
22
+ throw error;
23
+ };
24
+ }
25
+
26
+ describe("withDeterministicProvisionScope", () => {
27
+ it("attaches eval scope for template-not-found when the spec carries an environments table", async () => {
28
+ const error = new Error("template not found");
29
+ const r = resolvedSandbox({ "python-3.9": { image: "acme/py39" } });
30
+
31
+ await expect(
32
+ withDeterministicProvisionScope(alwaysThrows(error), () => "unknown" as SandboxProvisionErrorKind, r),
33
+ ).rejects.toBe(error);
34
+
35
+ expect((error as unknown as { _tag?: string })._tag).toBe("NiceevalClassifiedError");
36
+ expect((error as unknown as { class?: unknown }).class).toEqual({ retryable: false, scope: "eval" });
37
+ });
38
+
39
+ it("attaches experiment scope for template-not-found when the spec has no environments table", async () => {
40
+ const error = new Error("no such image: acme/missing:latest");
41
+ const r = resolvedSandbox(undefined);
42
+
43
+ await expect(
44
+ withDeterministicProvisionScope(alwaysThrows(error), () => "unknown" as SandboxProvisionErrorKind, r),
45
+ ).rejects.toBe(error);
46
+
47
+ expect((error as unknown as { class?: unknown }).class).toEqual({ retryable: false, scope: "experiment" });
48
+ });
49
+
50
+ it("attaches experiment scope for credentials-missing regardless of the environments table", async () => {
51
+ const error = Object.assign(new Error("Unauthorized, please check your credentials."), { name: "AuthenticationError" });
52
+ const r = resolvedSandbox({ "python-3.9": { image: "acme/py39" } });
53
+
54
+ await expect(
55
+ withDeterministicProvisionScope(alwaysThrows(error), () => "unknown" as SandboxProvisionErrorKind, r),
56
+ ).rejects.toBe(error);
57
+
58
+ expect((error as unknown as { class?: unknown }).class).toEqual({ retryable: false, scope: "experiment" });
59
+ });
60
+
61
+ it("attaches experiment scope for permission-denied", async () => {
62
+ const error = new Error("pull access denied for acme/private-image");
63
+ const r = resolvedSandbox(undefined);
64
+
65
+ await expect(
66
+ withDeterministicProvisionScope(alwaysThrows(error), () => "unknown" as SandboxProvisionErrorKind, r),
67
+ ).rejects.toBe(error);
68
+
69
+ expect((error as unknown as { class?: unknown }).class).toEqual({ retryable: false, scope: "experiment" });
70
+ });
71
+
72
+ it("does not attach a scope for transient failures even after retries are exhausted", async () => {
73
+ const error = new Error("too many requests");
74
+ const r = resolvedSandbox({ "python-3.9": { image: "acme/py39" } });
75
+
76
+ await expect(
77
+ withDeterministicProvisionScope(alwaysThrows(error), () => "rate_limit" as SandboxProvisionErrorKind, r),
78
+ ).rejects.toBe(error);
79
+
80
+ expect((error as unknown as { _tag?: string })._tag).toBeUndefined();
81
+ expect((error as unknown as { class?: unknown }).class).toBeUndefined();
82
+ });
83
+
84
+ it("does not attach a scope for a deterministic error that matches none of the three provable causes", async () => {
85
+ const error = new Error("invalid argument: timeout must be positive");
86
+ const r = resolvedSandbox(undefined);
87
+
88
+ await expect(
89
+ withDeterministicProvisionScope(alwaysThrows(error), () => "unknown" as SandboxProvisionErrorKind, r),
90
+ ).rejects.toBe(error);
91
+
92
+ expect((error as unknown as { _tag?: string })._tag).toBeUndefined();
93
+ });
94
+
95
+ it("passes through a successful work() untouched", async () => {
96
+ const r = resolvedSandbox(undefined);
97
+ const result = await withDeterministicProvisionScope(async () => "sandbox", () => "unknown" as SandboxProvisionErrorKind, r);
98
+ expect(result).toBe("sandbox");
99
+ });
100
+ });
@@ -13,6 +13,12 @@ import { t } from "../i18n/index.ts";
13
13
  import { reportActivity, reportDiagnostic } from "../runner/feedback/sink.ts";
14
14
  import { withProvisionRetry, type ProvisionSlot } from "./retry.ts";
15
15
  import { currentRunIdentity } from "./run-identity.ts";
16
+ import {
17
+ attachProvisionFailureScope,
18
+ classifyProvisionConfigCause,
19
+ provisionConfigCauseScope,
20
+ type SandboxProvisionErrorKind,
21
+ } from "./errors.ts";
16
22
 
17
23
  /** 归一化后的沙箱描述:确定的 provider + 各 provider 参数(只有对应 provider 用得上的会有值)。 */
18
24
  export interface ResolvedSandbox {
@@ -114,8 +120,10 @@ function fallbackFeedback(): ScopedFeedback {
114
120
  return {
115
121
  progress: (u) =>
116
122
  reportActivity(u.current !== undefined && u.total !== undefined ? `${u.message} (${u.current}/${u.total})` : u.message),
123
+ // key 只管折叠到多细(dedupeKey 里常编进 sandboxId 之类的实例细节),对外的稳定词法
124
+ // 一律是 code —— 不带 code 时 json.ts 会回落成 `code ?? key`,把复合去重串当成词法透出去。
117
125
  diagnostic: (d) =>
118
- reportDiagnostic({ key: d.dedupeKey ?? d.code, severity: d.level, message: d.message, data: d.data }),
126
+ reportDiagnostic({ key: d.dedupeKey ?? d.code, code: d.code, severity: d.level, message: d.message, data: d.data }),
119
127
  };
120
128
  }
121
129
 
@@ -148,6 +156,40 @@ export function createSandbox(opts: {
148
156
  );
149
157
  }
150
158
 
159
+ /**
160
+ * spec 是否带 `environments` 表:决定「模板不存在」死因的 scope 定档(见 errors.ts 的
161
+ * `provisionConfigCauseScope`)。查表本身(profile → 具体产物)是 runner/sandbox-selection.ts
162
+ * 的规划期职责;这里只读字段是否存在——`environments` 在 spec 声明了就会随 `resolveSandbox()`
163
+ * 的展开(以及 profile 派生 spec 的浅覆盖)原样带到这里,不需要重新解析 profile。
164
+ */
165
+ function hasEnvironmentsTable(r: ResolvedSandbox): boolean {
166
+ const environments = (r as { environments?: unknown }).environments;
167
+ return typeof environments === "object" && environments !== null;
168
+ }
169
+
170
+ /**
171
+ * provisioning 失败向外浮出确定性配置死因的 scope(契约见
172
+ * docs/feature/sandbox/architecture.md#provisioning-失败与重试「对外的空间轴映射」)。
173
+ * `work` 失败后,只有 provider 自身分类判定为 `"unknown"`(确定性)时才进一步细分死因;
174
+ * 瞬时失败(拒绝类/歧义类)不论是否重试耗尽都原样抛出,不附带 scope——死因不可证明为
175
+ * 兄弟共享。导出供单测直接注入 `work`/`classify`,不需要经过真实 provider SDK。
176
+ */
177
+ export async function withDeterministicProvisionScope<T>(
178
+ work: () => Promise<T>,
179
+ classify: (e: unknown) => SandboxProvisionErrorKind,
180
+ r: ResolvedSandbox,
181
+ ): Promise<T> {
182
+ try {
183
+ return await work();
184
+ } catch (e) {
185
+ if (classify(e) === "unknown") {
186
+ const cause = classifyProvisionConfigCause(e);
187
+ if (cause) attachProvisionFailureScope(e, provisionConfigCauseScope(cause, hasEnvironmentsTable(r)));
188
+ }
189
+ throw e;
190
+ }
191
+ }
192
+
151
193
  async function createProvider(
152
194
  r: ResolvedSandbox,
153
195
  feedback: ScopedFeedback,
@@ -167,20 +209,25 @@ async function createProvider(
167
209
  // 运行标识(host/pid/startedAt)与 provision token 同一 label 机制:强杀之后
168
210
  // `sandbox list --orphans` / `prune` 按它事后核对与收回(见 run-identity.ts)。
169
211
  const runIdentity = currentRunIdentity();
170
- return withProvisionRetry(
212
+ return withDeterministicProvisionScope(
171
213
  () =>
172
- DockerSandbox.create({
173
- timeout,
174
- runtime: r.runtime,
175
- image: r.image,
214
+ withProvisionRetry(
215
+ () =>
216
+ DockerSandbox.create({
217
+ timeout,
218
+ runtime: r.runtime,
219
+ image: r.image,
220
+ feedback,
221
+ provisionToken: token,
222
+ runIdentity,
223
+ }),
224
+ classifyProvisionError,
225
+ provisionSlot,
176
226
  feedback,
177
- provisionToken: token,
178
- runIdentity,
179
- }),
227
+ () => reconcileProvision(token),
228
+ ),
180
229
  classifyProvisionError,
181
- provisionSlot,
182
- feedback,
183
- () => reconcileProvision(token),
230
+ r,
184
231
  );
185
232
  }
186
233
  case "vercel": {
@@ -188,11 +235,16 @@ async function createProvider(
188
235
  throw new Error(t("sandbox.dependencyMissing.vercel"));
189
236
  });
190
237
  // vercel SDK 没有按元数据检索实例的通道:不传 reconcile,歧义类第一次抛出。
191
- return withProvisionRetry(
192
- () => VercelSandbox.create({ timeout, runtime: r.runtime, snapshotId: r.snapshotId, feedback }),
238
+ return withDeterministicProvisionScope(
239
+ () =>
240
+ withProvisionRetry(
241
+ () => VercelSandbox.create({ timeout, runtime: r.runtime, snapshotId: r.snapshotId, feedback }),
242
+ classifyProvisionError,
243
+ provisionSlot,
244
+ feedback,
245
+ ),
193
246
  classifyProvisionError,
194
- provisionSlot,
195
- feedback,
247
+ r,
196
248
  );
197
249
  }
198
250
  case "e2b": {
@@ -201,19 +253,24 @@ async function createProvider(
201
253
  });
202
254
  const token = randomUUID();
203
255
  const runIdentity = currentRunIdentity();
204
- return withProvisionRetry(
256
+ return withDeterministicProvisionScope(
205
257
  () =>
206
- E2BSandbox.create({
207
- timeout,
208
- runtime: r.runtime,
209
- template: r.template,
210
- provisionToken: token,
211
- runIdentity,
212
- }),
258
+ withProvisionRetry(
259
+ () =>
260
+ E2BSandbox.create({
261
+ timeout,
262
+ runtime: r.runtime,
263
+ template: r.template,
264
+ provisionToken: token,
265
+ runIdentity,
266
+ }),
267
+ classifyProvisionError,
268
+ provisionSlot,
269
+ feedback,
270
+ () => reconcileProvision(token),
271
+ ),
213
272
  classifyProvisionError,
214
- provisionSlot,
215
- feedback,
216
- () => reconcileProvision(token),
273
+ r,
217
274
  );
218
275
  }
219
276
  case "local": {
@@ -0,0 +1,149 @@
1
+ // cases: docs/engineering/testing/unit/experiments-runner.md
2
+ // 「用例锁与并发 Invocation」的共享原语半部——runner/teardown-registry.ts 与
3
+ // sandbox/keep-registry.ts 各自的既有单测已经在验收"零行为变化"的迁移;这里只覆盖
4
+ // entry-file-store.ts 自身语义无关的原语契约:写读往返、坏文件跳过、认领互斥、缺目录不抛错。
5
+
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ claimEntryFile,
12
+ hashEntryId,
13
+ readAllEntryFiles,
14
+ readEntryFile,
15
+ slugHashEntryId,
16
+ writeEntryFile,
17
+ } from "./entry-file-store.ts";
18
+
19
+ let dirs: string[] = [];
20
+ async function makeDir(): Promise<string> {
21
+ const dir = await mkdtemp(join(tmpdir(), "niceeval-entry-file-store-"));
22
+ dirs.push(dir);
23
+ return dir;
24
+ }
25
+ afterEach(async () => {
26
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })));
27
+ dirs = [];
28
+ });
29
+
30
+ describe("hashEntryId / slugHashEntryId: 稳定、无碰撞", () => {
31
+ it("同样的 parts 反复调用得到同一个 id(稳定,可跨调用往返)", () => {
32
+ expect(hashEntryId(["a", "1"])).toBe(hashEntryId(["a", "1"]));
33
+ });
34
+
35
+ it("不同 parts 得到不同 id(无碰撞)", () => {
36
+ expect(hashEntryId(["a", "1"])).not.toBe(hashEntryId(["a", "2"]));
37
+ expect(hashEntryId(["a", "1"])).not.toBe(hashEntryId(["b", "1"]));
38
+ });
39
+
40
+ it("slugHashEntryId 带上可读 slug 前缀,且对同样输入稳定", () => {
41
+ const id = slugHashEntryId("exp/a-case one", ["exp/a", "case one"]);
42
+ expect(id.startsWith("exp-a-case-one-")).toBe(true);
43
+ expect(id).toBe(slugHashEntryId("exp/a-case one", ["exp/a", "case one"]));
44
+ });
45
+
46
+ it("slugHashEntryId 对不同 hashParts 产出不同 id,即使 slugSource 相同", () => {
47
+ const a = slugHashEntryId("same-slug", ["x", "1"]);
48
+ const b = slugHashEntryId("same-slug", ["x", "2"]);
49
+ expect(a).not.toBe(b);
50
+ });
51
+ });
52
+
53
+ describe("writeEntryFile / readEntryFile: 写读往返", () => {
54
+ it("写入后按 id 读回,内容与写入时相等", async () => {
55
+ const dir = await makeDir();
56
+ await writeEntryFile(dir, "abc123", { hello: "world", n: 1 });
57
+ const read = await readEntryFile<{ hello: string; n: number }>(dir, "abc123");
58
+ expect(read).toEqual({ hello: "world", n: 1 });
59
+ });
60
+
61
+ it("写入不残留 .tmp 临时文件", async () => {
62
+ const dir = await makeDir();
63
+ await writeEntryFile(dir, "abc123", { a: 1 });
64
+ const files = await readdir(dir);
65
+ expect(files.filter((f) => f.endsWith(".tmp"))).toEqual([]);
66
+ expect(files).toEqual(["abc123.json"]);
67
+ });
68
+
69
+ it("读不存在的 id 返回 undefined,不抛错", async () => {
70
+ const dir = await makeDir();
71
+ expect(await readEntryFile(dir, "nothing")).toBeUndefined();
72
+ });
73
+
74
+ it("读损坏 JSON 返回 undefined,不抛错", async () => {
75
+ const dir = await makeDir();
76
+ await mkdir(dir, { recursive: true });
77
+ await writeFile(join(dir, "bad.json"), "{not json", "utf-8");
78
+ expect(await readEntryFile(dir, "bad")).toBeUndefined();
79
+ });
80
+
81
+ it("读不存在的目录返回 undefined,不抛错", async () => {
82
+ const missing = join((await makeDir()), "does-not-exist");
83
+ expect(await readEntryFile(missing, "any")).toBeUndefined();
84
+ });
85
+
86
+ it("再次写入同一个 id 会覆盖旧内容", async () => {
87
+ const dir = await makeDir();
88
+ await writeEntryFile(dir, "abc123", { v: 1 });
89
+ await writeEntryFile(dir, "abc123", { v: 2 });
90
+ expect(await readEntryFile(dir, "abc123")).toEqual({ v: 2 });
91
+ });
92
+ });
93
+
94
+ describe("readAllEntryFiles: 全目录扫描", () => {
95
+ it("跳过损坏条目与点文件(dotfiles),不拖垮整次扫描", async () => {
96
+ const dir = await makeDir();
97
+ await writeEntryFile(dir, "good-1", { ok: true });
98
+ await writeEntryFile(dir, "good-2", { ok: true });
99
+ await mkdir(dir, { recursive: true });
100
+ await writeFile(join(dir, "corrupt.json"), "{not json", "utf-8");
101
+ await writeFile(join(dir, ".hidden-tmp.json"), JSON.stringify({ ok: true }), "utf-8");
102
+ await writeFile(join(dir, "not-json.txt"), "irrelevant", "utf-8");
103
+
104
+ const all = await readAllEntryFiles<{ ok: boolean }>(dir);
105
+ expect(all.map((e) => e.id).sort()).toEqual(["good-1", "good-2"]);
106
+ });
107
+
108
+ it("目录不存在时返回空集合,不抛错", async () => {
109
+ const missing = join((await makeDir()), "does-not-exist");
110
+ expect(await readAllEntryFiles(missing)).toEqual([]);
111
+ });
112
+
113
+ it("空目录返回空集合", async () => {
114
+ const dir = await makeDir();
115
+ expect(await readAllEntryFiles(dir)).toEqual([]);
116
+ });
117
+ });
118
+
119
+ describe("claimEntryFile: rename-墓碑认领互斥", () => {
120
+ it("认领存在的 entry 返回 true,并把文件从目录移除", async () => {
121
+ const dir = await makeDir();
122
+ await writeEntryFile(dir, "claim-me", { a: 1 });
123
+ expect(await claimEntryFile(dir, "claim-me")).toBe(true);
124
+ expect(await readEntryFile(dir, "claim-me")).toBeUndefined();
125
+ // 墓碑文件本身也应该被清理干净,不残留
126
+ const files = await readdir(dir);
127
+ expect(files).toEqual([]);
128
+ });
129
+
130
+ it("认领不存在的 entry 返回 false,不抛错", async () => {
131
+ const dir = await makeDir();
132
+ expect(await claimEntryFile(dir, "nothing-here")).toBe(false);
133
+ });
134
+
135
+ it("两个并发调用者竞争同一个 id:只有一方拿到 true", async () => {
136
+ const dir = await makeDir();
137
+ await writeEntryFile(dir, "contested", { a: 1 });
138
+ const results = await Promise.all([claimEntryFile(dir, "contested"), claimEntryFile(dir, "contested")]);
139
+ expect(results.sort()).toEqual([false, true]);
140
+ });
141
+
142
+ it("认领后目录里没有残留的 .claimed 墓碑文件", async () => {
143
+ const dir = await makeDir();
144
+ await writeEntryFile(dir, "claim-me", { a: 1 });
145
+ await claimEntryFile(dir, "claim-me");
146
+ const files = await readdir(dir);
147
+ expect(files.some((f) => f.includes(".claimed"))).toBe(false);
148
+ });
149
+ });
@@ -0,0 +1,117 @@
1
+ // 逐条目原子文件原语:runner/teardown-registry.ts、sandbox/keep-registry.ts、runner/lock.ts
2
+ // 共用的写盘纪律(临时文件 → fsync 文件 → rename → fsync 目录)与损坏容错的全目录扫描,以及
3
+ // rename-墓碑认领互斥点。本模块语义无关——不知道登记/留存/锁各自的内容形状,也不做心跳、
4
+ // 过期判断或 pid/host 这类判活逻辑,那些都留在各消费方自己的模块里。
5
+
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { mkdir, open, readdir, readFile, rename, unlink } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+
10
+ /** 纯哈希 entry id:parts 用 ":" 拼接后 sha256,取十六进制前缀。不带可读前缀,只须无碰撞。 */
11
+ export function hashEntryId(parts: readonly string[], length = 12): string {
12
+ return createHash("sha256").update(parts.join(":")).digest("hex").slice(0, length);
13
+ }
14
+
15
+ /**
16
+ * slug + 哈希 entry id:人可读前缀(非 `[\w.-]` 字符替换为 `-`)拼接 `hashEntryId`,便于在目录里
17
+ * 目测定位是哪条身份;哈希部分仍然是唯一性的权威来源,slug 只是不承载解析的展示前缀。
18
+ */
19
+ export function slugHashEntryId(slugSource: string, hashParts: readonly string[], length = 12): string {
20
+ const slug = slugSource.replace(/[^\w.-]/g, "-");
21
+ return `${slug}-${hashEntryId(hashParts, length)}`;
22
+ }
23
+
24
+ /** 原子写入一条 entry:临时文件 → fsync 文件 → rename → fsync 目录(尽力而为,见 fsyncDir)。 */
25
+ export async function writeEntryFile(dir: string, id: string, data: unknown): Promise<void> {
26
+ await mkdir(dir, { recursive: true });
27
+ const tmpPath = join(dir, `.${id}.${process.pid}.tmp`);
28
+ const finalPath = join(dir, `${id}.json`);
29
+ const handle = await open(tmpPath, "w");
30
+ try {
31
+ await handle.writeFile(JSON.stringify(data, null, 2), "utf-8");
32
+ await handle.sync();
33
+ } finally {
34
+ await handle.close();
35
+ }
36
+ await rename(tmpPath, finalPath);
37
+ await fsyncDir(dir);
38
+ }
39
+
40
+ /** 读一条 entry(不存在或损坏都返回 undefined,不抛错)。 */
41
+ export async function readEntryFile<T>(dir: string, id: string): Promise<T | undefined> {
42
+ try {
43
+ return JSON.parse(await readFile(join(dir, `${id}.json`), "utf-8")) as T;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** 读全部 entry(目录不存在返回空集合;跳过点文件与非 `.json` 文件;损坏条目跳过,不拖垮整次扫描)。 */
50
+ export async function readAllEntryFiles<T>(dir: string): Promise<{ id: string; entry: T }[]> {
51
+ let files: string[];
52
+ try {
53
+ files = await readdir(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ const out: { id: string; entry: T }[] = [];
58
+ for (const file of files) {
59
+ if (!file.endsWith(".json") || file.startsWith(".")) continue; // 点文件是在飞临时/墓碑文件
60
+ try {
61
+ const raw = await readFile(join(dir, file), "utf-8");
62
+ out.push({ id: file.slice(0, -".json".length), entry: JSON.parse(raw) as T });
63
+ } catch {
64
+ // 跳过损坏条目,不让一条坏文件拖垮整次扫描。
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * 认领互斥点:用 rename 把 entry 移到本次调用独有的墓碑名,而不是直接 `unlink`。同一个源路径的
72
+ * rename 只有一个调用者能命中——其余全部拿到 ENOENT,这把「谁先抢到」这件事和后续清理动作分开,
73
+ * 在某些文件系统里,并发对同一路径 `unlink` 的语义并不保证「恰好一个成功」,而并发 `rename` 到
74
+ * 各自独占的目标名没有这个歧义。抢到墓碑之后再尝试 `unlink` 墓碑并 fsync 目录,但即使墓碑清理
75
+ * 本身失败也不放弃已经拿到的认领权——它已经不在原路径,不会被第二个认领者看到。
76
+ *
77
+ * 两种上层用法共用同一个操作,只是拿到 `true` 之后做的事不同:
78
+ * - 「删除且只删一次」(收尾登记的用法):认领成功即代表删除完成,不必再写回。
79
+ * - 「接管后立刻在原路径重建」(用例锁的过期接管用法):认领成功后原路径已空出,赢家随即在
80
+ * 该路径写入自己的新记录;若这一步撞上 EEXIST(另一个赢家已经抢先写回),按认领失败处理即可,
81
+ * 不需要报错。
82
+ *
83
+ * 返回 `true` 即拿到认领权;返回 `false` 表示 entry 已被别的调用者认领或删除(rename 源
84
+ * ENOENT);其余错误原样抛出。
85
+ */
86
+ export async function claimEntryFile(dir: string, id: string): Promise<boolean> {
87
+ const path = join(dir, `${id}.json`);
88
+ const claimedPath = join(dir, `.${id}.${process.pid}.${randomUUID()}.claimed`);
89
+ try {
90
+ await rename(path, claimedPath);
91
+ } catch (e) {
92
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
93
+ throw e;
94
+ }
95
+ try {
96
+ await unlink(claimedPath);
97
+ await fsyncDir(dir);
98
+ } catch (e) {
99
+ // 已经夺得认领权;清理墓碑失败不允许把 entry 重新暴露给第二个认领者。
100
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /** 目录 fsync,尽力而为——部分平台/文件系统不支持目录 fsync(如 Windows),静默降级。 */
106
+ export async function fsyncDir(dir: string): Promise<void> {
107
+ try {
108
+ const handle = await open(dir, "r");
109
+ try {
110
+ await handle.sync();
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ } catch {
115
+ // rename/rm 本身已是原子操作,fsync 只是尽力而为的额外保险。
116
+ }
117
+ }
@@ -0,0 +1,137 @@
1
+ // cases: docs/engineering/testing/unit/eval.md
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ EvalFatalError,
5
+ ExperimentFatalError,
6
+ attachFailureClass,
7
+ attemptFailureInfo,
8
+ failureClassOf,
9
+ resolveAttemptFailureClass,
10
+ type AttemptFailureInfo,
11
+ } from "./failure-class.ts";
12
+
13
+ describe("糖衣类 · 抛出点声明空间轴", () => {
14
+ it("ExperimentFatalError 携带 experiment 档,message 原样保留(它是走完全程的修复提示)", () => {
15
+ const error = new ExperimentFatalError("server probe failed — 修好隧道后重跑");
16
+ expect(failureClassOf(error)).toEqual({ retryable: false, scope: "experiment" });
17
+ expect(error.message).toBe("server probe failed — 修好隧道后重跑");
18
+ });
19
+
20
+ it("EvalFatalError 携带 eval 档,cause 原样透传", () => {
21
+ const cause = new Error("ENOENT");
22
+ const error = new EvalFatalError("fixture 缺失", { cause });
23
+ expect(failureClassOf(error)).toEqual({ retryable: false, scope: "eval" });
24
+ expect(error.cause).toBe(cause);
25
+ });
26
+
27
+ it("糖衣类是纯 Error 子类:instanceof Error 成立,可被常规 catch 捕获", () => {
28
+ expect(new ExperimentFatalError("x")).toBeInstanceOf(Error);
29
+ expect(new EvalFatalError("x")).toBeInstanceOf(Error);
30
+ });
31
+ });
32
+
33
+ describe("failureClassOf · 结构守卫", () => {
34
+ it("识别不依赖类身份:结构相同的手工对象同样命中(第二份 niceeval 实例下也不失效)", () => {
35
+ const impostor = { _tag: "NiceevalClassifiedError", class: { retryable: false, scope: "experiment" } };
36
+ expect(failureClassOf(impostor)).toEqual({ retryable: false, scope: "experiment" });
37
+ });
38
+
39
+ it("沿 cause 链穿透:糖衣类被上层库包装再抛,声明不丢失", () => {
40
+ const wrapped = new Error("sdk wrapper", { cause: new Error("layer 2", { cause: new EvalFatalError("boom") }) });
41
+ expect(failureClassOf(wrapped)).toEqual({ retryable: false, scope: "eval" });
42
+ });
43
+
44
+ it("取最外层命中:内外都带分类时,外层的声明生效", () => {
45
+ const outer = new ExperimentFatalError("outer", { cause: new EvalFatalError("inner") });
46
+ expect(failureClassOf(outer)).toEqual({ retryable: false, scope: "experiment" });
47
+ });
48
+
49
+ it("没有分类的错误、非对象值一律 undefined", () => {
50
+ expect(failureClassOf(new Error("plain"))).toBeUndefined();
51
+ expect(failureClassOf("just a string")).toBeUndefined();
52
+ expect(failureClassOf(undefined)).toBeUndefined();
53
+ });
54
+
55
+ it("`_tag` 对但 class 字段形状非法的对象不算命中(不把垃圾当分类喂给执行体)", () => {
56
+ expect(failureClassOf({ _tag: "NiceevalClassifiedError", class: { retryable: "yes" } })).toBeUndefined();
57
+ // retryable: true 必须带 reason(类型级规则),运行时进来的非法组合同样不认
58
+ expect(failureClassOf({ _tag: "NiceevalClassifiedError", class: { retryable: true } })).toBeUndefined();
59
+ expect(failureClassOf({ _tag: "NiceevalClassifiedError", class: { retryable: false, scope: "run" } })).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe("attachFailureClass · 框架决议出的分类附着", () => {
64
+ it("挂上的两个字段不可枚举:不进 JSON,只是路由标记", () => {
65
+ const error = attachFailureClass(new Error("boom"), { retryable: false, scope: "eval" });
66
+ expect(failureClassOf(error)).toEqual({ retryable: false, scope: "eval" });
67
+ expect(Object.keys(error)).not.toContain("class");
68
+ expect(JSON.stringify({ ...error })).toBe("{}");
69
+ });
70
+
71
+ it("抛出点已声明过的分类不被框架覆盖", () => {
72
+ const declared = new ExperimentFatalError("tunnel down");
73
+ attachFailureClass(declared, { retryable: true, reason: "rate_limit" });
74
+ expect(failureClassOf(declared)).toEqual({ retryable: false, scope: "experiment" });
75
+ });
76
+
77
+ it("非对象抛出值原样返回,不制造新失败", () => {
78
+ expect(attachFailureClass("string failure", { retryable: false })).toBe("string failure");
79
+ });
80
+
81
+ it("冻结的错误对象标记不上也不抛错(分类是旁路)", () => {
82
+ const frozen = Object.freeze(new Error("frozen"));
83
+ expect(() => attachFailureClass(frozen, { retryable: false, scope: "eval" })).not.toThrow();
84
+ expect(failureClassOf(frozen)).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("resolveAttemptFailureClass · 生命周期阶段的三道链", () => {
89
+ const info = (error: unknown): AttemptFailureInfo => attemptFailureInfo("eval.setup", error);
90
+
91
+ it("抛出点携带的分类命中即定,不询问实验分类器", () => {
92
+ const classifier = vi.fn(() => ({ retryable: false as const, scope: "attempt" as const }));
93
+ expect(resolveAttemptFailureClass(info(new EvalFatalError("fixture missing")), classifier)).toEqual({
94
+ retryable: false,
95
+ scope: "eval",
96
+ });
97
+ expect(classifier).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("实验分类器认领第三方错误,scope 与自造 reason 原样生效", () => {
101
+ const result = resolveAttemptFailureClass(info(new Error("connect ECONNREFUSED tunnel.example:443")), (failure) =>
102
+ failure.text.includes("tunnel.example")
103
+ ? { retryable: false, scope: "experiment", reason: "tunnel_down" }
104
+ : undefined,
105
+ );
106
+ expect(result).toEqual({ retryable: false, scope: "experiment", reason: "tunnel_down" });
107
+ });
108
+
109
+ it("链上不挂兜底正则:限流文案在这些位置也不产时间轴,缺省不可重试", () => {
110
+ // 同一段文本在 turn 链上会被兜底判成 rate_limit 可重试;这里没有重试执行体,
111
+ // 时间轴即使给出也无人消费(见 architecture.md「分类链」)。
112
+ expect(resolveAttemptFailureClass(info(new Error("429 too many requests, please retry later")))).toEqual({
113
+ retryable: false,
114
+ });
115
+ });
116
+
117
+ it("实验分类器返回 undefined / 抛错都落缺省,不掩盖原始失败", () => {
118
+ expect(resolveAttemptFailureClass(info(new Error("boom")), () => undefined)).toEqual({ retryable: false });
119
+ expect(
120
+ resolveAttemptFailureClass(info(new Error("boom")), () => {
121
+ throw new Error("classifier bug");
122
+ }),
123
+ ).toEqual({ retryable: false });
124
+ });
125
+
126
+ it("分类器读到的 text 与报错文案同源:错误链(含 cause)message 串接", () => {
127
+ const error = new Error("eval setup failed", { cause: new Error("connect ECONNREFUSED tunnel.example:443") });
128
+ const seen: AttemptFailureInfo[] = [];
129
+ resolveAttemptFailureClass(attemptFailureInfo("eval.setup", error), (failure) => {
130
+ seen.push(failure);
131
+ return undefined;
132
+ });
133
+ expect(seen[0].phase).toBe("eval.setup");
134
+ expect(seen[0].text).toBe("eval setup failed · connect ECONNREFUSED tunnel.example:443");
135
+ expect(seen[0].cause).toBe(error);
136
+ });
137
+ });