humanish 0.110.0 → 0.110.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 (178) hide show
  1. package/ARCHITECTURE.md +15 -12
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +103 -53
  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 +17 -0
  11. package/dist/actors/local-agent/cli.js +111 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +20 -12
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/observe.js +15 -2
  16. package/dist/cli/commands/run-signals.d.ts +2 -0
  17. package/dist/cli/commands/run-signals.js +106 -45
  18. package/dist/cli/commands/runs.d.ts +5 -0
  19. package/dist/cli/commands/runs.js +113 -74
  20. package/dist/cli/commands/setup.js +23 -14
  21. package/dist/cli/commands/study-route-computer-use.js +9 -3
  22. package/dist/cli/commands/study-route-open.js +1 -0
  23. package/dist/cli/commands/study-route-preview.js +1 -0
  24. package/dist/cli/commands/study-route-scripted.js +2 -0
  25. package/dist/cli/commands/study-route-shared-world.js +6 -2
  26. package/dist/cli/commands/study-route-terminal.js +2 -0
  27. package/dist/cli/commands/watch.js +14 -4
  28. package/dist/cli/doctor.d.ts +4 -1
  29. package/dist/cli/doctor.js +51 -10
  30. package/dist/cli/findings.d.ts +103 -0
  31. package/dist/cli/findings.js +317 -0
  32. package/dist/cli/first-run-path.d.ts +32 -11
  33. package/dist/cli/first-run-path.js +113 -69
  34. package/dist/cli/invocation.d.ts +7 -0
  35. package/dist/cli/invocation.js +32 -0
  36. package/dist/cli/io.d.ts +3 -1
  37. package/dist/cli/io.js +8 -15
  38. package/dist/cli/observer-follow.d.ts +20 -0
  39. package/dist/cli/observer-follow.js +36 -1
  40. package/dist/cli/orientation.js +16 -14
  41. package/dist/cli/product-sentence.d.ts +3 -3
  42. package/dist/cli/product-sentence.js +3 -3
  43. package/dist/cli/version.d.ts +1 -0
  44. package/dist/cli/version.js +11 -0
  45. package/dist/evidence/encoded-text.d.ts +9 -6
  46. package/dist/evidence/encoded-text.js +84 -19
  47. package/dist/evidence/redaction.d.ts +2 -0
  48. package/dist/evidence/redaction.js +123 -21
  49. package/dist/feedback/export-bundle.js +2 -2
  50. package/dist/keys/env-file.d.ts +4 -0
  51. package/dist/keys/env-file.js +17 -0
  52. package/dist/keys/key-resolution.js +6 -5
  53. package/dist/observer/data.d.ts +14 -1
  54. package/dist/observer/data.js +19 -9
  55. package/dist/observer/exposure.js +1 -1
  56. package/dist/observer/library.d.ts +4 -0
  57. package/dist/observer/library.js +21 -17
  58. package/dist/observer/render.js +10 -3
  59. package/dist/observer/run-routes.d.ts +2 -0
  60. package/dist/observer/run-routes.js +51 -35
  61. package/dist/observer/serve.js +2 -1
  62. package/dist/observer-app.html +3 -3
  63. package/dist/routes/computer-use/bundle.js +3 -1
  64. package/dist/routes/computer-use/fanout-records.js +1 -1
  65. package/dist/routes/computer-use/participant-execution.js +3 -1
  66. package/dist/routes/computer-use/participant-model.js +5 -2
  67. package/dist/routes/computer-use/plan.js +9 -4
  68. package/dist/routes/computer-use/preflight.js +1 -1
  69. package/dist/routes/computer-use/result.js +17 -28
  70. package/dist/routes/computer-use/single-bundle.js +7 -1
  71. package/dist/routes/scripted/bundle.js +4 -2
  72. package/dist/routes/scripted/plan.js +2 -2
  73. package/dist/routes/scripted/result.d.ts +3 -5
  74. package/dist/routes/scripted/result.js +16 -22
  75. package/dist/routes/scripted/scenario.js +4 -4
  76. package/dist/routes/shared-world/bundle-records.js +6 -1
  77. package/dist/routes/shared-world/bundle.js +2 -0
  78. package/dist/routes/shared-world/plan.js +6 -2
  79. package/dist/routes/shared-world/result.js +22 -22
  80. package/dist/routes/terminal/bundle.js +6 -3
  81. package/dist/routes/terminal/credentials.d.ts +5 -5
  82. package/dist/routes/terminal/credentials.js +14 -6
  83. package/dist/routes/terminal/dry-run.js +8 -12
  84. package/dist/routes/terminal/ledger.d.ts +3 -3
  85. package/dist/routes/terminal/ledger.js +6 -6
  86. package/dist/routes/terminal/live-finish.js +15 -13
  87. package/dist/routes/terminal/plan.js +12 -4
  88. package/dist/routes/terminal/result.d.ts +6 -7
  89. package/dist/routes/terminal/result.js +6 -22
  90. package/dist/routes/terminal/route.js +5 -4
  91. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  92. package/dist/routes/terminal/runtime-auth.js +5 -0
  93. package/dist/routes/terminal/session.js +1 -1
  94. package/dist/run/bundle-shape.d.ts +3 -1
  95. package/dist/run/bundle-shape.js +25 -1
  96. package/dist/run/bundle.d.ts +24 -0
  97. package/dist/run/display.d.ts +59 -0
  98. package/dist/run/display.js +106 -0
  99. package/dist/run/dry-run.js +13 -8
  100. package/dist/run/judge.d.ts +3 -2
  101. package/dist/run/judge.js +12 -1
  102. package/dist/run/local-only-files.d.ts +13 -0
  103. package/dist/run/local-only-files.js +35 -0
  104. package/dist/run/pricing.js +1 -1
  105. package/dist/run/projection.d.ts +0 -2
  106. package/dist/run/projection.js +0 -11
  107. package/dist/run/reclaim-outcomes.d.ts +89 -0
  108. package/dist/run/reclaim-outcomes.js +213 -0
  109. package/dist/run/reclaim.d.ts +23 -17
  110. package/dist/run/reclaim.js +213 -114
  111. package/dist/run/results.d.ts +10 -20
  112. package/dist/run/results.js +2 -2
  113. package/dist/run/run-index.d.ts +11 -1
  114. package/dist/run/run-index.js +11 -0
  115. package/dist/run/run-not-found.js +3 -2
  116. package/dist/run/run.d.ts +34 -18
  117. package/dist/run/run.js +166 -67
  118. package/dist/run/sandbox-creates.d.ts +59 -0
  119. package/dist/run/sandbox-creates.js +140 -0
  120. package/dist/run/sandbox-receipts.d.ts +20 -5
  121. package/dist/run/sandbox-receipts.js +56 -7
  122. package/dist/run/stats.d.ts +5 -0
  123. package/dist/run/stats.js +8 -4
  124. package/dist/run/status.d.ts +6 -9
  125. package/dist/run/status.js +7 -23
  126. package/dist/run/stored-runs.d.ts +5 -14
  127. package/dist/run/stored-runs.js +31 -146
  128. package/dist/run/streams.d.ts +3 -2
  129. package/dist/study/agents-section.d.ts +21 -0
  130. package/dist/study/agents-section.js +86 -0
  131. package/dist/study/doctor.d.ts +1 -1
  132. package/dist/study/doctor.js +8 -4
  133. package/dist/study/init-templates.js +0 -95
  134. package/dist/study/init.js +45 -37
  135. package/dist/study/keys.d.ts +12 -0
  136. package/dist/study/keys.js +32 -0
  137. package/dist/study/local-browser-host.d.ts +11 -0
  138. package/dist/study/local-browser-host.js +42 -0
  139. package/dist/study/parse/actors.js +9 -1
  140. package/dist/study/parse/subject.js +12 -0
  141. package/dist/study/parse/url-credentials.d.ts +9 -0
  142. package/dist/study/parse/url-credentials.js +50 -0
  143. package/dist/study/persona.d.ts +4 -0
  144. package/dist/study/persona.js +4 -2
  145. package/dist/study/preflight-probes.js +2 -1
  146. package/dist/study/types.d.ts +8 -6
  147. package/dist/study/url-credentials.d.ts +6 -0
  148. package/dist/study/url-credentials.js +34 -0
  149. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  150. package/dist/substrates/e2b/peer-install.js +161 -0
  151. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  152. package/dist/substrates/e2b/sandbox.js +141 -30
  153. package/dist/substrates/e2b/sdk.d.ts +33 -0
  154. package/dist/substrates/e2b/sdk.js +21 -21
  155. package/dist/substrates/local/firecracker-desktop.js +3 -2
  156. package/dist/substrates/local/runtime-host.js +5 -4
  157. package/dist/substrates/local/runtime.js +5 -4
  158. package/dist/substrates/shell.d.ts +2 -0
  159. package/dist/substrates/shell.js +4 -0
  160. package/dist/tui/contract.js +5 -4
  161. package/dist/tui-app.js +133 -133
  162. package/dist/verify/liveness.d.ts +19 -3
  163. package/dist/verify/liveness.js +47 -25
  164. package/dist/verify/verify.d.ts +3 -0
  165. package/dist/verify/verify.js +8 -3
  166. package/docs/architecture/actor-contract.md +2 -1
  167. package/docs/architecture/observer.md +22 -7
  168. package/docs/architecture/task-protocol-support.md +13 -13
  169. package/docs/architecture/terminal-product-route.md +15 -13
  170. package/docs/contracts/run-bundle.md +95 -10
  171. package/docs/contracts/schemas.md +44 -26
  172. package/docs/contracts/study-analysis.md +45 -0
  173. package/docs/contracts/study-costs.md +8 -0
  174. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  175. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  176. package/docs/principles/invariants-and-defaults.md +11 -8
  177. package/package.json +6 -3
  178. package/skills/humanish/SKILL.md +38 -20
package/dist/run/run.d.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { renderObserver, type ObserverResult } from "../observer/render.js";
2
2
  import { type RunBundle } from "./bundle.js";
3
- import type { ExecutionOutcome } from "./judge.js";
3
+ import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
4
4
  import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
5
- import { type RunStatusHandle } from "./status.js";
6
5
  import type { RunStudyProvenance } from "./study-provenance.js";
7
6
  interface StartRunOptions {
8
7
  /** Project directory, in the form the route resolved it. */
@@ -22,6 +21,19 @@ interface StartRunOptions {
22
21
  /** Clock for `createdAt` and the latest pointer. */
23
22
  now?: (() => number) | undefined;
24
23
  }
24
+ /** How a run ended as an execution, as run.json records it: the result's ok and execution outcome. */
25
+ interface RecordedOutcome {
26
+ ok: boolean;
27
+ execution: ExecutionOutcome;
28
+ }
29
+ /** What a route hands Run.finish about how its run ended, before the Observer renders. */
30
+ export interface FinishOutcome extends RecordedOutcome {
31
+ /**
32
+ * The route's entry in OUTCOME_POLICIES. FinishedRun.renderObserver records an Observer that
33
+ * did not render under it, as a failure or a warning.
34
+ */
35
+ policy: OutcomePolicy;
36
+ }
25
37
  interface Run {
26
38
  readonly runId: string;
27
39
  readonly createdAt: string;
@@ -41,14 +53,16 @@ interface Run {
41
53
  */
42
54
  writeSnapshot(bundle: RunBundle): Promise<void>;
43
55
  /**
44
- * The one final publication: run.json, then the status outcome, then review.json, review.md,
45
- * events.ndjson, observer/observer-data.json, and last the latest pointer. The status goes
46
- * after run.json so the index never gets ahead of the evidence, and the pointer goes last so
47
- * `latest` never selects a run whose projections are incomplete. Rejects before writing on a
48
- * second call, after the scope closed, or when the bundle names another run or mode. A
49
- * rejection partway leaves the files written so far and issues no FinishedRun.
56
+ * The one final publication: run.json with `outcome` set from `outcome`, then the status
57
+ * outcome copied from it, then review.json, review.md, events.ndjson,
58
+ * observer/observer-data.json, and last the latest pointer. The status goes after run.json so
59
+ * the index never gets ahead of the evidence, and the pointer goes last so `latest` never
60
+ * selects a run whose projections are incomplete. Every projection reads the outcome, so none
61
+ * shows a pass for a run whose ok is false. Rejects before writing on a second call, after the
62
+ * scope closed, or when the bundle names another run or mode. A rejection partway leaves the
63
+ * files written so far and issues no FinishedRun.
50
64
  */
51
- finish(bundle: RunBundle): Promise<FinishedRun>;
65
+ finish(bundle: RunBundle, outcome: FinishOutcome): Promise<FinishedRun>;
52
66
  }
53
67
  export interface RunScope {
54
68
  /**
@@ -78,18 +92,20 @@ export declare class FinishedRun {
78
92
  readonly runId: string;
79
93
  /** The paths created by startRun and validated at the start of `finish`. */
80
94
  readonly paths: PreparedRunArtifactPaths;
81
- constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, status: RunStatusHandle);
95
+ constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>);
82
96
  static isIssued(value: unknown): value is FinishedRun;
83
- /** Render the Observer for exactly these paths, never a directory re-resolved by name. */
84
- renderObserver(): Promise<ObserverResult>;
85
97
  /**
86
- * Record the result's ok and execution outcome in status.json. A route calls it once, after the
87
- * Observer render, with the values its result returns, so the two agree.
98
+ * The run's ok and execution outcome as run.json records them: what the route passed to
99
+ * finish, plus an Observer that did not render. A route's result reads them from here.
100
+ */
101
+ get outcome(): RecordedOutcome;
102
+ /**
103
+ * Render the Observer for exactly these paths, never a directory re-resolved by name. An
104
+ * Observer that did not render is an `evidence` failure: it is added to run.json's outcome and
105
+ * status.json under the route's policy before this resolves. No page exists then that could
106
+ * show the run without it. A render that throws records nothing and rejects.
88
107
  */
89
- recordOutcome(result: {
90
- ok: boolean;
91
- execution: ExecutionOutcome;
92
- }): Promise<void>;
108
+ renderObserver(): Promise<ObserverResult>;
93
109
  }
94
110
  /**
95
111
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
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,140 @@
1
+ // The sandboxes this process is creating for each run directory, and the E2B tags that name the
2
+ // directory. E2B creates a sandbox before its id reaches this process: on @e2b/desktop the startup
3
+ // guard reports the id, and its receipt is written, only once the SDK has the handle, and a
4
+ // create from an SDK without the guard reports it when `Sandbox.create` resolves, after desktop
5
+ // startup. The CLI's signal handler (src/cli/commands/run-signals.ts) uses this registry to cover
6
+ // the window before a receipt lands:
7
+ // it stops new creates, kills every id a create has reported, and kills each in-flight create's
8
+ // sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
9
+ // never reached this process at all.
10
+ import { createHash } from "node:crypto";
11
+ import path from "node:path";
12
+ /** Refuses a sandbox create for a run directory whose process is stopping. */
13
+ export class SandboxCreateRefusedError extends Error {
14
+ constructor() {
15
+ super("The run is stopping, so no further sandbox is created.");
16
+ this.name = "SandboxCreateRefusedError";
17
+ }
18
+ }
19
+ const directories = new Map();
20
+ // A process runs one study at a time; the cap only bounds a long-lived library caller, and only
21
+ // idle entries count against it.
22
+ const MAX_REMEMBERED_DIRECTORIES = 64;
23
+ function identityOf(root) {
24
+ return "physicalRunRoot" in root
25
+ ? { where: root.physicalRunRoot, identity: root.runRootIdentity }
26
+ : { where: root.physicalPath, identity: root.identity };
27
+ }
28
+ function directoryKey(root) {
29
+ const { where, identity } = identityOf(root);
30
+ return `${where}\0${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
31
+ }
32
+ function directoryFor(root) {
33
+ const key = directoryKey(root);
34
+ const existing = directories.get(key);
35
+ if (existing !== undefined)
36
+ return existing;
37
+ const created = {
38
+ stopping: false,
39
+ created: new Map(),
40
+ inFlight: new Set(),
41
+ listeners: new Set(),
42
+ };
43
+ directories.set(key, created);
44
+ // Only an idle entry is forgotten: a stopping one keeps refusing creates, and one with a create
45
+ // in flight or a sandbox its route has not released is still needed by the signal handler.
46
+ for (const [oldest, entry] of directories) {
47
+ if (directories.size <= MAX_REMEMBERED_DIRECTORIES)
48
+ break;
49
+ if (entry === created || !idle(entry))
50
+ continue;
51
+ directories.delete(oldest);
52
+ }
53
+ return created;
54
+ }
55
+ function idle(entry) {
56
+ return (!entry.stopping &&
57
+ entry.inFlight.size === 0 &&
58
+ [...entry.created.values()].every((sandbox) => sandbox.released));
59
+ }
60
+ /**
61
+ * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
62
+ * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
63
+ * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
64
+ * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
65
+ * route already sets.
66
+ */
67
+ export function sandboxOwnerTags(root) {
68
+ const { where, identity } = identityOf(root);
69
+ const runKey = createHash("sha256")
70
+ .update(`${identity.ino}:${identity.birthtimeNs}`)
71
+ .digest("hex")
72
+ .slice(0, 16);
73
+ return { tool: "humanish", runId: path.basename(where), runKey };
74
+ }
75
+ /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
76
+ export function beginSandboxCreate(root, participantId) {
77
+ const directory = directoryFor(root);
78
+ if (directory.stopping)
79
+ throw new SandboxCreateRefusedError();
80
+ let settle;
81
+ const done = new Promise((resolve) => {
82
+ settle = resolve;
83
+ });
84
+ directory.inFlight.add(done);
85
+ return {
86
+ stopping: () => directory.stopping,
87
+ created(sandboxId) {
88
+ if (directory.created.has(sandboxId))
89
+ return;
90
+ const sandbox = { sandboxId, participantId, released: false };
91
+ directory.created.set(sandboxId, sandbox);
92
+ for (const listener of directory.listeners)
93
+ listener(sandbox);
94
+ },
95
+ released(sandboxId) {
96
+ const sandbox = directory.created.get(sandboxId);
97
+ if (sandbox !== undefined)
98
+ sandbox.released = true;
99
+ },
100
+ settled() {
101
+ directory.inFlight.delete(done);
102
+ settle();
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * Run `write` once per directory and return its promise to every caller. Creates that start
108
+ * together then resume in the order they called, so the order they reach the provider does not
109
+ * depend on which file append finishes first. A write that reports failure is forgotten, so the
110
+ * next create tries again.
111
+ */
112
+ export function recordSandboxOwnerOnce(root, write) {
113
+ const directory = directoryFor(root);
114
+ const pending = (directory.ownerLine ??= write().then((written) => {
115
+ if (!written && directory.ownerLine === pending)
116
+ delete directory.ownerLine;
117
+ }));
118
+ return pending;
119
+ }
120
+ /**
121
+ * Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
122
+ * the signal arrives, before anything is awaited, so no create can slip in after it.
123
+ */
124
+ export function stopSandboxCreates(root) {
125
+ const directory = directoryFor(root);
126
+ directory.stopping = true;
127
+ const pending = [...directory.inFlight];
128
+ return {
129
+ watch(listener) {
130
+ for (const sandbox of directory.created.values())
131
+ listener(sandbox);
132
+ directory.listeners.add(listener);
133
+ return () => {
134
+ directory.listeners.delete(listener);
135
+ };
136
+ },
137
+ settled: Promise.all(pending).then(() => undefined),
138
+ inFlight: () => pending.filter((create) => directory.inFlight.has(create)).length,
139
+ };
140
+ }
@@ -23,11 +23,26 @@ export interface ParsedSandboxReceipt extends Omit<SandboxReceipt, "provider"> {
23
23
  /** The ids this process has receipted for `root`, whether or not the journal write landed. */
24
24
  export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
25
25
  /**
26
- * Append one receipt. Best-effort by design: the receipt exists to protect the run, so a failed
27
- * receipt write must never fail the participant. The only cost of a miss is that `reclaim` cannot see
28
- * this id and the TTL backstop covers it instead. Containment is the same prepare step every
29
- * artifact write uses; append (not atomic-replace) keeps racing participants' receipts intact.
26
+ * Append one receipt; false when the write failed. Best-effort by design: the receipt exists to
27
+ * protect the run, so a failed receipt write must never fail the participant. The only cost of a
28
+ * miss is that `reclaim` cannot see this id and the TTL backstop covers it instead. Containment is
29
+ * the same prepare step every artifact write uses; append (not atomic-replace) keeps racing
30
+ * participants' receipts intact.
30
31
  */
31
- export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<void>;
32
+ export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<boolean>;
33
+ /** Append one owner line; false when the write failed, which never fails the create. */
34
+ export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
35
+ /** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
36
+ export type SandboxOwnerTags = {
37
+ tool: "humanish";
38
+ runId: string;
39
+ runKey: string;
40
+ };
41
+ /**
42
+ * The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
43
+ * tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
44
+ * else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
45
+ */
46
+ export declare function parseSandboxOwners(text: string, runId: string): SandboxOwnerTags[];
32
47
  /** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
33
48
  export declare function parseSandboxReceipts(text: string): ParsedSandboxReceipt[];