niceeval 0.6.1 → 0.6.2

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 (296) hide show
  1. package/dist/agents/types.d.ts +67 -5
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -1
  9. package/dist/report/built-ins/experiment-comparison.js +116 -10
  10. package/dist/report/built-ins/index.d.ts +1 -0
  11. package/dist/report/built-ins/index.js +1 -1
  12. package/dist/report/components.d.ts +8 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +54 -34
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -8
  19. package/dist/report/format.js +27 -12
  20. package/dist/report/index.d.ts +4 -3
  21. package/dist/report/index.js +5 -4
  22. package/dist/report/locale.d.ts +11 -2
  23. package/dist/report/locale.js +23 -5
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +65 -14
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +2 -2
  28. package/dist/report/react/AttemptList.js +5 -6
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +2 -1
  34. package/dist/report/react/ExperimentList.js +8 -10
  35. package/dist/report/react/MetricScatter.js +5 -11
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +21 -14
  40. package/dist/report/report.d.ts +5 -1
  41. package/dist/report/report.js +6 -2
  42. package/dist/report/text/faces.d.ts +1 -1
  43. package/dist/report/text/faces.js +42 -41
  44. package/dist/report/text/table.js +36 -5
  45. package/dist/report/types.d.ts +39 -21
  46. package/dist/results/types.d.ts +11 -0
  47. package/dist/runner/feedback/sink.d.ts +110 -0
  48. package/dist/runner/types.d.ts +513 -22
  49. package/dist/sandbox/docker.d.ts +23 -2
  50. package/dist/sandbox/e2b.d.ts +15 -1
  51. package/dist/sandbox/errors.d.ts +30 -3
  52. package/dist/sandbox/io-retry.d.ts +17 -0
  53. package/dist/sandbox/registry.d.ts +2 -0
  54. package/dist/sandbox/resolve.d.ts +18 -5
  55. package/dist/sandbox/retry.d.ts +11 -1
  56. package/dist/sandbox/types.d.ts +39 -5
  57. package/dist/sandbox/vercel.d.ts +7 -1
  58. package/dist/scoring/coverage.d.ts +30 -0
  59. package/dist/scoring/display.d.ts +21 -0
  60. package/dist/scoring/display.js +120 -0
  61. package/dist/scoring/types.d.ts +103 -20
  62. package/dist/shared/aggregate.d.ts +1 -0
  63. package/dist/shared/aggregate.js +3 -3
  64. package/dist/shared/types.d.ts +28 -0
  65. package/dist/tty-line.d.ts +0 -4
  66. package/dist/util.d.ts +23 -0
  67. package/docs-site/zh/concepts/adapter.mdx +22 -4
  68. package/docs-site/zh/concepts/experiment.mdx +1 -1
  69. package/docs-site/zh/concepts/overview.mdx +6 -6
  70. package/docs-site/zh/guides/agent-feedback-loop.mdx +28 -26
  71. package/docs-site/zh/guides/authoring.mdx +33 -0
  72. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  73. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  74. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  75. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  76. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  77. package/docs-site/zh/guides/debugging.mdx +210 -0
  78. package/docs-site/zh/guides/experiments.mdx +10 -3
  79. package/docs-site/zh/guides/official-adapters.mdx +26 -2
  80. package/docs-site/zh/guides/publish-report.mdx +30 -16
  81. package/docs-site/zh/guides/report-components.mdx +42 -30
  82. package/docs-site/zh/guides/reporters.mdx +2 -2
  83. package/docs-site/zh/guides/results-data.mdx +17 -9
  84. package/docs-site/zh/guides/runner.mdx +17 -7
  85. package/docs-site/zh/guides/sandbox-agent.mdx +56 -7
  86. package/docs-site/zh/guides/sandbox-providers.mdx +257 -9
  87. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  88. package/docs-site/zh/guides/viewing-results.mdx +79 -36
  89. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  90. package/docs-site/zh/guides/write-send.mdx +17 -1
  91. package/docs-site/zh/index.mdx +1 -1
  92. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  93. package/docs-site/zh/reference/capabilities.mdx +2 -2
  94. package/docs-site/zh/reference/cli.mdx +33 -7
  95. package/docs-site/zh/reference/define-agent.mdx +57 -4
  96. package/docs-site/zh/reference/define-config.mdx +1 -1
  97. package/docs-site/zh/reference/define-eval.mdx +42 -9
  98. package/docs-site/zh/reference/expect.mdx +26 -1
  99. package/package.json +5 -1
  100. package/src/agents/ai-sdk-otel.test.ts +1 -0
  101. package/src/agents/ai-sdk.test.ts +3 -0
  102. package/src/agents/ai-sdk.ts +3 -0
  103. package/src/agents/bub-install-spec.test.ts +34 -0
  104. package/src/agents/bub-install-spec.ts +32 -0
  105. package/src/agents/bub.ts +31 -32
  106. package/src/agents/claude-code.test.ts +130 -9
  107. package/src/agents/claude-code.ts +76 -4
  108. package/src/agents/codex.test.ts +189 -40
  109. package/src/agents/codex.ts +155 -14
  110. package/src/agents/coding-cli-versions.test.ts +15 -0
  111. package/src/agents/coding-cli-versions.ts +3 -0
  112. package/src/agents/index.ts +11 -0
  113. package/src/agents/langgraph.test.ts +204 -0
  114. package/src/agents/langgraph.ts +495 -0
  115. package/src/agents/marketplace.ts +85 -0
  116. package/src/agents/native-config.test.ts +179 -0
  117. package/src/agents/native-config.ts +267 -0
  118. package/src/agents/openai-compat.test.ts +1 -0
  119. package/src/agents/openclaw.test.ts +31 -0
  120. package/src/agents/openclaw.ts +171 -0
  121. package/src/agents/plugin-config.test.ts +1 -0
  122. package/src/agents/sdk-streams.test.ts +79 -0
  123. package/src/agents/sdk-streams.ts +55 -10
  124. package/src/agents/skills.test.ts +1 -0
  125. package/src/agents/streaming.test.ts +3 -9
  126. package/src/agents/types.ts +68 -5
  127. package/src/agents/ui-message-stream.test.ts +3 -0
  128. package/src/cli.ts +411 -108
  129. package/src/context/context.test.ts +51 -12
  130. package/src/context/context.ts +161 -29
  131. package/src/context/session.test.ts +1 -0
  132. package/src/context/session.ts +114 -6
  133. package/src/context/types.ts +30 -12
  134. package/src/define.test.ts +13 -8
  135. package/src/define.ts +25 -4
  136. package/src/expect/index.ts +53 -23
  137. package/src/i18n/en.ts +64 -2
  138. package/src/i18n/zh-CN.ts +65 -3
  139. package/src/o11y/cost.test.ts +1 -0
  140. package/src/o11y/execution-tree.test.ts +1 -20
  141. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  142. package/src/o11y/otlp/parse.test.ts +1 -0
  143. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  144. package/src/o11y/parsers/bub.test.ts +1 -0
  145. package/src/o11y/parsers/claude-code.test.ts +1 -34
  146. package/src/o11y/parsers/openclaw.test.ts +154 -0
  147. package/src/o11y/parsers/openclaw.ts +310 -0
  148. package/src/o11y/prices.json +746 -311
  149. package/src/o11y/tool-names.test.ts +1 -0
  150. package/src/o11y/types.ts +16 -2
  151. package/src/report/aggregate.ts +34 -5
  152. package/src/report/built-in-user-parity.test.tsx +110 -153
  153. package/src/report/built-ins/experiment-comparison.tsx +173 -13
  154. package/src/report/built-ins/index.ts +6 -1
  155. package/src/report/components.tsx +9 -3
  156. package/src/report/compute.ts +70 -40
  157. package/src/report/dual-render.test.tsx +194 -67
  158. package/src/report/flag.ts +30 -2
  159. package/src/report/format.ts +35 -11
  160. package/src/report/index.ts +22 -4
  161. package/src/report/locale.ts +25 -5
  162. package/src/report/metrics.ts +67 -14
  163. package/src/report/primitives.tsx +6 -0
  164. package/src/report/react/AttemptList.tsx +6 -31
  165. package/src/report/react/EvalList.tsx +0 -0
  166. package/src/report/react/ExperimentComparison.tsx +68 -0
  167. package/src/report/react/ExperimentList.tsx +15 -9
  168. package/src/report/react/MetricScatter.tsx +12 -14
  169. package/src/report/react/chart-math.test.ts +85 -0
  170. package/src/report/react/chart-math.ts +101 -22
  171. package/src/report/react/enhance.js +33 -1
  172. package/src/report/react/fixtures.ts +24 -17
  173. package/src/report/react/render.test.tsx +9 -64
  174. package/src/report/react/styles.css +73 -2
  175. package/src/report/report.test.ts +306 -98
  176. package/src/report/report.ts +6 -2
  177. package/src/report/text/faces.ts +47 -43
  178. package/src/report/text/table.ts +42 -5
  179. package/src/report/types.ts +41 -21
  180. package/src/results/annotated-source.test.ts +62 -9
  181. package/src/results/annotated-source.ts +64 -6
  182. package/src/results/attempt-evidence.test.ts +9 -7
  183. package/src/results/attempt-evidence.ts +15 -8
  184. package/src/results/attempt-source.ts +6 -3
  185. package/src/results/copy.ts +145 -55
  186. package/src/results/host-equivalence.test.ts +8 -6
  187. package/src/results/index.ts +2 -0
  188. package/src/results/locator.test.ts +1 -22
  189. package/src/results/open.ts +7 -1
  190. package/src/results/publish.ts +149 -0
  191. package/src/results/results.test.ts +85 -51
  192. package/src/results/truncate.ts +90 -0
  193. package/src/results/types.ts +7 -0
  194. package/src/results/writer.ts +31 -13
  195. package/src/runner/attempt.test.ts +138 -7
  196. package/src/runner/attempt.ts +603 -104
  197. package/src/runner/discover.test.ts +47 -0
  198. package/src/runner/discover.ts +36 -2
  199. package/src/runner/eval-source.test.ts +1 -27
  200. package/src/runner/feedback/agent.test.ts +504 -0
  201. package/src/runner/feedback/agent.ts +409 -0
  202. package/src/runner/feedback/ci.test.ts +562 -0
  203. package/src/runner/feedback/ci.ts +401 -0
  204. package/src/runner/feedback/coordinator.test.ts +317 -0
  205. package/src/runner/feedback/coordinator.ts +397 -0
  206. package/src/runner/feedback/failure.ts +40 -0
  207. package/src/runner/feedback/human.test.ts +616 -0
  208. package/src/runner/feedback/human.ts +535 -0
  209. package/src/runner/feedback/index.ts +66 -0
  210. package/src/runner/feedback/io.ts +78 -0
  211. package/src/runner/feedback/profile.test.ts +50 -0
  212. package/src/runner/feedback/profile.ts +58 -0
  213. package/src/runner/feedback/reducer.test.ts +395 -0
  214. package/src/runner/feedback/reducer.ts +260 -0
  215. package/src/runner/feedback/renderer.ts +82 -0
  216. package/src/runner/feedback/sink.ts +203 -0
  217. package/src/runner/feedback/testing.ts +106 -0
  218. package/src/runner/ledger.test.ts +230 -0
  219. package/src/runner/ledger.ts +329 -0
  220. package/src/runner/report.test.ts +128 -3
  221. package/src/runner/report.ts +33 -9
  222. package/src/runner/reporters/artifacts.ts +8 -2
  223. package/src/runner/reporters/braintrust.test.ts +8 -7
  224. package/src/runner/reporters/braintrust.ts +9 -2
  225. package/src/runner/reporters/index.ts +2 -2
  226. package/src/runner/reporters/json.test.ts +162 -0
  227. package/src/runner/reporters/json.ts +35 -8
  228. package/src/runner/reporters/shared.ts +1 -5
  229. package/src/runner/run.test.ts +760 -3
  230. package/src/runner/run.ts +242 -36
  231. package/src/runner/sandbox-prep.ts +3 -42
  232. package/src/runner/timing.ts +158 -0
  233. package/src/runner/types.ts +518 -22
  234. package/src/sandbox/checkpoint.test.ts +55 -0
  235. package/src/sandbox/checkpoint.ts +29 -8
  236. package/src/sandbox/cli-commands.ts +407 -0
  237. package/src/sandbox/docker.ts +115 -16
  238. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  239. package/src/sandbox/e2b-agent-template.ts +94 -0
  240. package/src/sandbox/e2b.ts +74 -9
  241. package/src/sandbox/errors.ts +111 -4
  242. package/src/sandbox/index.ts +2 -0
  243. package/src/sandbox/io-retry.test.ts +58 -0
  244. package/src/sandbox/io-retry.ts +45 -0
  245. package/src/sandbox/keep-registry.test.ts +86 -0
  246. package/src/sandbox/keep-registry.ts +142 -0
  247. package/src/sandbox/keep.ts +178 -0
  248. package/src/sandbox/paths.test.ts +1 -0
  249. package/src/sandbox/paths.ts +19 -8
  250. package/src/sandbox/registry.ts +20 -3
  251. package/src/sandbox/resolve.ts +76 -11
  252. package/src/sandbox/retry.test.ts +70 -0
  253. package/src/sandbox/retry.ts +46 -4
  254. package/src/sandbox/types.ts +44 -6
  255. package/src/sandbox/vercel.ts +43 -20
  256. package/src/scoring/collector.ts +60 -17
  257. package/src/scoring/coverage.ts +95 -0
  258. package/src/scoring/diff.ts +81 -0
  259. package/src/scoring/display.test.ts +121 -0
  260. package/src/scoring/display.ts +133 -0
  261. package/src/scoring/evidence.test.ts +189 -0
  262. package/src/scoring/judge.test.ts +142 -0
  263. package/src/scoring/judge.ts +15 -18
  264. package/src/scoring/scoped.ts +217 -50
  265. package/src/scoring/types.ts +117 -20
  266. package/src/scoring/verdict.ts +16 -4
  267. package/src/shared/aggregate.ts +3 -2
  268. package/src/shared/types.ts +31 -0
  269. package/src/show/compose.ts +2 -2
  270. package/src/show/index.ts +21 -1
  271. package/src/show/render.ts +619 -104
  272. package/src/show/show.test.ts +235 -19
  273. package/src/tty-line.ts +8 -26
  274. package/src/util.test.ts +1 -0
  275. package/src/util.ts +41 -0
  276. package/src/view/app/components/AttemptModal.tsx +153 -2
  277. package/src/view/app/components/CodeView.tsx +32 -11
  278. package/src/view/app/components/CopyControls.tsx +2 -2
  279. package/src/view/app/i18n.ts +6 -0
  280. package/src/view/app/lib/attempt-route.test.ts +1 -0
  281. package/src/view/app/lib/verdict.ts +7 -9
  282. package/src/view/artifact-serving.test.ts +2 -1
  283. package/src/view/client-dist/app.css +1 -1
  284. package/src/view/client-dist/app.js +17 -17
  285. package/src/view/data.test.ts +1 -0
  286. package/src/view/data.ts +11 -1
  287. package/src/view/index.ts +11 -0
  288. package/src/view/server.ts +2 -0
  289. package/src/view/styles.css +3 -0
  290. package/src/view/view-report.test.ts +6 -5
  291. package/src/runner/reporters/console.ts +0 -70
  292. package/src/runner/reporters/live.test.ts +0 -56
  293. package/src/runner/reporters/live.ts +0 -247
  294. package/src/runner/reporters/quiet.test.ts +0 -66
  295. package/src/runner/reporters/quiet.ts +0 -49
  296. package/src/runner/reporters/table.ts +0 -277
@@ -6,7 +6,10 @@
6
6
  import { resolve as resolvePath } from "node:path";
7
7
  import { readFile as readSourceFile } from "node:fs/promises";
8
8
  import { Effect, Cause, Duration } from "effect";
9
- import { createSandbox, sandboxLabel } from "../sandbox/resolve.ts";
9
+ import { createSandbox, resolveSandbox, sandboxRunInfo } from "../sandbox/resolve.ts";
10
+ import { stopSandbox, unregisterSandbox } from "../sandbox/registry.ts";
11
+ import { KEEPABLE_PROVIDERS, nativeEnterCommand, suspendSandbox } from "../sandbox/keep.ts";
12
+ import { keptEntryId, updateKeptEntry, writeKeptEntry } from "../sandbox/keep-registry.ts";
10
13
  import { createTraceReceiver, type TraceReceiver } from "../o11y/otlp/receiver.ts";
11
14
  import { createInSandboxTraceReceiver } from "../o11y/otlp/sandbox-receiver.ts";
12
15
  import type { AgentOtelChannel } from "../o11y/otlp/turn-otel.ts";
@@ -20,20 +23,24 @@ import { computeVerdict } from "../scoring/verdict.ts";
20
23
  import { deriveRunFacts, buildO11ySummary } from "../o11y/derive.ts";
21
24
  import { estimateCost } from "../o11y/cost.ts";
22
25
  import { t } from "../i18n/index.ts";
23
- import { formatThrown } from "../util.ts";
24
- import { captureGeneratedFiles, initGitAndCommit } from "./sandbox-prep.ts";
26
+ import { describeError, firstLine, formatThrown } from "../util.ts";
27
+ import { createChangeLedger, type ChangeLedger } from "./ledger.ts";
28
+ import { deriveDiffData, emptyDiffData } from "../scoring/diff.ts";
25
29
  import { createRemoteSandbox, withEvalLocalPaths } from "./remote-sandbox.ts";
26
30
  import type { CapturedEvalSource } from "./eval-source.ts";
27
31
  import type {
28
32
  AgentContext,
29
- AgentSetup,
30
33
  AgentSetupManifest,
31
- AgentTeardown,
32
34
  Cleanup,
33
35
  Config,
36
+ DiagnosticInput,
37
+ DiffArtifact,
34
38
  EvalResult,
35
39
  JudgeConfig,
36
40
  Sandbox,
41
+ SandboxHook,
42
+ SandboxHookContext,
43
+ ScopedFeedback,
37
44
  ScoringContext,
38
45
  ScriptResult,
39
46
  SourceArtifact,
@@ -41,17 +48,33 @@ import type {
41
48
  Telemetry,
42
49
  TraceSpan,
43
50
  } from "../types.ts";
44
- import { runWho } from "./types.ts";
45
- import type { AgentRun, Attempt, RunOptions } from "./types.ts";
51
+ import { reportAttemptLifecycle, reportDiagnostic, reportKept } from "./feedback/sink.ts";
52
+ import { encodeAttemptKey, runWho } from "./types.ts";
53
+ import { commandDisplay, commandNode, createTimingRecorder, type TimingRecorder } from "./timing.ts";
54
+ import type {
55
+ AgentRun,
56
+ Attempt,
57
+ AttemptError,
58
+ AttemptRef,
59
+ DiagnosticRecord,
60
+ LifecyclePhase,
61
+ RunOptions,
62
+ } from "./types.ts";
46
63
 
47
64
  export function runAttemptEffect(
48
65
  a: Attempt,
49
66
  opts: RunOptions,
50
67
  sandboxSem: Effect.Semaphore,
51
68
  parentSignal?: AbortSignal,
69
+ /** 每次跨入一个新 `LifecyclePhase` 边界时同步回调一次(与下面的 `enterPhase` 同一调用点,见
70
+ * 该函数)。run.ts 用它在本地跟踪「这个 attempt 目前所在的阶段」,好在 attempt 失败/errored
71
+ * 时把 phase 塞进 `reportFailure()`(见 sink.ts 的 `FailureInput.phase`)—— 到那时
72
+ * attempt:complete 已经让 coordinator 把 active map 里的条目删掉,没有别的地方能事后查到。 */
73
+ onPhase?: (phase: LifecyclePhase) => void,
52
74
  ): Effect.Effect<EvalResult> {
53
75
  const config = opts.config;
54
76
  const { evalDef, run, attempt } = a;
77
+ const niceevalRoot = opts.niceevalRoot ?? `${process.cwd()}/.niceeval`;
55
78
  const t0 = Date.now();
56
79
 
57
80
  const base: EvalResult = {
@@ -76,19 +99,81 @@ export function runAttemptEffect(
76
99
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
77
100
  const signal = parentSignal ? AbortSignal.any([parentSignal, timeoutSignal]) : timeoutSignal;
78
101
 
79
- // 流式进度打到宿主 stderr(结果走 stdout,互不干扰)。容器主日志【不】放这些进度标记 ——
80
- // 那里留给 agent 的原始输出(adapter agent 命令开 { stream: true })。
81
- const who = runWho({ agentName: run.agent.name, model: run.model, experimentId: run.experimentId });
102
+ // Attempt 阶段的正式生命周期投影( docs/feature/experiments/cli.md「Attempt 阶段」)
103
+ // run.ts 在这个 attempt body Effect 真正开始跑之前,已经先发出过一次 attempt:start(占位
104
+ // phase,见 run.ts 的 attempt:start emission,和这里的 eval:start 是同一个调用点),所以这里
105
+ // 只需要在每个「实际执行到的」边界调 enterPhase() 覆盖上一个 phase(attempt:phase),不需要
106
+ // 自己区分「第一次」。没有对应 hook/配置的步骤直接不调用,不产生空阶段(如没有 setup 的 agent
107
+ // 跳过 agent-setup)。没有活跃 feedback coordinator 时 reportAttemptLifecycle 静默 no-op,
108
+ // 不产生任何终端输出。
109
+ const identity: AttemptRef = { experimentId: run.experimentId, evalId: evalDef.id, attempt };
110
+ // 最近跨入的正式 phase:errored 结果的 `error.phase` 从它取(见下方 timeout / scope
111
+ // 兜底与 runAttemptBody 的 body catch)。body 与本函数共用同一个 enterPhase 闭包(经 res 传下去),
112
+ // 所以 body 内部的阶段推进也会更新它,不需要 body 再单独维护一份。
113
+ // 阶段计时:live 展示、error.phase、落盘 phases[].name 用同一套 LifecyclePhase 闭集,
114
+ // 一次 enterPhase 同时推进三者(词表全仓只有一套,见 runner/types.ts 的 LifecyclePhase)。
115
+ let lastPhase: LifecyclePhase | undefined;
116
+ const recorder = createTimingRecorder(() => Date.now());
117
+ // adapter send 在飞时,错误/诊断归因到嵌套的 `agent.run`(eval.run 内打开,不单列计时条目)。
118
+ let sendActive = false;
119
+ const enterPhase = (phase: LifecyclePhase) => {
120
+ lastPhase = phase;
121
+ recorder.enter(phase);
122
+ onPhase?.(phase);
123
+ reportAttemptLifecycle({ type: "attempt:phase", at: Date.now(), identity, phase });
124
+ };
125
+ // 本 attempt 累计的诊断(与 verdict 独立):ScopedFeedback.diagnostic 与 teardown 失败都落这里,
126
+ // 收尾时并入结果;dedupeKey 相同的并发诊断折叠成一条并累计 count。
127
+ const diagnostics: DiagnosticRecord[] = [];
128
+ const dedupeIndex = new Map<string, DiagnosticRecord>();
129
+ const recordDiagnostic = (input: DiagnosticInput) => {
130
+ const phase = (sendActive ? "agent.run" : lastPhase) ?? "eval.run";
131
+ if (input.dedupeKey !== undefined) {
132
+ const existing = dedupeIndex.get(input.dedupeKey);
133
+ if (existing) {
134
+ existing.count = (existing.count ?? 1) + 1;
135
+ return;
136
+ }
137
+ }
138
+ const record: DiagnosticRecord = {
139
+ code: input.code,
140
+ level: input.level,
141
+ message: input.message,
142
+ phase,
143
+ ...(input.data !== undefined ? { data: input.data } : {}),
144
+ };
145
+ if (input.dedupeKey !== undefined) dedupeIndex.set(input.dedupeKey, record);
146
+ diagnostics.push(record);
147
+ // 同时进运行级永久事件流(human 撤下 dashboard 后追加、agent/ci 各追加一条,去重按 key)。
148
+ reportDiagnostic({
149
+ key: input.dedupeKey ?? `${input.code}:${encodeAttemptKey(identity)}`,
150
+ severity: input.level,
151
+ message: input.message,
152
+ identity,
153
+ data: input.data,
154
+ });
155
+ };
156
+ // 作用域反馈:progress 走 attempt:progress(短命状态,归因由 runner 的当前阶段决定),
157
+ // diagnostic 落 attempt diagnostics + 运行级永久事件。绑定见 docs/feature/experiments/library.md。
158
+ const scopedFeedback: ScopedFeedback = {
159
+ progress: (u) => {
160
+ const suffix = u.current !== undefined && u.total !== undefined ? ` (${u.current}/${u.total})` : "";
161
+ log(`${u.message}${suffix}`);
162
+ },
163
+ diagnostic: recordDiagnostic,
164
+ };
165
+
82
166
  // 同时保留最近 20 条进度消息,timeout 时嵌入 error 字段方便定位卡在哪一步。
83
167
  const recentLogs: string[] = [];
84
168
  const log = (m: string) => {
85
169
  recentLogs.push(m);
86
170
  if (recentLogs.length > 20) recentLogs.shift();
87
- if (opts.onProgress) {
88
- opts.onProgress(evalDef.id, who, m);
89
- } else {
90
- process.stderr.write(` · ${evalDef.id} [${who}] ${m}\n`);
91
- }
171
+ // 附着在「当前阶段」上的次要文本(见 ActiveAttempt.detail);attempt:start 早于本函数任何
172
+ // 调用点发出(见上),active map 里一定已经有这个 identity 的条目。这是 log() 唯一的出口 ——
173
+ // 没有裸写 stderr 的兜底分支(那是给已删除的 Live reporter 用的旧接线,见
174
+ // docs/feature/experiments/cli.md「一个 run 内只有一个终端协调者」);由当前活跃的 profile
175
+ // renderer(human/agent/ci)决定这条 detail 要不要、怎么展示。
176
+ reportAttemptLifecycle({ type: "attempt:progress", at: Date.now(), identity, detail: m });
92
177
  };
93
178
 
94
179
  return Effect.scoped(
@@ -96,27 +181,87 @@ export function runAttemptEffect(
96
181
  // run.sandbox ?? config.sandbox 是同一个 SandboxSpec 对象,既用来起沙箱 provider,
97
182
  // 也是 sandbox.setup / sandbox.teardown 钩子(SandboxSpec.setup()/.teardown() 链式挂的)的来源。
98
183
  const sandboxSpec = run.sandbox ?? config.sandbox;
184
+ // defineSandbox 自定义 provider 不参与留存(事后命令不执行用户项目代码,新进程无法安全
185
+ // 找回用户对象上的 stopDetached);组合使用在创建沙箱前报清晰错误。
186
+ if (
187
+ run.agent.kind === "sandbox" &&
188
+ opts.keepSandbox !== undefined &&
189
+ resolveSandbox(sandboxSpec).create !== undefined
190
+ ) {
191
+ throw new Error(
192
+ `--keep-sandbox is not supported with a defineSandbox custom provider ("${resolveSandbox(sandboxSpec).provider}"): the after-the-fact 'niceeval sandbox' commands never load project code, so a detached stop for user-defined sandboxes cannot be recovered safely. Use a built-in provider (docker / e2b / vercel), or drop --keep-sandbox.`,
193
+ );
194
+ }
195
+ // 留存 disposition:只在本 attempt 内可变,初始 stop;只有留存提交成功才改成 keep
196
+ // (Ctrl+C 中断外层 Scope 时仍是 stop,照常清理)。
197
+ let disposition: "stop" | "keep" = "stop";
99
198
  // 退避重试(resolve.ts → retry.ts)期间临时归还这个名额:被限流的 provider 只是在
100
199
  // setTimeout 里睡觉,不该攥着 sandboxSem 的槽位陪跑,不然一批 429 能把整体并发拖成个位数。
101
200
  const provisionSlot = {
102
201
  release: () => Effect.runPromise(sandboxSem.release(1)).then(() => {}),
103
202
  reacquire: () => Effect.runPromise(sandboxSem.take(1)).then(() => {}),
104
203
  };
204
+ // Scope release(receiver close + provider stop)整段计成 sandbox.stop:先加的 finalizer
205
+ // 后跑(LIFO),所以「先加的」在 release 链末尾打终点戳、「后加的」在 release 开始前打起点戳;
206
+ // 结果封口(附 phases)发生在 Scope release 完成之后(见下方 Effect.map)。
207
+ let releaseStartedAt = 0;
208
+ if (run.agent.kind === "sandbox") {
209
+ yield* Effect.addFinalizer(() =>
210
+ Effect.sync(() => {
211
+ // 留存路径的 phases 以 sandbox.suspend 结尾,没有 sandbox.stop 条目(见 release)。
212
+ if (releaseStartedAt > 0 && disposition !== "keep") {
213
+ recorder.record("sandbox.stop", Date.now() - releaseStartedAt);
214
+ }
215
+ }),
216
+ );
217
+ }
105
218
  const sandbox =
106
219
  run.agent.kind === "sandbox"
107
- ? yield* sandboxSem.withPermits(1)(
108
- Effect.gen(function* () {
109
- // ── 沙箱:acquire=起,release=stop(成功 / 失败 / 中断都跑)──
110
- // sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
111
- log(t("runner.startSandbox"));
112
- return yield* createSandbox({
113
- sandbox: sandboxSpec,
114
- provisionSlot,
115
- timeout: timeoutMs,
116
- runtime: "node24",
117
- });
118
- }),
119
- )
220
+ ? yield* Effect.gen(function* () {
221
+ // ── 沙箱:acquire=起,release=stop(成功 / 失败 / 中断都跑)──
222
+ // sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
223
+ enterPhase("sandbox.queue");
224
+ return yield* sandboxSem.withPermits(1)(
225
+ Effect.gen(function* () {
226
+ enterPhase("sandbox.create");
227
+ log(t("runner.startSandbox"));
228
+ return yield* createSandbox({
229
+ sandbox: sandboxSpec,
230
+ provisionSlot,
231
+ timeout: timeoutMs,
232
+ runtime: "node24",
233
+ feedback: scopedFeedback,
234
+ // Scope release 按 disposition 收尾:stop = 销毁(默认);keep = provider
235
+ // suspend(sandbox.suspend 阶段,有界计时),成功把登记项转 dormant,
236
+ // 失败保持 alive 并追加 diagnostic——不销毁、不冒充 dormant。
237
+ release: async (sb) => {
238
+ if (disposition !== "keep") {
239
+ await stopSandbox(sb);
240
+ return;
241
+ }
242
+ unregisterSandbox(sb);
243
+ const providerName = resolveSandbox(sandboxSpec).provider;
244
+ const suspendStart = Date.now();
245
+ try {
246
+ await suspendSandbox(sb);
247
+ recorder.record("sandbox.suspend", Date.now() - suspendStart);
248
+ await updateKeptEntry(niceevalRoot, keptEntryId(providerName, sb.sandboxId), {
249
+ state: "dormant",
250
+ }).catch(() => false);
251
+ } catch (e) {
252
+ recorder.record("sandbox.suspend", Date.now() - suspendStart, true);
253
+ recordDiagnostic({
254
+ code: "sandbox-suspend-failed",
255
+ level: "warning",
256
+ message: `sandbox ${sb.sandboxId} kept but suspend failed; the instance is still running: ${e instanceof Error ? e.message : String(e)}`,
257
+ dedupeKey: `sandbox-suspend-failed:${sb.sandboxId}`,
258
+ });
259
+ }
260
+ },
261
+ });
262
+ }),
263
+ );
264
+ })
120
265
  : createRemoteSandbox();
121
266
  if (run.agent.kind !== "sandbox") log(t("runner.useRemoteAgent"));
122
267
 
@@ -170,10 +315,19 @@ export function runAttemptEffect(
170
315
  }
171
316
  }
172
317
 
318
+ if (run.agent.kind === "sandbox") {
319
+ // 后加先跑:release 链开始时打起点戳(与上面的终点戳配对,测出整段 sandbox.stop)。
320
+ yield* Effect.addFinalizer(() =>
321
+ Effect.sync(() => {
322
+ releaseStartedAt = Date.now();
323
+ }),
324
+ );
325
+ }
326
+
173
327
  // body 是 Promise(adapter 边界)。Effect.promise 给的 AbortSignal 在本 fiber 被中断
174
328
  //(用户 Ctrl+C / 下面 timeoutTo 到点)时 abort —— 并进 signal,让真正观察 signal 的
175
329
  // adapter / docker 命令随中断一起停,而不只靠 Scope release 兜底。
176
- return yield* Effect.promise((interruptSignal) =>
330
+ const bodyResult = yield* Effect.promise((interruptSignal) =>
177
331
  runAttemptBody(a, config, t0, base, {
178
332
  sandbox,
179
333
  sandboxSetupHooks: sandboxSpec?.setupHooks ?? [],
@@ -183,8 +337,73 @@ export function runAttemptEffect(
183
337
  otel: otelChannel,
184
338
  signal: AbortSignal.any([signal, interruptSignal]),
185
339
  log,
340
+ enterPhase,
341
+ // send 在飞时归因到嵌套的 agent.run(不切换顶层阶段,见 LifecyclePhase 注释)。
342
+ getPhase: () => (sendActive ? "agent.run" : lastPhase),
343
+ setSendActive: (active) => {
344
+ sendActive = active;
345
+ },
346
+ recorder,
347
+ attemptEpoch: t0,
348
+ feedback: scopedFeedback,
349
+ diagnostics,
186
350
  }),
187
351
  );
352
+
353
+ // 留存提交:verdict 定稿、其余收尾(teardown 链、diff 采集)已在 body 内完成后,按档位
354
+ // 提交——failed 档留 failed/errored,all 档全部;顺序不可调换:先原子写登记项,写入成功
355
+ // 才把 disposition 改成 keep;写入失败保持 stop、记 diagnostic,`sandbox.kept` 不得为 true。
356
+ const keepMode = opts.keepSandbox;
357
+ if (
358
+ run.agent.kind === "sandbox" &&
359
+ keepMode !== undefined &&
360
+ a.locator !== undefined &&
361
+ (keepMode === "all" || bodyResult.verdict === "failed" || bodyResult.verdict === "errored")
362
+ ) {
363
+ const providerName = resolveSandbox(sandboxSpec).provider;
364
+ if (KEEPABLE_PROVIDERS.has(providerName)) {
365
+ try {
366
+ const enter = nativeEnterCommand(providerName, sandbox.sandboxId);
367
+ yield* Effect.promise(() =>
368
+ writeKeptEntry(niceevalRoot, {
369
+ sandboxId: sandbox.sandboxId,
370
+ provider: providerName,
371
+ evalId: evalDef.id,
372
+ attempt,
373
+ ...(run.experimentId !== undefined ? { experimentId: run.experimentId } : {}),
374
+ locator: String(a.locator),
375
+ verdict: bodyResult.verdict,
376
+ keptAt: new Date().toISOString(),
377
+ workdir: sandbox.workdir,
378
+ ...(enter !== undefined ? { enter } : {}),
379
+ state: "alive",
380
+ }),
381
+ );
382
+ disposition = "keep";
383
+ reportKept({
384
+ locator: a.locator,
385
+ identity,
386
+ who: runWho({ agentName: run.agent.name, model: run.model, experimentId: run.experimentId }),
387
+ verdict: bodyResult.verdict,
388
+ provider: providerName,
389
+ sandboxId: sandbox.sandboxId,
390
+ ...(enter !== undefined ? { enter } : {}),
391
+ });
392
+ return {
393
+ ...bodyResult,
394
+ sandbox: { provider: providerName, sandboxId: sandbox.sandboxId, kept: true as const },
395
+ };
396
+ } catch (e) {
397
+ recordDiagnostic({
398
+ code: "sandbox-keep-failed",
399
+ level: "warning",
400
+ message: `failed to register kept sandbox ${sandbox.sandboxId}; it will be destroyed normally: ${e instanceof Error ? e.message : String(e)}`,
401
+ dedupeKey: `sandbox-keep-failed:${sandbox.sandboxId}`,
402
+ });
403
+ }
404
+ }
405
+ }
406
+ return bodyResult;
188
407
  }),
189
408
  ).pipe(
190
409
  // ── attempt 总超时的硬边界(P1)──
@@ -194,14 +413,21 @@ export function runAttemptEffect(
194
413
  Effect.timeoutTo({
195
414
  duration: Duration.millis(timeoutMs),
196
415
  onSuccess: (r: EvalResult) => r,
197
- onTimeout: (): EvalResult => ({
198
- ...base,
199
- durationMs: Date.now() - t0,
200
- error: t("runner.timeout", {
201
- timeoutMs,
202
- recentLogs: recentLogs.map((l) => ` · ${l}`).join("\n"),
203
- }),
204
- }),
416
+ onTimeout: (): EvalResult => {
417
+ // 超时:message 是一层原因(首行),recentLogs 明细放进 stack 供 show 展开「卡在哪一步」;
418
+ // operation 取超时那一刻打开的 lifecycle operation。code 稳定为 "timeout"。
419
+ const text = t("runner.timeout", { timeoutMs, recentLogs: recentLogs.map((l) => ` · ${l}`).join("\n") });
420
+ const message = firstLine(text);
421
+ const rest = text.length > message.length ? text.slice(message.length + 1).replace(/\n+$/, "") : "";
422
+ const error: AttemptError = {
423
+ code: "timeout",
424
+ message,
425
+ phase: (sendActive ? "agent.run" : lastPhase) ?? "eval.run",
426
+ ...(rest.trim() !== "" ? { stack: rest } : {}),
427
+ };
428
+ recorder.failCurrent();
429
+ return { ...base, durationMs: Date.now() - t0, error };
430
+ },
205
431
  }),
206
432
  // body 自己已兜了 agent 执行错;这里兜的是资源获取 / Scope 层的意外(起沙箱失败等)。
207
433
  // 中断【不】吞:此时 Scope 已跑完 release(容器已停),把中断继续上抛,让 forEach 整体停掉,
@@ -209,27 +435,63 @@ export function runAttemptEffect(
209
435
  Effect.catchAllCause((cause) =>
210
436
  Cause.isInterrupted(cause)
211
437
  ? Effect.failCause(cause)
212
- : Effect.succeed({ ...base, durationMs: Date.now() - t0, error: causeToError(cause) }),
438
+ : Effect.succeed({
439
+ ...base,
440
+ durationMs: Date.now() - t0,
441
+ error: errorFromThrown(Cause.squash(cause), sendActive ? "agent.run" : lastPhase),
442
+ }),
213
443
  ),
444
+ // 结果封口在 Scope release 完成之后:sandbox.stop 已由 finalizer 写进 recorder,
445
+ // 这里把完整的阶段计时挂到即将交还的结果上(timeout / scope 兜底分支同样带上)。
446
+ Effect.map((r: EvalResult): EvalResult => {
447
+ const phases = recorder.finalize();
448
+ return phases ? { ...r, phases } : r;
449
+ }),
214
450
  );
215
451
  }
216
452
 
217
- function causeToError(cause: Cause.Cause<never>): string {
218
- return formatThrown(Cause.squash(cause));
453
+ /** 把 catch 到的 e(body 里 test()/setup 抛错,或 Scope 层 squash 出来的原始错误)折成
454
+ * `AttemptError`。message/stack/cause 由 `describeError` 拆分;phase 取失败那一刻打开的
455
+ * 生命周期阶段(极早期就挂、还没跨进任何阶段时兜底 `eval.run`——phase 是必填字段,不留空);
456
+ * code 目前只对确定已知的类别赋稳定码,其余走 `"unexpected-error"`——provider 专属的限流码
457
+ * 分类留在各 provider 的 `classifyProvisionError`,没有中性入口能在这里复算,不猜一个可能错的码。 */
458
+ function errorFromThrown(e: unknown, phase: LifecyclePhase | undefined): AttemptError {
459
+ const { message, stack, cause } = describeError(e);
460
+ return {
461
+ code: "unexpected-error",
462
+ message,
463
+ phase: phase ?? "eval.run",
464
+ ...(stack ? { stack } : {}),
465
+ ...(cause ? { cause } : {}),
466
+ };
219
467
  }
220
468
 
221
469
  interface AttemptResources {
222
470
  sandbox: Sandbox;
223
471
  /** SandboxSpec.setup() 链式挂的钩子,按追加顺序;非沙箱 agent 传空数组(usesSandbox 挡住不会跑)。 */
224
- sandboxSetupHooks: readonly AgentSetup[];
472
+ sandboxSetupHooks: readonly SandboxHook[];
225
473
  /** SandboxSpec.teardown() 链式挂的钩子,按追加顺序保存,执行时逆序。 */
226
- sandboxTeardownHooks: readonly AgentTeardown[];
474
+ sandboxTeardownHooks: readonly SandboxHook[];
227
475
  receiver?: TraceReceiver;
228
476
  telemetry?: Telemetry;
229
477
  /** 非沙箱 tracing agent 的共享 OTLP 通道(run 级池持有,不随 attempt 关)。 */
230
478
  otel?: AgentOtelChannel;
231
479
  signal: AbortSignal;
232
480
  log: (m: string) => void;
481
+ /** 进入一个正式 LifecyclePhase 边界(见 runAttemptEffect 顶部的定义)。 */
482
+ enterPhase: (phase: LifecyclePhase) => void;
483
+ /** 读当前最近跨入的 phase(send 在飞时返回嵌套的 `agent.run`):error/diagnostic 归因用。 */
484
+ getPhase: () => LifecyclePhase | undefined;
485
+ /** SessionManager 的 send 在飞通知落点(agent.run 归因)。 */
486
+ setSendActive: (active: boolean) => void;
487
+ /** 阶段计时 recorder(turn/command 时间树挂载点)。 */
488
+ recorder: TimingRecorder;
489
+ /** attempt 墙钟起点(turn 节点的 startOffsetMs 基准)。 */
490
+ attemptEpoch: number;
491
+ /** 作用域反馈句柄(归因随 runner 当前阶段);各生命周期入口共享同一实现。 */
492
+ feedback: ScopedFeedback;
493
+ /** attempt 级诊断累计(runAttemptEffect 持有,含 sandbox.create 期间的诊断)。 */
494
+ diagnostics: DiagnosticRecord[];
233
495
  }
234
496
 
235
497
  // attempt 的固定段(上传→基线→setup→驱动 agent→采 diff→脚本→评分→判定)。
@@ -242,8 +504,29 @@ async function runAttemptBody(
242
504
  res: AttemptResources,
243
505
  ): Promise<EvalResult> {
244
506
  const { evalDef, run, attempt } = a;
245
- const { sandbox, sandboxSetupHooks, sandboxTeardownHooks, receiver, telemetry, otel, signal, log } = res;
507
+ const {
508
+ sandbox: rawSandbox,
509
+ sandboxSetupHooks,
510
+ sandboxTeardownHooks,
511
+ receiver,
512
+ telemetry,
513
+ otel,
514
+ signal,
515
+ log,
516
+ enterPhase,
517
+ getPhase,
518
+ setSendActive,
519
+ recorder,
520
+ attemptEpoch,
521
+ feedback,
522
+ diagnostics,
523
+ } = res;
246
524
  const usesSandbox = run.agent.kind === "sandbox";
525
+ // 命令时间树:所有经这个包装 sandbox 发出的 runCommand/runShell 都挂成当前阶段(或当前 hook
526
+ // 节点)下的 command 子节点。包装只在最外层公开调用记录一次——provider 内部转调不经过它。
527
+ const sandbox = usesSandbox ? withCommandTiming(rawSandbox, recorder) : rawSandbox;
528
+ // 在两个 return 前赋值,好让 finally 把 diagnostics 挂到即将返回的同一个对象上(见 finally 末尾)。
529
+ let result: EvalResult | undefined;
247
530
  // 整个 attempt 共用一份 agent ctx(sandbox 钩子 / agent setup / tracing configure / teardown 都用它)。
248
531
  const attemptCtx: AgentContext = {
249
532
  signal,
@@ -254,14 +537,27 @@ async function runAttemptBody(
254
537
  sandbox,
255
538
  session: createAgentSession(),
256
539
  telemetry,
540
+ progress: feedback.progress,
541
+ diagnostic: feedback.diagnostic,
542
+ // log 是 progress({ message }) 的别名,不是第二条通道(见 AgentContext.log 注释)。
257
543
  log,
258
544
  };
545
+ // Sandbox hook / eval.setup 的窄上下文:experimentId + signal + 作用域反馈,不借用完整 AgentContext
546
+ // (hook 拿不到 session / model / telemetry,见 docs/feature/sandbox/library.md)。
547
+ const hookCtx: SandboxHookContext = {
548
+ experimentId: run.experimentId,
549
+ signal,
550
+ progress: feedback.progress,
551
+ diagnostic: feedback.diagnostic,
552
+ };
259
553
  let agentCleanup: Cleanup | void = undefined;
260
554
  let agentDidSetup = false;
261
555
  /** agent.setup 写进沙箱的安装清单(装了 Skill / plugin / MCP 的沙箱型 adapter 才有)。 */
262
556
  let agentSetup: AgentSetupManifest | undefined;
263
557
  // EvalDef.setup() 返回的 cleanup 闭包;finally 里按 LIFO 跑(见下)。
264
558
  let evalCleanup: Cleanup | void = undefined;
559
+ // 变更分类账(仅沙箱型;workspace.baseline 阶段建立)。
560
+ let ledger: ChangeLedger | undefined;
265
561
  // SandboxSpec.setup() 返回的 cleanup 闭包,按调用顺序收集;finally 里 LIFO 跑(见下)。
266
562
  const sandboxCleanups: Cleanup[] = [];
267
563
  try {
@@ -270,24 +566,50 @@ async function runAttemptBody(
270
566
  // eval.setup 跑——改动进 git 基线,不会被误算进 agent 产出的 diff。按追加顺序依次执行;
271
567
  // 单个抛错走下面的执行错误路径(与 eval.setup / agent.setup 同一条),已跑过的 cleanup /
272
568
  // sandbox.teardown 钩子仍在 finally 里跑(见 catch/finally)。
273
- if (sandboxSetupHooks.length > 0) log(t("runner.startSandboxSetup"));
274
- for (const hook of sandboxSetupHooks) {
275
- const cleanup = await hook(sandbox, attemptCtx);
276
- if (typeof cleanup === "function") sandboxCleanups.push(cleanup);
569
+ if (sandboxSetupHooks.length > 0) {
570
+ enterPhase("sandbox.setup");
571
+ log(t("runner.startSandboxSetup"));
572
+ }
573
+ for (const [i, hook] of sandboxSetupHooks.entries()) {
574
+ // hook 先建节点,hook 内经 Sandbox.runCommand/runShell 发出的命令挂成它的 command 子节点。
575
+ const hookStart = Date.now();
576
+ const hookNode = recorder.child({
577
+ kind: "hook",
578
+ label: `setup#${i}`,
579
+ startOffsetMs: Math.max(0, hookStart - attemptEpoch),
580
+ durationMs: 0,
581
+ });
582
+ if (hookNode) recorder.pushParent(hookNode);
583
+ try {
584
+ const cleanup = await hook(sandbox, hookCtx);
585
+ if (typeof cleanup === "function") sandboxCleanups.push(cleanup);
586
+ } catch (e) {
587
+ if (hookNode) hookNode.failed = true;
588
+ throw e;
589
+ } finally {
590
+ if (hookNode) {
591
+ hookNode.durationMs = Date.now() - hookStart;
592
+ recorder.popParent();
593
+ }
594
+ }
277
595
  }
278
596
 
279
- await initGitAndCommit(sandbox);
597
+ // 变更分类账锚点:私有 git ledger(git 目录在 workdir 外),排除清单在此冻结。
598
+ enterPhase("workspace.baseline");
599
+ ledger = await createChangeLedger(sandbox, evalDef.diff);
280
600
 
281
601
  // eval 级 setup(starter prep:npm install / 装系统依赖等)。命令默认非 root;
282
602
  // setup 里需要 root 的(apt/pip)自己传 { root: true }。
283
603
  if (evalDef.setup) {
604
+ enterPhase("eval.setup");
284
605
  log(t("runner.evalSetup"));
285
- evalCleanup = await evalDef.setup(withEvalLocalPaths(sandbox, evalDef.baseDir));
606
+ evalCleanup = await evalDef.setup(withEvalLocalPaths(sandbox, evalDef.baseDir), hookCtx);
286
607
  }
287
608
  }
288
609
 
289
610
  // agent 自己的 lifecycle:装 CLI、写 config(每个沙箱一次,不在每轮 send 里)。
290
611
  if (run.agent.setup) {
612
+ enterPhase("agent.setup");
291
613
  log(t("runner.startAgentSetup"));
292
614
  agentDidSetup = true;
293
615
  agentCleanup = await run.agent.setup(sandbox, attemptCtx);
@@ -301,11 +623,13 @@ async function runAttemptBody(
301
623
  // OTLP 导出配置(file-based,如 codex 的 config.toml [otel] 块):与 setup 分开,
302
624
  // 在主配置写完后追加。仅当 tracing 开 + 有 endpoint 时调一次(env-based 的不实现 configure)。
303
625
  if (telemetry && run.agent.tracing?.configure) {
626
+ enterPhase("telemetry.configure");
304
627
  log(t("runner.startAgentTracing"));
305
628
  await run.agent.tracing.configure(sandbox, attemptCtx);
306
629
  }
307
630
 
308
631
  // 构造 t,跑 test
632
+ enterPhase("eval.run");
309
633
  log(t("runner.driveAgent"));
310
634
  const judge = resolveJudge(evalDef.judge, config.judge);
311
635
  const { context, state } = createEvalContext({
@@ -321,9 +645,32 @@ async function runAttemptBody(
321
645
  telemetry,
322
646
  otel,
323
647
  evalBaseDir: evalDef.baseDir,
648
+ feedback,
649
+ // send 窗口钩子:进入前落 eval 归因、返回后落 agent 归因(见 ledger.ts)。
650
+ ledgerHooks: ledger
651
+ ? {
652
+ beforeSend: (label) => ledger!.commitEvalWindow(label),
653
+ afterSend: (label) => ledger!.commitAgentWindow(label),
654
+ }
655
+ : undefined,
656
+ onSendActive: setSendActive,
657
+ // 每次 send 一个 turn 节点:本地单调时钟测得的端到端包络 + session/turn 身份;
658
+ // OTel 接入时再带 traceId,trace.json 的 spans 由消费方按它临时挂到 turn 下。
659
+ onTurn: (info) =>
660
+ recorder.child({
661
+ kind: "turn",
662
+ label: `s${info.sessionIndex}/t${info.turnIndex}`,
663
+ startOffsetMs: Math.max(0, info.startedAt - attemptEpoch),
664
+ durationMs: info.durationMs,
665
+ ...(info.failed ? { failed: true as const } : {}),
666
+ sessionIndex: info.sessionIndex,
667
+ turnIndex: info.turnIndex,
668
+ ...(info.traceId !== undefined ? { traceId: info.traceId } : {}),
669
+ ...(info.traceAttribution !== undefined ? { traceAttribution: info.traceAttribution } : {}),
670
+ }),
324
671
  });
325
672
 
326
- let error: string | undefined;
673
+ let error: AttemptError | undefined;
327
674
  let skipReason: string | undefined;
328
675
  try {
329
676
  await evalDef.test(context);
@@ -332,26 +679,53 @@ async function runAttemptBody(
332
679
  else if (e instanceof EvalRequirementFailed) {
333
680
  /* 断言已记录,非执行错误 */
334
681
  } else if (e instanceof TurnFailed) {
335
- error = e.message;
682
+ // TurnFailed 是 eval 驱动 agent 时的一层可读失败(message 已是一句话);稳定 code
683
+ // `turn-failed`,不带控制流 stack(那指向 control-flow.ts,对定位无益)。
684
+ error = { code: "turn-failed", message: e.message, phase: getPhase() ?? "eval.run" };
336
685
  } else {
337
- // 带 stack——eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError 只有
338
- // "name: message" 完全定位不到是哪一行,报告里必须能看见 eval 文件的 file:line。
339
- error = formatThrown(e);
686
+ // eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError:message 是一层原因,完整 stack
687
+ // 单独进 `error.stack`,niceeval show 展开时才看得到 eval 文件的 file:line。
688
+ error = errorFromThrown(e, getPhase());
340
689
  }
341
690
  }
342
691
 
343
692
  if (skipReason) log(t("runner.skip", { reason: skipReason }));
344
693
 
345
- // 采 diff(脚本如 next build 在采集后才跑,避免 .next 污染 diff)。remote agent 没有 workspace。
346
- const diff =
347
- skipReason || !usesSandbox
348
- ? { generatedFiles: {}, deletedFiles: [] }
349
- : await captureGeneratedFiles(sandbox);
694
+ // 采 agent 归因增量(workspace.diff 阶段:从分类账折叠逐窗口 delta)。remote agent 没有 workspace。
695
+ if (!skipReason && usesSandbox) enterPhase("workspace.diff");
696
+ let diffWindows: DiffArtifact = [];
697
+ if (!skipReason && usesSandbox && ledger) {
698
+ const startedAt = Date.now();
699
+ const operation = recorder.child({
700
+ kind: "operation",
701
+ label: "export workspace diff",
702
+ startOffsetMs: Math.max(0, startedAt - attemptEpoch),
703
+ durationMs: 0,
704
+ });
705
+ if (operation) recorder.pushParent(operation);
706
+ try {
707
+ diffWindows = await ledger.exportWindows();
708
+ if (operation) {
709
+ const files = new Set(diffWindows.flatMap((window) => Object.keys(window.changes))).size;
710
+ operation.label = `export workspace diff · ${diffWindows.length} ${diffWindows.length === 1 ? "window" : "windows"} · ${files} ${files === 1 ? "file" : "files"}`;
711
+ }
712
+ } catch (error) {
713
+ if (operation) operation.failed = true;
714
+ throw error;
715
+ } finally {
716
+ if (operation) {
717
+ operation.durationMs = Date.now() - startedAt;
718
+ recorder.popParent();
719
+ }
720
+ }
721
+ }
722
+ const diff = deriveDiffData(diffWindows);
350
723
  state.late.diff = diff;
351
724
  if (!skipReason && usesSandbox) {
725
+ const files = Object.values(diff.files);
352
726
  log(t("runner.diffProgress", {
353
- changed: Object.keys(diff.generatedFiles).length,
354
- deleted: diff.deletedFiles.length,
727
+ changed: files.filter((f) => f.net !== "deleted").length,
728
+ deleted: files.filter((f) => f.net === "deleted").length,
355
729
  }));
356
730
  }
357
731
 
@@ -369,6 +743,9 @@ async function runAttemptBody(
369
743
  scripts,
370
744
  usage,
371
745
  status: state.manager.lastStatus,
746
+ // attempt 级聚合覆盖(各轮最差值);t.* 作用域断言按它折叠,turn/session 作用域在
747
+ // record 时已换成各自的覆盖(见 context.ts 的 recordScoped / makeTurnHandle)。
748
+ coverage: state.manager.coverage,
372
749
  readFile: async (path) => {
373
750
  try {
374
751
  return await sandbox!.readFile(path);
@@ -377,7 +754,10 @@ async function runAttemptBody(
377
754
  }
378
755
  },
379
756
  };
380
- if (!skipReason) log(t("runner.scoreJudge"));
757
+ if (!skipReason) {
758
+ enterPhase("scoring.evaluate");
759
+ log(t("runner.scoreJudge"));
760
+ }
381
761
  const assertions = skipReason ? [] : await state.collector.finalize(scoringContext);
382
762
  const verdict = computeVerdict({ error, assertions, skipReason, strict: run.strict });
383
763
 
@@ -387,6 +767,7 @@ async function runAttemptBody(
387
767
  // 再 selectTraceSpans 按 kind 挑出回合/模型/工具,丢掉 "other" 噪声(干净小 trace 整段保留)。
388
768
  let trace: TraceSpan[] | undefined;
389
769
  if (receiver) {
770
+ enterPhase("telemetry.collect");
390
771
  await receiver.settle(250, 1500);
391
772
  const spans = receiver.collect();
392
773
  if (spans.length) {
@@ -400,6 +781,7 @@ async function runAttemptBody(
400
781
  } else if (otel) {
401
782
  // 共享通道:receiver 不归本 attempt 关,trace 只取归属到本 attempt 的 span
402
783
  //(逐轮攒的 + 按本 attempt traceId sweep 回的迟到批)。
784
+ enterPhase("telemetry.collect");
403
785
  const late = await otel.sweep(state.manager.otelTraceIds);
404
786
  const spans = [...state.manager.otelSpans, ...late];
405
787
  if (spans.length) {
@@ -410,6 +792,10 @@ async function runAttemptBody(
410
792
  }
411
793
  }
412
794
 
795
+ // 主链到 telemetry.collect 为止。必须在 Effect Scope release 之前显式封口;否则最后一个
796
+ // 主链 phase 会一直开到 sandbox.stop 完成,既把收尾时间重复算进主链,也会让 phases
797
+ // 主链合计大于 durationMs。Scope finalizer 只负责另记 sandbox.stop / sandbox.suspend。
798
+ recorder.closeCurrent();
413
799
  const durationMs = Date.now() - t0;
414
800
  const o11y = buildO11ySummary(events, usage, durationMs);
415
801
  // 实测成本(网关带回)优先,缺则按 model + 用量查价格表估算(见 o11y/cost.ts)。
@@ -419,7 +805,7 @@ async function runAttemptBody(
419
805
  // 收 test 引用到的 eval 源码(按 send / 断言的 loc 去重),供 view 渲染代码视图。
420
806
  const sources = await collectSources(events, assertions, evalDef.source);
421
807
 
422
- return {
808
+ const value: EvalResult = {
423
809
  id: evalDef.id,
424
810
  description: evalDef.description,
425
811
  experimentId: run.experimentId,
@@ -441,56 +827,163 @@ async function runAttemptBody(
441
827
  o11y,
442
828
  trace,
443
829
  agentSetup,
444
- diff,
830
+ diff: diffWindows,
831
+ coverage: state.manager.coverage,
832
+ ...(usesSandbox
833
+ ? {
834
+ sandbox: {
835
+ provider: resolveSandbox(run.sandbox ?? config.sandbox).provider,
836
+ sandboxId: sandbox.sandboxId,
837
+ },
838
+ }
839
+ : {}),
445
840
  };
841
+ result = value;
842
+ return value;
446
843
  } catch (e) {
447
- return {
844
+ recorder.failCurrent();
845
+ const value: EvalResult = {
448
846
  ...base,
449
847
  durationMs: Date.now() - t0,
450
- error: formatThrown(e),
848
+ error: errorFromThrown(e, getPhase()),
451
849
  ...(agentSetup !== undefined ? { agentSetup } : {}),
452
850
  };
851
+ result = value;
852
+ return value;
453
853
  } finally {
454
- // teardown / cleanup 一律在 finally 跑(失败也跑),不改判定,各自兜错(diagnostic)
455
- // LIFO:agent 级(setup 最晚 cleanup / teardown 先跑)在前,sandbox 级(最早就绪)收尾在后
456
- //(即 sandbox.setup 返回的 cleanup、sandbox.teardown 钩子最后跑,沙箱销毁前)
457
- // 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收。
458
- try {
459
- if (typeof agentCleanup === "function") await agentCleanup();
460
- if (agentDidSetup) await run.agent.teardown?.(sandbox, attemptCtx);
461
- } catch {
462
- // teardown 失败只是 diagnostic,不影响已出的结果
463
- }
464
- try {
465
- // eval.setup 返回的 cleanup:排在 agent 级之后、sandbox 级之前(LIFO,与 setup 顺序对称)
466
- if (typeof evalCleanup === "function") await evalCleanup();
467
- } catch {
468
- // 同上,只作 diagnostic
854
+ // 收尾段一律在 finally 跑(主链成败都执行),不改判定,各自兜错(diagnostic)、各自计时
855
+ // (不计入 durationMs 口径,见 docs/feature/results/architecture.md)。执行序与 LifecyclePhase
856
+ // 闭集声明一致:eval.teardown agent.teardown → sandbox.teardown;各段可独立标 failed
857
+ // 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收,
858
+ // 并经 finalizer 计成 sandbox.stop。没有对应 cleanup 的段直接跳过,不产生空阶段。
859
+ const evalCleanupFn = typeof evalCleanup === "function" ? evalCleanup : undefined;
860
+ if (evalCleanupFn) {
861
+ enterPhase("eval.teardown");
862
+ await recorder
863
+ .measureClosing("eval.teardown", async () => {
864
+ try {
865
+ await evalCleanupFn();
866
+ } catch (e) {
867
+ // 收尾失败只是 diagnostic,不改判定 —— 挂到 attempt.diagnostics(见 finally 末尾并入)。
868
+ diagnostics.push(teardownDiagnostic("eval.teardown", e));
869
+ throw e; // 让 measureClosing 把这段标 failed
870
+ }
871
+ })
872
+ .catch(() => {});
469
873
  }
470
- if (usesSandbox) {
471
- // sandbox.setup 返回的 cleanup:LIFO( setup cleanup),与 agent 级同构。
472
- for (let i = sandboxCleanups.length - 1; i >= 0; i--) {
473
- try {
474
- await sandboxCleanups[i]();
475
- } catch {
476
- // 同上,只作 diagnostic
477
- }
478
- }
479
- // sandbox.teardown 钩子:按追加的逆序执行,沙箱销毁前最后一步。
480
- if (sandboxTeardownHooks.length > 0) {
481
- log(t("runner.startSandboxTeardown"));
482
- for (let i = sandboxTeardownHooks.length - 1; i >= 0; i--) {
874
+ const agentCleanupFn = typeof agentCleanup === "function" ? agentCleanup : undefined;
875
+ if (agentCleanupFn !== undefined || (agentDidSetup && run.agent.teardown !== undefined)) {
876
+ enterPhase("agent.teardown");
877
+ await recorder
878
+ .measureClosing("agent.teardown", async () => {
483
879
  try {
484
- await sandboxTeardownHooks[i](sandbox, attemptCtx);
485
- } catch {
486
- // 同上,只作 diagnostic
880
+ if (agentCleanupFn) await agentCleanupFn();
881
+ if (agentDidSetup) await run.agent.teardown?.(sandbox, attemptCtx);
882
+ } catch (e) {
883
+ diagnostics.push(teardownDiagnostic("agent.teardown", e));
884
+ throw e;
487
885
  }
488
- }
489
- }
886
+ })
887
+ .catch(() => {});
888
+ }
889
+ if (usesSandbox && (sandboxCleanups.length > 0 || sandboxTeardownHooks.length > 0)) {
890
+ enterPhase("sandbox.teardown");
891
+ await recorder
892
+ .measureClosing("sandbox.teardown", async () => {
893
+ const before = diagnostics.length;
894
+ // sandbox.setup 返回的 cleanup:LIFO(后 setup 先 cleanup)。
895
+ for (let i = sandboxCleanups.length - 1; i >= 0; i--) {
896
+ try {
897
+ await sandboxCleanups[i]();
898
+ } catch (e) {
899
+ diagnostics.push(teardownDiagnostic("sandbox.teardown", e));
900
+ }
901
+ }
902
+ // sandbox.teardown 钩子:按追加的逆序执行,沙箱销毁前最后一步。
903
+ if (sandboxTeardownHooks.length > 0) {
904
+ log(t("runner.startSandboxTeardown"));
905
+ for (let i = sandboxTeardownHooks.length - 1; i >= 0; i--) {
906
+ try {
907
+ const teardownCleanup = await sandboxTeardownHooks[i](sandbox, hookCtx);
908
+ // SandboxHook 类型允许返回 Cleanup(与 setup 同一签名);teardown 返回的
909
+ // cleanup 没有更晚的挂点,立即执行。
910
+ if (typeof teardownCleanup === "function") await teardownCleanup();
911
+ } catch (e) {
912
+ diagnostics.push(teardownDiagnostic("sandbox.teardown", e));
913
+ }
914
+ }
915
+ }
916
+ if (diagnostics.length > before) throw new Error("sandbox teardown diagnostics");
917
+ })
918
+ .catch(() => {});
490
919
  }
920
+ // finally 在两个 return 求值之后、函数真正交还返回值之前运行;`result` 已经是那个即将被返回的
921
+ // 对象引用,这里往它上面挂 diagnostics,调用方拿到的就是带诊断的同一个对象(标准 try/finally
922
+ // 变异语义)。result 恒已赋值(两个 return 分支都先赋值再 return);极端情况下(finally 之前就
923
+ // 抛了、result 还没赋值)静默跳过,不掩盖原始异常。
924
+ if (diagnostics.length > 0 && result) result.diagnostics = diagnostics;
491
925
  }
492
926
  }
493
927
 
928
+ /** 把一次 teardown / cleanup 失败折成一条 `DiagnosticRecord`(warning,不改判定)。message 取一层
929
+ * 摘要(`firstLine(formatThrown)`),完整 stack 不塞进单 attempt 诊断 —— 诊断是「顺带发生的清理
930
+ * 问题」,不是 attempt 的主因(主因在 verdict / error)。稳定 code `teardown-failed`。 */
931
+ function teardownDiagnostic(phase: LifecyclePhase, e: unknown): DiagnosticRecord {
932
+ return {
933
+ code: "teardown-failed",
934
+ level: "warning",
935
+ message: firstLine(formatThrown(e)),
936
+ phase,
937
+ };
938
+ }
939
+
940
+ /**
941
+ * 命令时间树包装:runCommand / runShell 的最外层公开调用各记一个 command 子节点
942
+ * (有界脱敏摘要 + exitCode;env 值与 stdout/stderr 不进入时间树)。Proxy 只拦这两个方法,
943
+ * provider 内部 `this.runCommand(...)` 转调不经过它——不形成重复节点。
944
+ */
945
+ function withCommandTiming(sandbox: Sandbox, recorder: TimingRecorder): Sandbox {
946
+ const wrap = async <T>(display: string, fn: () => Promise<T>): Promise<T> => {
947
+ const startOffsetMs = recorder.offsetNow();
948
+ const t0 = Date.now();
949
+ try {
950
+ const result = await fn();
951
+ const exitCode = (result as { exitCode?: unknown })?.exitCode;
952
+ recorder.child(
953
+ commandNode({
954
+ display,
955
+ startOffsetMs,
956
+ durationMs: Date.now() - t0,
957
+ ...(typeof exitCode === "number" ? { exitCode, failed: exitCode !== 0 } : {}),
958
+ }),
959
+ );
960
+ // CommandResult.command:最外层公开调用恰好是「eval 实际跑了什么」的定义点,摘要
961
+ // 与时间树节点同一份;provider 自己填过就不覆盖。
962
+ if (result !== null && typeof result === "object" && !("command" in result)) {
963
+ return { ...result, command: display } as T;
964
+ }
965
+ return result;
966
+ } catch (e) {
967
+ recorder.child(commandNode({ display, startOffsetMs, durationMs: Date.now() - t0, failed: true }));
968
+ throw e;
969
+ }
970
+ };
971
+ return new Proxy(sandbox, {
972
+ get(target, prop, receiver) {
973
+ if (prop === "runCommand") {
974
+ return (cmd: string, args?: string[], opts?: unknown) =>
975
+ wrap(commandDisplay(cmd, args), () => (target.runCommand as (...a: unknown[]) => Promise<unknown>)(cmd, args, opts));
976
+ }
977
+ if (prop === "runShell") {
978
+ return (script: string, opts?: unknown) =>
979
+ wrap(commandDisplay(script), () => (target.runShell as (...a: unknown[]) => Promise<unknown>)(script, opts));
980
+ }
981
+ const value = Reflect.get(target, prop, receiver);
982
+ return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value;
983
+ },
984
+ });
985
+ }
986
+
494
987
  /**
495
988
  * 收集 test 引用到的 eval 源码:从 send(user message)与断言的 loc 去重出文件集。
496
989
  * 命中 eval 自己的定义文件(绝大多数情况——send / 断言几乎总在 eval 主体里直接调用)时,
@@ -521,15 +1014,21 @@ async function collectSources(
521
1014
  return out;
522
1015
  }
523
1016
 
1017
+ /** 解析后运行配置的穷尽投影(ExperimentRunInfo,见 docs/feature/results/architecture.md):
1018
+ * agent/model 只在快照顶层,这里不复制;sandbox 只经 provider 的公开参数投影落盘。 */
524
1019
  function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
525
1020
  return {
526
- id: run.experimentId,
527
- flags: run.flags,
1021
+ ...(run.description !== undefined ? { description: run.description } : {}),
1022
+ ...(run.reasoningEffort !== undefined ? { reasoningEffort: run.reasoningEffort } : {}),
1023
+ ...(Object.keys(run.flags).length > 0 ? { flags: run.flags } : {}),
528
1024
  runs: run.runs,
529
1025
  earlyExit: run.earlyExit,
530
- sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
531
- timeoutMs: run.timeoutMs,
532
- budget: run.budget,
1026
+ ...(run.timeoutMs !== undefined ? { timeoutMs: run.timeoutMs } : {}),
1027
+ ...(run.budget !== undefined ? { budget: run.budget } : {}),
1028
+ ...(run.maxConcurrency !== undefined ? { maxConcurrency: run.maxConcurrency } : {}),
1029
+ selectedEvalIds: run.selectedEvalIds ?? [],
1030
+ ...(run.evalFilterFingerprint !== undefined ? { evalFilterFingerprint: run.evalFilterFingerprint } : {}),
1031
+ ...(run.sandbox !== undefined ? { sandbox: sandboxRunInfo(run.sandbox) } : {}),
533
1032
  };
534
1033
  }
535
1034