humanish 0.110.0 → 0.110.1

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 (132) hide show
  1. package/ARCHITECTURE.md +14 -11
  2. package/CHANGELOG.md +13 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +96 -47
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +11 -0
  11. package/dist/actors/local-agent/cli.js +103 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +18 -11
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/run-signals.d.ts +2 -0
  16. package/dist/cli/commands/run-signals.js +96 -44
  17. package/dist/cli/commands/runs.d.ts +5 -0
  18. package/dist/cli/commands/runs.js +86 -70
  19. package/dist/cli/commands/study-route-computer-use.js +2 -0
  20. package/dist/cli/commands/study-route-scripted.js +2 -0
  21. package/dist/cli/commands/study-route-shared-world.js +2 -0
  22. package/dist/cli/commands/study-route-terminal.js +2 -0
  23. package/dist/cli/doctor.d.ts +4 -1
  24. package/dist/cli/doctor.js +42 -4
  25. package/dist/cli/findings.d.ts +103 -0
  26. package/dist/cli/findings.js +316 -0
  27. package/dist/cli/first-run-path.d.ts +5 -4
  28. package/dist/cli/first-run-path.js +14 -7
  29. package/dist/cli/io.js +2 -1
  30. package/dist/cli/product-sentence.d.ts +3 -3
  31. package/dist/cli/product-sentence.js +3 -3
  32. package/dist/keys/env-file.d.ts +4 -0
  33. package/dist/keys/env-file.js +17 -0
  34. package/dist/observer/data.d.ts +14 -1
  35. package/dist/observer/data.js +19 -9
  36. package/dist/observer/library.d.ts +4 -0
  37. package/dist/observer/library.js +18 -15
  38. package/dist/observer/render.js +8 -2
  39. package/dist/observer/run-routes.d.ts +2 -0
  40. package/dist/observer/run-routes.js +43 -34
  41. package/dist/observer-app.html +3 -3
  42. package/dist/routes/computer-use/bundle.js +3 -1
  43. package/dist/routes/computer-use/fanout-records.js +1 -1
  44. package/dist/routes/computer-use/participant-execution.js +3 -1
  45. package/dist/routes/computer-use/participant-model.js +5 -2
  46. package/dist/routes/computer-use/plan.js +4 -4
  47. package/dist/routes/computer-use/preflight.js +1 -1
  48. package/dist/routes/computer-use/result.js +17 -28
  49. package/dist/routes/computer-use/single-bundle.js +7 -1
  50. package/dist/routes/scripted/bundle.js +4 -2
  51. package/dist/routes/scripted/plan.js +2 -2
  52. package/dist/routes/scripted/result.d.ts +3 -5
  53. package/dist/routes/scripted/result.js +16 -22
  54. package/dist/routes/scripted/scenario.js +4 -4
  55. package/dist/routes/shared-world/bundle-records.js +6 -1
  56. package/dist/routes/shared-world/bundle.js +2 -0
  57. package/dist/routes/shared-world/plan.js +1 -1
  58. package/dist/routes/shared-world/result.js +22 -22
  59. package/dist/routes/terminal/bundle.js +4 -2
  60. package/dist/routes/terminal/dry-run.js +8 -12
  61. package/dist/routes/terminal/ledger.d.ts +3 -3
  62. package/dist/routes/terminal/ledger.js +6 -6
  63. package/dist/routes/terminal/live-finish.js +15 -13
  64. package/dist/routes/terminal/plan.js +4 -4
  65. package/dist/routes/terminal/result.d.ts +6 -7
  66. package/dist/routes/terminal/result.js +6 -22
  67. package/dist/routes/terminal/session.js +1 -1
  68. package/dist/run/bundle-shape.d.ts +3 -1
  69. package/dist/run/bundle-shape.js +25 -1
  70. package/dist/run/bundle.d.ts +24 -0
  71. package/dist/run/display.d.ts +59 -0
  72. package/dist/run/display.js +106 -0
  73. package/dist/run/dry-run.js +11 -7
  74. package/dist/run/judge.d.ts +3 -2
  75. package/dist/run/judge.js +10 -0
  76. package/dist/run/pricing.js +1 -1
  77. package/dist/run/projection.d.ts +0 -2
  78. package/dist/run/projection.js +0 -11
  79. package/dist/run/reclaim-outcomes.d.ts +89 -0
  80. package/dist/run/reclaim-outcomes.js +213 -0
  81. package/dist/run/reclaim.d.ts +18 -17
  82. package/dist/run/reclaim.js +180 -113
  83. package/dist/run/results.d.ts +10 -20
  84. package/dist/run/results.js +2 -2
  85. package/dist/run/run-index.d.ts +11 -1
  86. package/dist/run/run-index.js +11 -0
  87. package/dist/run/run.d.ts +34 -18
  88. package/dist/run/run.js +166 -67
  89. package/dist/run/sandbox-creates.d.ts +59 -0
  90. package/dist/run/sandbox-creates.js +138 -0
  91. package/dist/run/sandbox-receipts.d.ts +14 -0
  92. package/dist/run/sandbox-receipts.js +44 -0
  93. package/dist/run/stats.d.ts +5 -0
  94. package/dist/run/stats.js +8 -4
  95. package/dist/run/status.d.ts +6 -9
  96. package/dist/run/status.js +7 -23
  97. package/dist/run/stored-runs.d.ts +5 -14
  98. package/dist/run/stored-runs.js +31 -146
  99. package/dist/run/streams.d.ts +3 -2
  100. package/dist/study/doctor.d.ts +1 -1
  101. package/dist/study/doctor.js +5 -2
  102. package/dist/study/init.js +14 -3
  103. package/dist/study/keys.d.ts +12 -0
  104. package/dist/study/keys.js +32 -0
  105. package/dist/study/persona.d.ts +4 -0
  106. package/dist/study/persona.js +4 -2
  107. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  108. package/dist/substrates/e2b/peer-install.js +161 -0
  109. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  110. package/dist/substrates/e2b/sandbox.js +128 -35
  111. package/dist/substrates/e2b/sdk.d.ts +33 -0
  112. package/dist/substrates/e2b/sdk.js +21 -21
  113. package/dist/substrates/shell.d.ts +2 -0
  114. package/dist/substrates/shell.js +4 -0
  115. package/dist/tui-app.js +133 -133
  116. package/dist/verify/liveness.d.ts +19 -3
  117. package/dist/verify/liveness.js +46 -25
  118. package/dist/verify/verify.d.ts +3 -0
  119. package/dist/verify/verify.js +6 -2
  120. package/docs/architecture/actor-contract.md +2 -1
  121. package/docs/architecture/observer.md +22 -7
  122. package/docs/architecture/task-protocol-support.md +13 -13
  123. package/docs/architecture/terminal-product-route.md +1 -1
  124. package/docs/contracts/run-bundle.md +75 -6
  125. package/docs/contracts/schemas.md +39 -22
  126. package/docs/contracts/study-analysis.md +45 -0
  127. package/docs/contracts/study-costs.md +8 -0
  128. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  129. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  130. package/docs/principles/invariants-and-defaults.md +11 -8
  131. package/package.json +4 -3
  132. package/skills/humanish/SKILL.md +16 -2
package/dist/run/run.js CHANGED
@@ -2,15 +2,17 @@
2
2
  // token that lets the Observer and automatic analysis read it. Routes build bundles and write
3
3
  // their own evidence files; this module owns when the run starts, how it is published, and how it
4
4
  // is closed on every exit.
5
+ import { redactText } from "../evidence/redaction.js";
5
6
  import { buildObserverData } from "../observer/data.js";
6
7
  import { renderObserver } from "../observer/render.js";
7
8
  import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD } from "./bundle.js";
9
+ import { judgeExecution, } from "./judge.js";
8
10
  import {} from "./results.js";
9
11
  import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
10
12
  import { registerActiveRun } from "./active-runs.js";
11
13
  import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./contained-output.js";
12
14
  import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
13
- import { beginRunStatus, runStatusOutcome } from "./status.js";
15
+ import { beginRunStatus, runStatusOutcome, } from "./status.js";
14
16
  /** An entry point called in a state that does not admit it. */
15
17
  class RunLifecycleError extends Error {
16
18
  name = "RunLifecycleError";
@@ -24,38 +26,66 @@ const issueKey = Symbol("FinishedRun");
24
26
  */
25
27
  export class FinishedRun {
26
28
  #observer;
27
- #status;
29
+ #recordFailure;
30
+ #outcome;
28
31
  runId;
29
32
  /** The paths created by startRun and validated at the start of `finish`. */
30
33
  paths;
31
- constructor(key, runId, paths, observer, status) {
34
+ constructor(key, runId, paths, observer, outcome, recordFailure) {
32
35
  if (key !== issueKey)
33
36
  throw new Error("Only Run.finish issues a FinishedRun.");
34
37
  this.runId = runId;
35
38
  this.paths = paths;
36
39
  this.#observer = observer;
37
- this.#status = status;
40
+ this.#outcome = outcome;
41
+ this.#recordFailure = recordFailure;
38
42
  }
39
43
  static isIssued(value) {
40
44
  return typeof value === "object" && value !== null && #observer in value;
41
45
  }
42
- /** Render the Observer for exactly these paths, never a directory re-resolved by name. */
43
- renderObserver() {
44
- const target = this.#observer;
45
- if (target === undefined) {
46
- return Promise.reject(new RunLifecycleError("This run was started without an Observer."));
47
- }
48
- return target.render(target.cwd, this.runId, { open: target.open, expectedRun: this.paths });
46
+ /**
47
+ * The run's ok and execution outcome as run.json records them: what the route passed to
48
+ * finish, plus an Observer that did not render. A route's result reads them from here.
49
+ */
50
+ get outcome() {
51
+ return this.#outcome;
49
52
  }
50
53
  /**
51
- * Record the result's ok and execution outcome in status.json. A route calls it once, after the
52
- * Observer render, with the values its result returns, so the two agree.
54
+ * Render the Observer for exactly these paths, never a directory re-resolved by name. An
55
+ * Observer that did not render is an `evidence` failure: it is added to run.json's outcome and
56
+ * status.json under the route's policy before this resolves. No page exists then that could
57
+ * show the run without it. A render that throws records nothing and rejects.
53
58
  */
54
- recordOutcome(result) {
55
- return this.#status.settle(result);
59
+ async renderObserver() {
60
+ const target = this.#observer;
61
+ if (target === undefined)
62
+ throw new RunLifecycleError("This run was started without an Observer.");
63
+ const observer = await target.render(target.cwd, this.runId, {
64
+ open: target.open,
65
+ expectedRun: this.paths,
66
+ });
67
+ if (!observer.ok) {
68
+ this.#outcome = await this.#recordFailure({
69
+ kind: "evidence",
70
+ message: observer.error?.message ?? "The Observer did not render.",
71
+ });
72
+ }
73
+ return observer;
56
74
  }
57
75
  }
58
76
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
77
+ /** An execution outcome as run.json records it, each message through the shape redaction. */
78
+ function redactedExecution(execution) {
79
+ const redacted = (failure) => ({
80
+ kind: failure.kind,
81
+ message: redactText(failure.message),
82
+ });
83
+ return {
84
+ succeeded: execution.succeeded,
85
+ failures: execution.failures.map(redacted),
86
+ ...(execution.warnings === undefined ? {} : { warnings: execution.warnings.map(redacted) }),
87
+ };
88
+ }
59
89
  /** Where FinishedRun renders the Observer, when the run was started with one. */
60
90
  function observerTarget(options) {
61
91
  return options.observer === undefined
@@ -66,6 +96,99 @@ function observerTarget(options) {
66
96
  render: options.observer.render ?? renderObserver,
67
97
  };
68
98
  }
99
+ function runPublisher(args) {
100
+ const { options, runId, paths, runStatus, now, admit } = args;
101
+ const observer = observerTarget(options);
102
+ let pointerWritten = false;
103
+ // The bundle of the last write asked for, and the outcome every later write carries once a
104
+ // signal stopped the run, so no write after the interrupt can drop it.
105
+ let lastBundle;
106
+ let interrupted;
107
+ // One chain serializes every write. A rejection reaches only the caller of that write; the
108
+ // chain itself continues, so a failed snapshot never blocks the final publication.
109
+ let tail = Promise.resolve();
110
+ const enqueue = (write) => {
111
+ const written = tail.then(write);
112
+ tail = written.then(() => undefined, () => undefined);
113
+ return written;
114
+ };
115
+ const writePointer = async () => {
116
+ const pointer = {
117
+ schema: "humanish.latest-run.v1",
118
+ runId,
119
+ path: paths.relativeRunRoot,
120
+ updatedAt: new Date(now()).toISOString(),
121
+ };
122
+ await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
123
+ pointerWritten = true;
124
+ };
125
+ /** run.json with its outcome, then `afterBundle`, then the review and projections. */
126
+ const writeBundleFiles = async (bundle, outcome, afterBundle) => {
127
+ await validatePreparedRunArtifactPaths(paths);
128
+ // The run owns `outcome`: a bundle read back from disk keeps none of its own.
129
+ const { outcome: _previous, ...evidence } = bundle;
130
+ const recorded = interrupted ?? outcome;
131
+ const publicBundle = await withPublicSandboxIds(paths, {
132
+ ...evidence,
133
+ cwd: PUBLIC_TARGET_CWD,
134
+ ...(recorded === undefined ? {} : { outcome: recorded }),
135
+ });
136
+ await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
137
+ await afterBundle(publicBundle);
138
+ await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
139
+ await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle), "utf8");
140
+ const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");
141
+ await writeContainedOutputFile(paths, "events.ndjson", `${events}\n`, "utf8");
142
+ await writeContainedOutputFile(paths, "observer/observer-data.json", json(buildObserverData(publicBundle)), "utf8");
143
+ };
144
+ const snapshot = async (bundle) => {
145
+ await writeBundleFiles(bundle, undefined, async () => { });
146
+ if (!pointerWritten)
147
+ await writePointer();
148
+ };
149
+ const finishedOutcome = (outcome) => ({
150
+ state: "finished",
151
+ ok: outcome.ok,
152
+ execution: redactedExecution(outcome.execution),
153
+ });
154
+ const publish = async (bundle, outcome) => {
155
+ await writeBundleFiles(bundle, finishedOutcome(outcome), (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
156
+ await scrubRunSandboxIds(paths);
157
+ await writePointer();
158
+ let current = { ok: outcome.ok, execution: outcome.execution };
159
+ // An Observer that did not render, judged under the route's policy and written to run.json
160
+ // and status.json, so the two agree with the result the route returns. A run directory that
161
+ // can no longer be written (replaced, or a disk error) keeps the files it has, and the
162
+ // result still carries the failure.
163
+ const recordFailure = (failure) => admit(enqueue(async () => {
164
+ const { failures, warnings = [] } = current.execution;
165
+ const execution = judgeExecution([...failures, ...warnings, failure], outcome.policy);
166
+ current = { ok: current.ok && execution.succeeded, execution };
167
+ await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
168
+ return current;
169
+ }));
170
+ return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure);
171
+ };
172
+ const interrupt = async (signal) => {
173
+ interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
174
+ const written = lastBundle;
175
+ if (written === undefined)
176
+ return;
177
+ // A failed write leaves status.json as the record of the interrupt.
178
+ await enqueue(() => writeBundleFiles(written, undefined, async () => { })).catch(() => undefined);
179
+ };
180
+ return {
181
+ snapshot(bundle) {
182
+ lastBundle = bundle;
183
+ return enqueue(() => snapshot(bundle));
184
+ },
185
+ publish(bundle, outcome) {
186
+ lastBundle = bundle;
187
+ return enqueue(() => publish(bundle, outcome));
188
+ },
189
+ interrupt,
190
+ };
191
+ }
69
192
  /**
70
193
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
71
194
  * so the scope and its run admit no further calls, even through references `fn` leaked into
@@ -100,65 +223,40 @@ export async function runScope(fn) {
100
223
  if (!created.ok)
101
224
  return created;
102
225
  // beginRunStatus reads only the mode and the study provenance from the run's options.
103
- status = beginRunStatus(created.paths, { ...options, runId });
226
+ const runStatus = beginRunStatus(created.paths, { ...options, runId });
227
+ status = runStatus;
228
+ let interruptBundle;
104
229
  // Registered before the first write lands: a signal while it is still settling reaches
105
- // the handle, whose interrupt queues behind that write.
106
- unregister = registerActiveRun({ cwd: options.cwd, runId, paths: created.paths, status });
107
- await status.started;
108
- return { ok: true, run: openRun(options, runId, created.paths, status) };
230
+ // the handle, whose interrupt queues behind that write. The interrupt lands in
231
+ // status.json first, then in run.json once the run has written one.
232
+ unregister = registerActiveRun({
233
+ cwd: options.cwd,
234
+ runId,
235
+ paths: created.paths,
236
+ status: {
237
+ async interrupt(signal) {
238
+ const recorded = await runStatus.interrupt(signal);
239
+ if (recorded)
240
+ await interruptBundle?.(signal);
241
+ return recorded;
242
+ },
243
+ },
244
+ });
245
+ await runStatus.started;
246
+ const run = openRun(options, runId, created.paths, runStatus);
247
+ interruptBundle = run.interrupt;
248
+ return { ok: true, run: run.run };
109
249
  })());
110
250
  };
111
251
  const openRun = (options, runId, paths, runStatus) => {
112
252
  const now = options.now ?? Date.now;
113
253
  const createdAt = new Date(now()).toISOString();
114
- const observer = observerTarget(options);
254
+ const publisher = runPublisher({ options, runId, paths, runStatus, now, admit });
115
255
  let finishCalled = false;
116
- let pointerWritten = false;
117
- // One chain serializes every write. A rejection reaches only the caller of that write; the
118
- // chain itself continues, so a failed snapshot never blocks the final publication.
119
- let tail = Promise.resolve();
120
- const enqueue = (write) => {
121
- const written = tail.then(write);
122
- tail = written.then(() => undefined, () => undefined);
123
- return written;
124
- };
125
256
  const checkIdentity = (bundle) => bundle.runId !== runId || bundle.mode !== options.mode
126
257
  ? "The bundle names another run or mode than this run."
127
258
  : undefined;
128
- const writePointer = async () => {
129
- const pointer = {
130
- schema: "humanish.latest-run.v1",
131
- runId,
132
- path: paths.relativeRunRoot,
133
- updatedAt: new Date(now()).toISOString(),
134
- };
135
- await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
136
- pointerWritten = true;
137
- };
138
- /** run.json, then `afterBundle`, then the review and projections. */
139
- const writeBundleFiles = async (bundle, afterBundle) => {
140
- await validatePreparedRunArtifactPaths(paths);
141
- const publicBundle = await withPublicSandboxIds(paths, { ...bundle, cwd: PUBLIC_TARGET_CWD });
142
- await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
143
- await afterBundle(publicBundle);
144
- await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
145
- await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle), "utf8");
146
- const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");
147
- await writeContainedOutputFile(paths, "events.ndjson", `${events}\n`, "utf8");
148
- await writeContainedOutputFile(paths, "observer/observer-data.json", json(buildObserverData(publicBundle)), "utf8");
149
- };
150
- const snapshot = async (bundle) => {
151
- await writeBundleFiles(bundle, async () => { });
152
- if (!pointerWritten)
153
- await writePointer();
154
- };
155
- const publish = async (bundle) => {
156
- await writeBundleFiles(bundle, (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
157
- await scrubRunSandboxIds(paths);
158
- await writePointer();
159
- return new FinishedRun(issueKey, runId, paths, observer, runStatus);
160
- };
161
- return {
259
+ const run = {
162
260
  runId,
163
261
  createdAt,
164
262
  mode: options.mode,
@@ -172,9 +270,9 @@ export async function runScope(fn) {
172
270
  const mismatch = checkIdentity(bundle);
173
271
  if (mismatch !== undefined)
174
272
  return refuse(mismatch);
175
- return admit(enqueue(() => snapshot(bundle)));
273
+ return admit(publisher.snapshot(bundle));
176
274
  },
177
- finish(bundle) {
275
+ finish(bundle, outcome) {
178
276
  if (closed)
179
277
  return refuse("The run scope has closed.");
180
278
  if (finishCalled)
@@ -183,12 +281,13 @@ export async function runScope(fn) {
183
281
  if (mismatch !== undefined)
184
282
  return refuse(mismatch);
185
283
  finishCalled = true;
186
- return admit(enqueue(() => publish(bundle)).then((issued) => {
284
+ return admit(publisher.publish(bundle, outcome).then((issued) => {
187
285
  finished = issued;
188
286
  return issued;
189
287
  }));
190
288
  },
191
289
  };
290
+ return { run, interrupt: publisher.interrupt };
192
291
  };
193
292
  let outcome;
194
293
  try {
@@ -0,0 +1,59 @@
1
+ import type { PreparedOutputRoot } from "./contained-output.js";
2
+ import type { SandboxOwnerTags } from "./sandbox-receipts.js";
3
+ /** Refuses a sandbox create for a run directory whose process is stopping. */
4
+ export declare class SandboxCreateRefusedError extends Error {
5
+ constructor();
6
+ }
7
+ /** One sandbox a create in this process reported, and what its route did with it. */
8
+ export interface CreatedSandbox {
9
+ sandboxId: string;
10
+ /** The participant or subject label the receipt also records. */
11
+ participantId: string;
12
+ /** True once the route's own release confirmed the sandbox gone. */
13
+ released: boolean;
14
+ }
15
+ /**
16
+ * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
17
+ * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
18
+ * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
19
+ * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
20
+ * route already sets.
21
+ */
22
+ export declare function sandboxOwnerTags(root: PreparedOutputRoot): SandboxOwnerTags;
23
+ /** A create in progress; the acquire step in src/substrates/e2b/sandbox.ts holds one per create. */
24
+ export interface SandboxCreateTicket {
25
+ /** Whether the directory's process has started stopping; checked before each attempt. */
26
+ stopping(): boolean;
27
+ /** The provider reported a sandbox id for this create. Safe to call twice with one id. */
28
+ created(sandboxId: string): void;
29
+ /** The route's release confirmed this sandbox gone. */
30
+ released(sandboxId: string): void;
31
+ /** The create resolved or threw, and its receipt write, if any, has finished. */
32
+ settled(): void;
33
+ }
34
+ /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
35
+ export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string): SandboxCreateTicket;
36
+ /**
37
+ * Run `write` once per directory and return its promise to every caller. Creates that start
38
+ * together then resume in the order they called, so the order they reach the provider does not
39
+ * depend on which file append finishes first. A write that reports failure is forgotten, so the
40
+ * next create tries again.
41
+ */
42
+ export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, write: () => Promise<boolean>): Promise<void>;
43
+ /** What the signal handler holds after stopping a directory's creates. */
44
+ export interface StoppedSandboxCreates {
45
+ /**
46
+ * Calls `listener` at once for every sandbox a create has reported so far, then for each one an
47
+ * in-flight create reports later, until the returned function is called.
48
+ */
49
+ watch(listener: (sandbox: CreatedSandbox) => void): () => void;
50
+ /** Resolves once every create that was in flight at the stop has settled. */
51
+ settled: Promise<void>;
52
+ /** How many of those creates have not settled yet. */
53
+ inFlight(): number;
54
+ }
55
+ /**
56
+ * Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
57
+ * the signal arrives, before anything is awaited, so no create can slip in after it.
58
+ */
59
+ export declare function stopSandboxCreates(root: PreparedOutputRoot): StoppedSandboxCreates;
@@ -0,0 +1,138 @@
1
+ // The sandboxes this process is creating for each run directory, and the E2B tags that name the
2
+ // directory. A receipt reaches sandbox-receipts.ndjson only after `Sandbox.create` resolves, and
3
+ // on @e2b/desktop that is after desktop startup, seconds after E2B has created the sandbox. The
4
+ // CLI's signal handler (src/cli/commands/run-signals.ts) uses this registry to cover that window:
5
+ // it stops new creates, kills every id a create has reported, and kills each in-flight create's
6
+ // sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
7
+ // never reached this process at all.
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+ /** Refuses a sandbox create for a run directory whose process is stopping. */
11
+ export class SandboxCreateRefusedError extends Error {
12
+ constructor() {
13
+ super("The run is stopping, so no further sandbox is created.");
14
+ this.name = "SandboxCreateRefusedError";
15
+ }
16
+ }
17
+ const directories = new Map();
18
+ // A process runs one study at a time; the cap only bounds a long-lived library caller, and only
19
+ // idle entries count against it.
20
+ const MAX_REMEMBERED_DIRECTORIES = 64;
21
+ function identityOf(root) {
22
+ return "physicalRunRoot" in root
23
+ ? { where: root.physicalRunRoot, identity: root.runRootIdentity }
24
+ : { where: root.physicalPath, identity: root.identity };
25
+ }
26
+ function directoryKey(root) {
27
+ const { where, identity } = identityOf(root);
28
+ return `${where}\0${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
29
+ }
30
+ function directoryFor(root) {
31
+ const key = directoryKey(root);
32
+ const existing = directories.get(key);
33
+ if (existing !== undefined)
34
+ return existing;
35
+ const created = {
36
+ stopping: false,
37
+ created: new Map(),
38
+ inFlight: new Set(),
39
+ listeners: new Set(),
40
+ };
41
+ directories.set(key, created);
42
+ // Only an idle entry is forgotten: a stopping one keeps refusing creates, and one with a create
43
+ // in flight or a sandbox its route has not released is still needed by the signal handler.
44
+ for (const [oldest, entry] of directories) {
45
+ if (directories.size <= MAX_REMEMBERED_DIRECTORIES)
46
+ break;
47
+ if (entry === created || !idle(entry))
48
+ continue;
49
+ directories.delete(oldest);
50
+ }
51
+ return created;
52
+ }
53
+ function idle(entry) {
54
+ return (!entry.stopping &&
55
+ entry.inFlight.size === 0 &&
56
+ [...entry.created.values()].every((sandbox) => sandbox.released));
57
+ }
58
+ /**
59
+ * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
60
+ * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
61
+ * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
62
+ * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
63
+ * route already sets.
64
+ */
65
+ export function sandboxOwnerTags(root) {
66
+ const { where, identity } = identityOf(root);
67
+ const runKey = createHash("sha256")
68
+ .update(`${identity.ino}:${identity.birthtimeNs}`)
69
+ .digest("hex")
70
+ .slice(0, 16);
71
+ return { tool: "humanish", runId: path.basename(where), runKey };
72
+ }
73
+ /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
74
+ export function beginSandboxCreate(root, participantId) {
75
+ const directory = directoryFor(root);
76
+ if (directory.stopping)
77
+ throw new SandboxCreateRefusedError();
78
+ let settle;
79
+ const done = new Promise((resolve) => {
80
+ settle = resolve;
81
+ });
82
+ directory.inFlight.add(done);
83
+ return {
84
+ stopping: () => directory.stopping,
85
+ created(sandboxId) {
86
+ if (directory.created.has(sandboxId))
87
+ return;
88
+ const sandbox = { sandboxId, participantId, released: false };
89
+ directory.created.set(sandboxId, sandbox);
90
+ for (const listener of directory.listeners)
91
+ listener(sandbox);
92
+ },
93
+ released(sandboxId) {
94
+ const sandbox = directory.created.get(sandboxId);
95
+ if (sandbox !== undefined)
96
+ sandbox.released = true;
97
+ },
98
+ settled() {
99
+ directory.inFlight.delete(done);
100
+ settle();
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * Run `write` once per directory and return its promise to every caller. Creates that start
106
+ * together then resume in the order they called, so the order they reach the provider does not
107
+ * depend on which file append finishes first. A write that reports failure is forgotten, so the
108
+ * next create tries again.
109
+ */
110
+ export function recordSandboxOwnerOnce(root, write) {
111
+ const directory = directoryFor(root);
112
+ const pending = (directory.ownerLine ??= write().then((written) => {
113
+ if (!written && directory.ownerLine === pending)
114
+ delete directory.ownerLine;
115
+ }));
116
+ return pending;
117
+ }
118
+ /**
119
+ * Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
120
+ * the signal arrives, before anything is awaited, so no create can slip in after it.
121
+ */
122
+ export function stopSandboxCreates(root) {
123
+ const directory = directoryFor(root);
124
+ directory.stopping = true;
125
+ const pending = [...directory.inFlight];
126
+ return {
127
+ watch(listener) {
128
+ for (const sandbox of directory.created.values())
129
+ listener(sandbox);
130
+ directory.listeners.add(listener);
131
+ return () => {
132
+ directory.listeners.delete(listener);
133
+ };
134
+ },
135
+ settled: Promise.all(pending).then(() => undefined),
136
+ inFlight: () => pending.filter((create) => directory.inFlight.has(create)).length,
137
+ };
138
+ }
@@ -29,5 +29,19 @@ export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
29
29
  * artifact write uses; append (not atomic-replace) keeps racing participants' receipts intact.
30
30
  */
31
31
  export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<void>;
32
+ /** Append one owner line; false when the write failed, which never fails the create. */
33
+ export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
34
+ /** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
35
+ export type SandboxOwnerTags = {
36
+ tool: "humanish";
37
+ runId: string;
38
+ runKey: string;
39
+ };
40
+ /**
41
+ * The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
42
+ * tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
43
+ * else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
44
+ */
45
+ export declare function parseSandboxOwners(text: string, runId: string): SandboxOwnerTags[];
32
46
  /** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
33
47
  export declare function parseSandboxReceipts(text: string): ParsedSandboxReceipt[];
@@ -45,6 +45,50 @@ export async function appendSandboxReceipt(root, receipt) {
45
45
  // Swallowed on purpose; see the contract above.
46
46
  }
47
47
  }
48
+ /** Append one owner line; false when the write failed, which never fails the create. */
49
+ export async function appendSandboxOwner(root, owner) {
50
+ try {
51
+ const line = { at: new Date().toISOString(), provider: "e2b", owner };
52
+ const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
53
+ // The leading newline ends a line an earlier append left torn, so this record parses.
54
+ await appendFile(filePath, `\n${JSON.stringify(line)}\n`, "utf8");
55
+ return true;
56
+ }
57
+ catch {
58
+ // Never fails the create. Without the line, reclaim reports the run's sandboxes as unknown,
59
+ // never clean, so the next create tries again.
60
+ return false;
61
+ }
62
+ }
63
+ const RUN_KEY = /^[0-9a-f]{16}$/;
64
+ /**
65
+ * The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
66
+ * tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
67
+ * else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
68
+ */
69
+ export function parseSandboxOwners(text, runId) {
70
+ const owners = new Map();
71
+ for (const line of text.split("\n")) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed)
74
+ continue;
75
+ try {
76
+ const parsed = JSON.parse(trimmed);
77
+ const owner = parsed.owner;
78
+ if (parsed.provider !== "e2b" || owner === null || typeof owner !== "object")
79
+ continue;
80
+ if (Object.keys(owner).length !== 3 || owner.tool !== "humanish" || owner.runId !== runId)
81
+ continue;
82
+ if (typeof owner.runKey !== "string" || !RUN_KEY.test(owner.runKey))
83
+ continue;
84
+ owners.set(owner.runKey, { tool: "humanish", runId, runKey: owner.runKey });
85
+ }
86
+ catch {
87
+ // A torn line: skip it.
88
+ }
89
+ }
90
+ return [...owners.values()];
91
+ }
48
92
  /** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
49
93
  export function parseSandboxReceipts(text) {
50
94
  const receipts = [];
@@ -1,3 +1,4 @@
1
+ import { type RunDisplayState } from "./display.js";
1
2
  import { type CostTotals, type CostRow } from "./costs.js";
2
3
  declare const STATS_SCHEMA = "humanish.stats.v2";
3
4
  interface StatsParticipants {
@@ -13,6 +14,7 @@ interface StatsStudyRow {
13
14
  dryRun: number;
14
15
  /** Runs whose bundle carries a verdict; the denominator for passRate. */
15
16
  judged: number;
17
+ /** Runs that show as passed (runDisplay in src/run/display.ts): a pass verdict and an ok run. */
16
18
  passed: number;
17
19
  /** passed / judged, absent when judged is 0. */
18
20
  passRate?: number;
@@ -52,7 +54,10 @@ export interface StatsResult {
52
54
  estimatedSpendUsd: number;
53
55
  unpricedRuns: number;
54
56
  participants: StatsParticipants;
57
+ /** The participants' verdicts. `outcomes` says how many runs passed. */
55
58
  verdicts: Record<string, number>;
59
+ /** Runs by the state every surface shows them in (runDisplay in src/run/display.ts). */
60
+ outcomes: Partial<Record<RunDisplayState, number>>;
56
61
  costs: CostTotals;
57
62
  };
58
63
  studies: StatsStudyRow[];
package/dist/run/stats.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // numbers under the per-run rules: an estimate is labelled an estimate, a run whose cost is unknown
3
3
  // counts as unknown and never as zero, and every rate has a denominator next to it.
4
4
  import path from "node:path";
5
+ import { runDisplay, runDisplayLabel } from "./display.js";
5
6
  import { readRunIndex } from "./run-index.js";
6
7
  import { addCostTotals, emptyCostTotals, readCostTotals, } from "./costs.js";
7
8
  import { round6 } from "./pricing.js";
@@ -68,7 +69,7 @@ function addToStudyRow(studies, entry, runUsd, costs) {
68
69
  row.dryRun += 1;
69
70
  if (entry.verdict !== undefined) {
70
71
  row.judged += 1;
71
- if (entry.verdict === "pass")
72
+ if (runDisplay(entry).state === "passed")
72
73
  row.passed += 1;
73
74
  }
74
75
  if (entry.mode === "live" && entry.durationMs !== undefined)
@@ -157,6 +158,7 @@ export async function computeStats(cwdInput, options = {}) {
157
158
  unpricedRuns: 0,
158
159
  participants: emptyParticipants(),
159
160
  verdicts: {},
161
+ outcomes: {},
160
162
  costs: emptyCostTotals(),
161
163
  };
162
164
  const studies = new Map();
@@ -181,6 +183,8 @@ export async function computeStats(cwdInput, options = {}) {
181
183
  addParticipants(totals.participants, entry);
182
184
  if (entry.verdict !== undefined)
183
185
  totals.verdicts[entry.verdict] = (totals.verdicts[entry.verdict] ?? 0) + 1;
186
+ const { state } = runDisplay(entry);
187
+ totals.outcomes[state] = (totals.outcomes[state] ?? 0) + 1;
184
188
  addToStudyRow(studies, entry, runUsd, accounting.costs);
185
189
  addToDayRow(days, entry, runUsd, accounting.costs);
186
190
  }
@@ -239,7 +243,7 @@ function runCountLines(t) {
239
243
  const running = t.running > 0 ? ` (${t.running} still running)` : "";
240
244
  return [`live runs: ${t.live}${running}`, ...(t.dryRun > 0 ? [`dry runs: ${t.dryRun}`] : [])];
241
245
  }
242
- /** Spend, analysis, participants and verdicts across the selected live runs. */
246
+ /** Spend, analysis, participants and outcomes across the selected live runs. */
243
247
  function totalsLines(t) {
244
248
  const c = t.costs;
245
249
  const incomplete = c.incompleteRunEstimates > 0
@@ -248,7 +252,7 @@ function totalsLines(t) {
248
252
  const unpriced = c.analysisUnpricedAttempts > 0 || c.analysisUnresolvedAttempts > 0
249
253
  ? `; ${c.analysisUnpricedAttempts} unpriced, ${c.analysisUnresolvedAttempts} unresolved`
250
254
  : "";
251
- const verdicts = Object.entries(t.verdicts).map(([verdict, count]) => `${verdict} ${count}`);
255
+ const outcomes = Object.entries(t.outcomes).map(([state, count]) => `${runDisplayLabel(state)} ${count}`);
252
256
  return [
253
257
  `known estimated spend: ${knownMoney(c.estimatedTotalUsd)}`,
254
258
  `participants and desktops: ${knownMoney(c.runEstimatedUsd)}${incomplete}`,
@@ -265,7 +269,7 @@ function totalsLines(t) {
265
269
  `participants: ${t.participants.reachedGoal} of ${t.participants.total} reached the goal, ${t.participants.reportedFriction} reported friction`,
266
270
  ]
267
271
  : []),
268
- ...(verdicts.length > 0 ? [`verdicts: ${verdicts.join(", ")}`] : []),
272
+ ...(outcomes.length > 0 ? [`outcomes: ${outcomes.join(", ")}`] : []),
269
273
  ];
270
274
  }
271
275
  function dayLine(row) {