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
@@ -0,0 +1,106 @@
1
+ // How a run reads wherever a surface says whether it passed: the TUI, the Observer and its run
2
+ // library, `humanish runs`, `humanish stats` and review.md. Each of them calls runDisplay with the
3
+ // same facts, so a run whose own ok is false shows as a pass on none of them, and two surfaces
4
+ // cannot disagree about one run.
5
+ import { classifyRunStatus, isRunStatusRecord } from "./status.js";
6
+ const LABELS = {
7
+ running: "running",
8
+ interrupted: "interrupted",
9
+ passed: "passed",
10
+ failed: "failed",
11
+ blocked: "blocked",
12
+ timed_out: "timed out",
13
+ no_verdict: "no verdict",
14
+ dry_run: "dry run",
15
+ unknown: "unknown",
16
+ };
17
+ const TONES = {
18
+ running: "live",
19
+ interrupted: "warn",
20
+ passed: "pass",
21
+ failed: "fail",
22
+ blocked: "warn",
23
+ timed_out: "warn",
24
+ no_verdict: "warn",
25
+ dry_run: "neutral",
26
+ unknown: "neutral",
27
+ };
28
+ function shown(state, reason) {
29
+ return {
30
+ state,
31
+ label: LABELS[state],
32
+ tone: TONES[state],
33
+ ...(reason === undefined ? {} : { reason }),
34
+ };
35
+ }
36
+ /**
37
+ * A run's state. A live or interrupted run is that, whatever its last flush said. A finished run
38
+ * shows its participants' blocked, timed-out or failed verdict; otherwise a run whose ok is false
39
+ * is failed, even with a pass verdict, because its execution failed. Only then does a pass verdict
40
+ * show as passed.
41
+ */
42
+ export function runDisplay(facts) {
43
+ if (facts.liveness === "running")
44
+ return shown("running");
45
+ if (facts.liveness === "interrupted")
46
+ return shown("interrupted");
47
+ const reason = facts.ok === false && facts.failure !== undefined
48
+ ? `${facts.failure.kind}: ${facts.failure.message}`
49
+ : undefined;
50
+ if (facts.verdict === "blocked")
51
+ return shown("blocked", reason);
52
+ if (facts.verdict === "timed_out")
53
+ return shown("timed_out", reason);
54
+ if (facts.verdict === "fail" || facts.ok === false)
55
+ return shown("failed", reason);
56
+ if (facts.verdict === "pass")
57
+ return shown("passed");
58
+ if (facts.verdict === "contract_proof_only")
59
+ return shown(facts.mode === "dry-run" ? "dry_run" : "no_verdict");
60
+ return shown("unknown");
61
+ }
62
+ /** A state in words: "passed", "timed out", "no verdict". */
63
+ export function runDisplayLabel(state) {
64
+ return LABELS[state];
65
+ }
66
+ /** A display as one line of text: the label, then why the run failed when it says. */
67
+ function runDisplayLine(display) {
68
+ return display.reason === undefined ? display.label : `${display.label}: ${display.reason}`;
69
+ }
70
+ /**
71
+ * The facts for a run from its bundle. run.json's `outcome` decides when it has one. A bundle
72
+ * without one is in progress, or was recorded before the field existed: its status record, when
73
+ * given and naming the run, then says whether the run is alive and, for the older run, holds its ok.
74
+ * With no record, a bundle whose simulations are still running is live.
75
+ */
76
+ export function bundleDisplayFacts(bundle, status, nowMs = Date.now()) {
77
+ const base = { mode: bundle.mode, verdict: bundle.review?.verdict };
78
+ const outcome = bundle.outcome;
79
+ if (outcome?.state === "interrupted")
80
+ return { ...base, liveness: "interrupted" };
81
+ if (outcome?.state === "finished")
82
+ return {
83
+ ...base,
84
+ liveness: "finished",
85
+ ok: outcome.ok,
86
+ failure: outcome.execution.failures[0],
87
+ };
88
+ if (isRunStatusRecord(status) && status.runId === bundle.runId)
89
+ return {
90
+ ...base,
91
+ liveness: classifyRunStatus(status, nowMs),
92
+ ...(typeof status.outcome?.ok === "boolean" ? { ok: status.outcome.ok } : {}),
93
+ ...(status.outcome?.execution?.failures[0] === undefined
94
+ ? {}
95
+ : { failure: status.outcome.execution.failures[0] }),
96
+ };
97
+ const inProgress = (bundle.simulations ?? []).some((simulation) => simulation?.status === "running");
98
+ return { ...base, liveness: inProgress ? "running" : "finished" };
99
+ }
100
+ /**
101
+ * How review.md says the run ended: the display of the bundle it is rendered from, so a run whose
102
+ * execution failed never reads as a pass beside its participants' verdict.
103
+ */
104
+ export function reviewOutcome(bundle) {
105
+ return runDisplayLine(runDisplay(bundleDisplayFacts(bundle)));
106
+ }
@@ -10,6 +10,8 @@ import { participantEvent, participantIdsOf, participantRecord, participantStrea
10
10
  import { implicitProjectDirectoryExists, readPackageName, validateCwd } from "./project.js";
11
11
  import { loadDryRunInputs } from "./dry-run-inputs.js";
12
12
  import { judgeExecution, judgePreview, OUTCOME_POLICIES, resultOk, verdictText, } from "./judge.js";
13
+ import { reviewOutcome } from "./display.js";
14
+ import { cli } from "../cli/invocation.js";
13
15
  /**
14
16
  * The preview route's run. The run scope closes the run it started on every exit, including
15
17
  * the fail-closed ones. It renders an Observer only when `options.observer` asks for one.
@@ -48,7 +50,7 @@ async function runDryRunInScope(options, scope) {
48
50
  if (!options.dryRun) {
49
51
  return refused(requestedCwd, warnings, {
50
52
  code: "HUMANISH_LIVE_RUN_UNIMPLEMENTED",
51
- message: "humanish run needs a study. List studies with humanish study list, or run humanish run --dry-run for a sample bundle.",
53
+ message: `humanish run needs a study. List studies with ${cli("study list")}, or run ${cli("run --dry-run")} for a sample bundle.`,
52
54
  });
53
55
  }
54
56
  const createdAt = new Date().toISOString();
@@ -123,16 +125,17 @@ async function runDryRunInScope(options, scope) {
123
125
  review: createReviewSummary(judgment.verdict),
124
126
  feedbackCandidates: [],
125
127
  };
126
- const finished = await run.finish(bundle);
127
- const observer = options.observer === undefined ? undefined : await renderPreviewObserver(finished, warnings);
128
128
  // The preview's policy keeps an Observer that did not render a warning: the bundle is still
129
129
  // evidence, and `export` can render it later.
130
130
  const policy = OUTCOME_POLICIES.preview;
131
- const execution = judgeExecution(observer === undefined || observer.ok
132
- ? []
133
- : [{ kind: "evidence", message: observer.error?.message ?? "render failed" }], policy);
134
- const ok = resultOk({ judgment, execution, scorerFailures: [], policy });
135
- await finished.recordOutcome({ ok, execution });
131
+ const execution = judgeExecution([], policy);
132
+ const finished = await run.finish(bundle, {
133
+ ok: resultOk({ judgment, execution, scorerFailures: [], policy }),
134
+ execution,
135
+ policy,
136
+ });
137
+ const observer = options.observer === undefined ? undefined : await renderPreviewObserver(finished, warnings);
138
+ const { ok } = finished.outcome;
136
139
  return {
137
140
  schema: "humanish.run-result.v1",
138
141
  ok,
@@ -175,6 +178,8 @@ Mode: ${bundle.mode}
175
178
 
176
179
  Verdict: ${verdictText(bundle.review.verdict, bundle.mode)}
177
180
 
181
+ Outcome: ${reviewOutcome(bundle)}
182
+
178
183
  ${bundle.review.summary}
179
184
 
180
185
  ## Public-Safety
@@ -91,8 +91,9 @@ export interface Judgment {
91
91
  }
92
92
  /** The judgment for a verdict: a run passes when its verdict is pass, and a dry run passes. */
93
93
  export declare function judgmentOf(verdict: Verdict, dryRun: boolean): Judgment;
94
- /** What kind of execution failure a run recorded. */
95
- type ExecutionFailureKind = "harness" | "provider-cleanup" | "provider-policy" | "sandbox-cleanup" | "evidence" | "cap" | "run";
94
+ /** The kinds of execution failure a run records. */
95
+ export declare const EXECUTION_FAILURE_KINDS: readonly ["harness", "provider-cleanup", "provider-policy", "sandbox-cleanup", "evidence", "cap", "run"];
96
+ type ExecutionFailureKind = (typeof EXECUTION_FAILURE_KINDS)[number];
96
97
  /** One way the run failed as an execution. The message is scrubbed; status.json shows it. */
97
98
  export interface ExecutionFailure {
98
99
  kind: ExecutionFailureKind;
package/dist/run/judge.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // 4. foldScorerFailures, which every route with a scorer applies before the run finishes;
13
13
  // 5. the outcome: judgeExecution applies the route's policy to its execution failures, and
14
14
  // resultOk gives the result's ok.
15
+ import { cli } from "../cli/invocation.js";
15
16
  /**
16
17
  * The participant said it reached the goal having taken no action and said nothing, and no stop
17
18
  * condition ended the session. It most likely saw a blank or loading screen, so it is not a pass.
@@ -108,6 +109,16 @@ export function verdictForStatus(status) {
108
109
  export function judgmentOf(verdict, dryRun) {
109
110
  return { verdict, passed: dryRun || verdict === "pass" };
110
111
  }
112
+ /** The kinds of execution failure a run records. */
113
+ export const EXECUTION_FAILURE_KINDS = [
114
+ "harness",
115
+ "provider-cleanup",
116
+ "provider-policy",
117
+ "sandbox-cleanup",
118
+ "evidence",
119
+ "cap",
120
+ "run",
121
+ ];
111
122
  /**
112
123
  * The execution failure for one sandbox whose release is unconfirmed. `warning` is the route's
113
124
  * release warning, already scrubbed. `recovery` says how to release it by hand; without one,
@@ -116,7 +127,7 @@ export function judgmentOf(verdict, dryRun) {
116
127
  export function sandboxCleanupFailure(owner, warning, runId, recovery) {
117
128
  return {
118
129
  kind: "sandbox-cleanup",
119
- message: `${owner}: ${warning ?? "Sandbox release is unconfirmed."} ${recovery ?? `Reclaim it by recorded id with \`humanish reclaim --run ${runId}\`.`}`,
130
+ message: `${owner}: ${warning ?? "Sandbox release is unconfirmed."} ${recovery ?? `Reclaim it by recorded id with \`${cli(`reclaim --run ${runId}`)}\`.`}`,
120
131
  };
121
132
  }
122
133
  /** Each route's outcome policy, in one place. */
@@ -0,0 +1,13 @@
1
+ /** Each local-only file, relative to the run directory, with why a shared copy omits it. */
2
+ export declare const LOCAL_ONLY_RUN_FILES: ReadonlyMap<string, string>;
3
+ /**
4
+ * Whether a run-relative posix path names a local-only file. Case is ignored, so a request that
5
+ * spells the name differently cannot reach the file on a case-insensitive filesystem.
6
+ */
7
+ export declare function isLocalOnlyRunFile(relativePath: string): boolean;
8
+ /**
9
+ * Whether `filePath` is one of the local-only files of the run at `runRoot` under another
10
+ * spelling: the same device and inode. A case-insensitive filesystem also folds Unicode, so a name
11
+ * check alone cannot rule every spelling out.
12
+ */
13
+ export declare function isLocalOnlyRunFileIdentity(runRoot: string, filePath: string): Promise<boolean>;
@@ -0,0 +1,35 @@
1
+ // Run files that mean something only on the machine that recorded the run. sandbox-receipts.ndjson
2
+ // holds the raw sandbox ids reclaim kills by, and status.json holds the recording process's pid.
3
+ // Bundle export leaves both out of a shared copy, and the Observer servers never hand them out.
4
+ import { lstat } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { SANDBOX_RECEIPTS_ARTIFACT } from "./sandbox-receipts.js";
7
+ import { RUN_STATUS_FILE } from "./status.js";
8
+ /** Each local-only file, relative to the run directory, with why a shared copy omits it. */
9
+ export const LOCAL_ONLY_RUN_FILES = new Map([
10
+ [RUN_STATUS_FILE, "local process status is not a new attempt"],
11
+ [SANDBOX_RECEIPTS_ARTIFACT, "operational journal does not confer a derivative resource lease"],
12
+ ]);
13
+ /**
14
+ * Whether a run-relative posix path names a local-only file. Case is ignored, so a request that
15
+ * spells the name differently cannot reach the file on a case-insensitive filesystem.
16
+ */
17
+ export function isLocalOnlyRunFile(relativePath) {
18
+ return LOCAL_ONLY_RUN_FILES.has(relativePath.toLowerCase());
19
+ }
20
+ /**
21
+ * Whether `filePath` is one of the local-only files of the run at `runRoot` under another
22
+ * spelling: the same device and inode. A case-insensitive filesystem also folds Unicode, so a name
23
+ * check alone cannot rule every spelling out.
24
+ */
25
+ export async function isLocalOnlyRunFileIdentity(runRoot, filePath) {
26
+ const requested = await lstat(filePath, { bigint: true }).catch(() => null);
27
+ if (requested === null)
28
+ return false;
29
+ for (const name of LOCAL_ONLY_RUN_FILES.keys()) {
30
+ const local = await lstat(path.join(runRoot, name), { bigint: true }).catch(() => null);
31
+ if (local !== null && local.dev === requested.dev && local.ino === requested.ino)
32
+ return true;
33
+ }
34
+ return false;
35
+ }
@@ -101,7 +101,7 @@ const DESKTOP_RESOURCE_RATE = {
101
101
  * since an npm install cannot edit this file.
102
102
  */
103
103
  export function unpricedCapMessage(model) {
104
- return `execution.caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${Object.keys(MODEL_RATES).sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
104
+ return `caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${Object.keys(MODEL_RATES).sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
105
105
  }
106
106
  export const DESKTOP_RATE = {
107
107
  usdPerMinute: 0.00888,
@@ -99,8 +99,6 @@ export declare function listWindow(args: {
99
99
  start: number;
100
100
  end: number;
101
101
  };
102
- /** The label for a run's state, in the register the surfaces share. */
103
- export declare function livenessLabel(entry: Pick<RunIndexEntry, "liveness" | "verdict">): string;
104
102
  /** A study as the study list shows it: what is declared, joined to what actually happened. */
105
103
  export interface StudyRow {
106
104
  /**
@@ -228,17 +228,6 @@ export function listWindow(args) {
228
228
  start = Math.max(0, Math.min(start, total - viewport));
229
229
  return { start, end: start + viewport };
230
230
  }
231
- /** The label for a run's state, in the register the surfaces share. */
232
- export function livenessLabel(entry) {
233
- switch (entry.liveness) {
234
- case "running":
235
- return "running";
236
- case "interrupted":
237
- return "interrupted";
238
- default:
239
- return entry.verdict ?? "finished";
240
- }
241
- }
242
231
  /** The addressable handle for a manifest: its filename without directory or extension. */
243
232
  function studyNameFromPath(manifestPath) {
244
233
  const base = manifestPath.split("/").pop() ?? manifestPath;
@@ -0,0 +1,89 @@
1
+ import { type E2BTagSearch } from "../substrates/e2b/sandbox.js";
2
+ import type { E2BDesktopModule } from "../substrates/e2b/sdk.js";
3
+ import { type PreparedOutputRoot } from "./contained-output.js";
4
+ export declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
5
+ export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
6
+ /**
7
+ * killed = kill(id) confirmed; already-gone = the server no longer knows the id (TTL or a prior
8
+ * cleanup got it), or the run's route released it before the signal; kill-failed = the attempt
9
+ * errored (the TTL backstop still applies); unsupported-provider = the receipt names a provider
10
+ * this version cannot reach, so nothing was attempted; pending = the kill had not answered when
11
+ * the receipt was written. `--check` writes running, already-gone or check-failed instead.
12
+ */
13
+ export type ReclaimOutcomeState = "killed" | "already-gone" | "kill-failed" | "unsupported-provider" | "pending" | "running" | "check-failed";
14
+ export interface ReclaimOutcome {
15
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
16
+ sandboxId: string;
17
+ /** The id's digest, which matches its receipt. */
18
+ sandboxIdDigest: string;
19
+ laneId: string;
20
+ /** How reclaim learned of the sandbox: its receipt, a create in this process whose receipt had
21
+ * not landed, or E2B's list of sandboxes tagged with this run. */
22
+ source: "receipt" | "create" | "tag";
23
+ state: ReclaimOutcomeState;
24
+ detail?: string;
25
+ }
26
+ /** E2B's list of sandboxes carrying the run's owner tags. */
27
+ export interface ReclaimTagSearch {
28
+ /** not-run: the search did not start, because an earlier step failed or the run was stopping. */
29
+ status: E2BTagSearch["status"] | "not-run";
30
+ /** Tagged sandboxes no receipt or create named. */
31
+ found: number;
32
+ detail?: string;
33
+ }
34
+ /**
35
+ * clean: every sandbox found is confirmed gone, and the tag search finished with no create still
36
+ * in flight. unconfirmed: a kill did not confirm. running (`--check` only): a sandbox still
37
+ * exists. unknown: nothing failed, but reclaim could not search E2B or a create was still in
38
+ * flight, so a sandbox it never saw may still run.
39
+ */
40
+ export type ReclaimState = "clean" | "unconfirmed" | "running" | "unknown";
41
+ /** One sandbox reclaim acts on, keyed by provider and raw id. */
42
+ export interface Target {
43
+ provider: string;
44
+ rawId: string;
45
+ outcome: ReclaimOutcome;
46
+ }
47
+ /**
48
+ * The sandboxes one reclaim acts on, each once by provider and raw id. The receipt naming every
49
+ * sandbox found, each pending, lands before that sandbox's kill starts, so an exit during the
50
+ * kills leaves a record of each one not yet confirmed gone.
51
+ */
52
+ export declare function targetSet(options: {
53
+ e2b: E2BDesktopModule | undefined;
54
+ check: boolean;
55
+ requestTimeoutMs: number;
56
+ earlier: ReclaimOutcome[];
57
+ persist: ReturnType<typeof receiptWriter>;
58
+ }): {
59
+ snapshot: (carried?: ReclaimOutcome[]) => ReclaimOutcome[];
60
+ /** Add one sandbox; false when it was already in the set. */
61
+ add(provider: string, rawId: string, participant: string, source: ReclaimOutcome["source"], released?: boolean): boolean;
62
+ /** Write the receipt naming every sandbox found so far, then start their kills. */
63
+ start(): Promise<void>;
64
+ /** Wait for every kill, including ones added while waiting. */
65
+ drain(): Promise<void>;
66
+ finish(outcomes: readonly ReclaimOutcome[], state: ReclaimState): Promise<void>;
67
+ };
68
+ /** Search E2B by each distinct tag set; done only when every search finished. */
69
+ export declare function searchTagSets(e2b: E2BDesktopModule | undefined, tagSets: readonly Record<string, string>[], options: {
70
+ requestTimeoutMs: number;
71
+ unavailable: string;
72
+ }): Promise<E2BTagSearch>;
73
+ export declare const GONE: Set<ReclaimOutcomeState>;
74
+ /**
75
+ * The outcomes an earlier reclaim of this directory recorded, by digest. A receipt written before
76
+ * 0.110 names raw ids, which are digested; an unreadable or foreign receipt carries nothing.
77
+ */
78
+ export declare function earlierOutcomes(root: PreparedOutputRoot, label: string): Promise<ReclaimOutcome[]>;
79
+ export declare function rootState(outcomes: readonly ReclaimOutcome[], check: boolean, searched: boolean): ReclaimState;
80
+ /** Resolves when `settled` does, or after `ms` if given, whichever is first. */
81
+ export declare function settledWithin(settled: Promise<void>, ms: number | undefined): Promise<void>;
82
+ /**
83
+ * Writes reclaim-receipt.json in order, one write at a time, each with the latest outcomes. A
84
+ * check writes nothing. A failed write adds one warning and leaves the result unchanged.
85
+ */
86
+ export declare function receiptWriter(root: PreparedOutputRoot, runId: string, receiptCount: number, check: boolean, warnings: string[]): {
87
+ /** Queue one write with these outcomes; resolves once it has landed or failed. */
88
+ write(outcomes: readonly ReclaimOutcome[], state: ReclaimState): Promise<void>;
89
+ };
@@ -0,0 +1,213 @@
1
+ // What one reclaim found and did, sandbox by sandbox: the outcome vocabulary of
2
+ // reclaim-receipt.json, the set of sandboxes a reclaim acts on (each once, with its pending record
3
+ // written before its kill starts), the tag search across the tag sets a run recorded, and the
4
+ // receipt writer. src/run/reclaim.ts decides what to find; this module records what happened.
5
+ import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
6
+ import { destroyE2BSandbox, findE2BSandboxesByTags, inspectE2BSandbox, } from "../substrates/e2b/sandbox.js";
7
+ import { readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
8
+ import { scrubSandboxIds } from "./sandbox-ids.js";
9
+ import { isRecord } from "./type-guards.js";
10
+ export const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
11
+ export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
12
+ /**
13
+ * The sandboxes one reclaim acts on, each once by provider and raw id. The receipt naming every
14
+ * sandbox found, each pending, lands before that sandbox's kill starts, so an exit during the
15
+ * kills leaves a record of each one not yet confirmed gone.
16
+ */
17
+ export function targetSet(options) {
18
+ const { e2b, check, requestTimeoutMs, earlier, persist } = options;
19
+ const targets = new Map();
20
+ const work = [];
21
+ const queued = [];
22
+ let started = false;
23
+ const snapshot = (carried = earlier) => {
24
+ const current = [...targets.values()].map((target) => target.outcome);
25
+ const seen = new Set(current.map((outcome) => outcome.sandboxIdDigest));
26
+ return [...current, ...carried.filter((outcome) => !seen.has(outcome.sandboxIdDigest))];
27
+ };
28
+ const act = (target) => actOn(e2b, target, { check, requestTimeoutMs }).then((settled) => {
29
+ target.outcome = settled;
30
+ void persist.write(snapshot(), "unknown");
31
+ });
32
+ return {
33
+ snapshot,
34
+ /** Add one sandbox; false when it was already in the set. */
35
+ add(provider, rawId, participant, source, released = false) {
36
+ const key = JSON.stringify([provider, rawId]);
37
+ if (targets.has(key))
38
+ return false;
39
+ const pending = outcomeFor(sandboxIdDigest(rawId), participant, source);
40
+ const target = {
41
+ provider,
42
+ rawId,
43
+ outcome: released
44
+ ? { ...pending, state: "already-gone", detail: "its route released it before the stop" }
45
+ : pending,
46
+ };
47
+ targets.set(key, target);
48
+ if (released)
49
+ return true;
50
+ if (started)
51
+ work.push(persist.write(snapshot(), "unknown").then(() => act(target)));
52
+ else
53
+ queued.push(target);
54
+ return true;
55
+ },
56
+ /** Write the receipt naming every sandbox found so far, then start their kills. */
57
+ async start() {
58
+ let size;
59
+ do {
60
+ size = targets.size;
61
+ await persist.write(snapshot(), "unknown");
62
+ } while (targets.size !== size);
63
+ started = true;
64
+ for (const target of queued)
65
+ work.push(act(target));
66
+ },
67
+ /** Wait for every kill, including ones added while waiting. */
68
+ async drain() {
69
+ let count;
70
+ do {
71
+ count = work.length;
72
+ await Promise.all(work);
73
+ } while (work.length !== count);
74
+ },
75
+ async finish(outcomes, state) {
76
+ await persist.write(outcomes, state);
77
+ },
78
+ };
79
+ }
80
+ /** Search E2B by each distinct tag set; done only when every search finished. */
81
+ export async function searchTagSets(e2b, tagSets, options) {
82
+ if (e2b === undefined)
83
+ return { status: "unavailable", sandboxes: [], detail: options.unavailable };
84
+ const distinct = new Map(tagSets.map((tags) => [
85
+ JSON.stringify(Object.entries(tags).sort(([a], [b]) => a.localeCompare(b))),
86
+ tags,
87
+ ]));
88
+ const searches = await Promise.all([...distinct.values()].map((tags) => findE2BSandboxesByTags(e2b, tags, { requestTimeoutMs: options.requestTimeoutMs })));
89
+ const unfinished = searches.find((search) => search.status !== "done");
90
+ const sandboxes = new Map(searches.flatMap((search) => search.sandboxes).map((listed) => [listed.sandboxId, listed]));
91
+ return {
92
+ status: unfinished?.status ?? "done",
93
+ sandboxes: [...sandboxes.values()],
94
+ ...(unfinished?.detail === undefined ? {} : { detail: unfinished.detail }),
95
+ };
96
+ }
97
+ /** Kill (or look up) one sandbox, and say what happened in reclaim's vocabulary. */
98
+ async function actOn(e2b, target, options) {
99
+ const { outcome, provider, rawId } = target;
100
+ if (provider !== "e2b" || e2b === undefined)
101
+ return {
102
+ ...outcome,
103
+ state: "unsupported-provider",
104
+ detail: `provider ${JSON.stringify(provider)} is not supported by this humanish version; nothing was attempted, and that provider's own timeout is the backstop`,
105
+ };
106
+ const attempt = options.check
107
+ ? await inspectE2BSandbox(e2b, rawId, options)
108
+ : await destroyE2BSandbox(e2b, rawId, options);
109
+ const { detail: _previous, ...rest } = outcome;
110
+ // A provider error that quotes the id is scrubbed; the raw id stays in sandbox-receipts.ndjson.
111
+ return "detail" in attempt
112
+ ? { ...rest, state: attempt.state, detail: scrubSandboxIds(attempt.detail, [rawId]) }
113
+ : { ...rest, state: attempt.state };
114
+ }
115
+ export const GONE = new Set(["killed", "already-gone"]);
116
+ const OUTCOME_STATES = new Set([
117
+ "killed",
118
+ "already-gone",
119
+ "kill-failed",
120
+ "unsupported-provider",
121
+ "pending",
122
+ ]);
123
+ /**
124
+ * The outcomes an earlier reclaim of this directory recorded, by digest. A receipt written before
125
+ * 0.110 names raw ids, which are digested; an unreadable or foreign receipt carries nothing.
126
+ */
127
+ export async function earlierOutcomes(root, label) {
128
+ let parsed;
129
+ try {
130
+ const bytes = await readContainedRegularFile(root, RECLAIM_RECEIPT_ARTIFACT);
131
+ if (bytes === null)
132
+ return [];
133
+ parsed = JSON.parse(bytes.toString("utf8"));
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ if (!isRecord(parsed) || parsed.runId !== label || !Array.isArray(parsed.outcomes))
139
+ return [];
140
+ const carried = [];
141
+ for (const entry of parsed.outcomes) {
142
+ if (!isRecord(entry))
143
+ continue;
144
+ const text = (key) => typeof entry[key] === "string" ? entry[key] : undefined;
145
+ const state = text("state");
146
+ const raw = text("sandboxId");
147
+ const digest = text("sandboxIdDigest") ??
148
+ (raw === undefined || raw === REDACTED_SANDBOX_ID ? undefined : sandboxIdDigest(raw));
149
+ if (digest === undefined || state === undefined || !OUTCOME_STATES.has(state))
150
+ continue;
151
+ const source = text("source");
152
+ const detail = text("detail");
153
+ carried.push({
154
+ ...outcomeFor(digest, text("laneId") ?? "unknown", source === "create" || source === "tag" ? source : "receipt"),
155
+ state: state,
156
+ ...(detail === undefined ? {} : { detail }),
157
+ });
158
+ }
159
+ return carried;
160
+ }
161
+ /** A pending outcome, naming the sandbox by digest only. */
162
+ function outcomeFor(digest, participant, source) {
163
+ return {
164
+ sandboxId: REDACTED_SANDBOX_ID,
165
+ sandboxIdDigest: digest,
166
+ laneId: participant,
167
+ source,
168
+ state: "pending",
169
+ };
170
+ }
171
+ export function rootState(outcomes, check, searched) {
172
+ if (check && outcomes.some((outcome) => outcome.state === "running"))
173
+ return "running";
174
+ const gone = outcomes.every((outcome) => GONE.has(outcome.state));
175
+ if (!gone)
176
+ return check ? "unknown" : "unconfirmed";
177
+ return searched ? "clean" : "unknown";
178
+ }
179
+ /** Resolves when `settled` does, or after `ms` if given, whichever is first. */
180
+ export async function settledWithin(settled, ms) {
181
+ if (ms === undefined)
182
+ return settled;
183
+ let timer;
184
+ await Promise.race([
185
+ settled,
186
+ new Promise((resolve) => {
187
+ timer = setTimeout(resolve, ms);
188
+ }),
189
+ ]).finally(() => clearTimeout(timer));
190
+ }
191
+ /**
192
+ * Writes reclaim-receipt.json in order, one write at a time, each with the latest outcomes. A
193
+ * check writes nothing. A failed write adds one warning and leaves the result unchanged.
194
+ */
195
+ export function receiptWriter(root, runId, receiptCount, check, warnings) {
196
+ let chain = Promise.resolve();
197
+ let failed = false;
198
+ return {
199
+ /** Queue one write with these outcomes; resolves once it has landed or failed. */
200
+ write(outcomes, state) {
201
+ if (check)
202
+ return chain;
203
+ const text = `${JSON.stringify({ schema: RECLAIM_RESULT_SCHEMA, at: new Date().toISOString(), runId, state, receiptCount, outcomes }, null, 2)}\n`;
204
+ chain = chain.then(() => writeContainedOutputFile(root, RECLAIM_RECEIPT_ARTIFACT, text, "utf8").catch((error) => {
205
+ if (failed)
206
+ return;
207
+ failed = true;
208
+ warnings.push(`Reclaim ran but its receipt could not be written: ${redactText(toErrorMessage(error))}`);
209
+ }));
210
+ return chain;
211
+ },
212
+ };
213
+ }
@@ -1,28 +1,28 @@
1
1
  import { type E2BDesktopModule } from "../substrates/e2b/sdk.js";
2
+ import { type StoppedSandboxCreates } from "./sandbox-creates.js";
3
+ import { RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, type ReclaimOutcome, type ReclaimState, type ReclaimTagSearch } from "./reclaim-outcomes.js";
2
4
  import { type PreparedRunArtifactPaths } from "./paths.js";
3
- declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
4
- export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
5
- interface ReclaimOutcome {
6
- /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
7
- sandboxId: string;
8
- /** The id's digest, which matches its receipt. */
9
- sandboxIdDigest: string;
10
- laneId: string;
11
- /** killed = kill(id) confirmed; already-gone = the server no longer knows the id (TTL or a
12
- * prior cleanup got it); kill-failed = the attempt errored (the TTL backstop still applies);
13
- * unsupported-provider = the receipt names a provider this version cannot reach, so nothing
14
- * was attempted. */
15
- state: "killed" | "already-gone" | "kill-failed" | "unsupported-provider";
16
- detail?: string;
17
- }
5
+ export { RECLAIM_RECEIPT_ARTIFACT };
18
6
  export interface ReclaimResult {
19
7
  schema: typeof RECLAIM_RESULT_SCHEMA;
8
+ /** True only when `state` is clean. */
20
9
  ok: boolean;
10
+ state: ReclaimState;
11
+ /** `check` kills nothing and writes nothing. */
12
+ mode: "kill" | "check";
21
13
  cwd: string;
22
14
  runId: string;
23
- /** Receipts found in the run's journal (0 = nothing was created, or the run predates receipts). */
15
+ /** Receipts found in the run's journal (0 = nothing journaled, or the run predates receipts). */
24
16
  receiptCount: number;
25
17
  outcomes: ReclaimOutcome[];
18
+ tagSearch: ReclaimTagSearch;
19
+ /** Creates that had not returned when reclaim finished; only the signal handler sees any. */
20
+ createsInFlight: number;
21
+ /**
22
+ * Why there was nothing to look for: `dry-run` when run.json or status.json records the run as
23
+ * a dry run, which creates no sandboxes. Reclaim then contacts E2B for nothing.
24
+ */
25
+ reason?: "dry-run";
26
26
  warnings: string[];
27
27
  error?: {
28
28
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
@@ -33,6 +33,13 @@ export interface ReclaimHooks {
33
33
  /** Tests inject a fake @e2b/desktop module; default loads the real one. */
34
34
  loadModule?: () => Promise<E2BDesktopModule>;
35
35
  requestTimeoutMs?: number;
36
+ /** Ask E2B whether each sandbox still exists, and kill nothing. */
37
+ check?: boolean;
38
+ /** The signal handler's view of this run's creates: the ids they reported, and those still
39
+ * in flight, whose sandboxes are killed as their ids arrive. */
40
+ creates?: StoppedSandboxCreates;
41
+ /** How long to wait for in-flight creates before the tag search runs anyway. */
42
+ createsWaitMs?: number;
36
43
  }
37
44
  export declare function reclaimRunSandboxes(cwd: string, runInput: string, hooks?: ReclaimHooks): Promise<ReclaimResult>;
38
45
  /**
@@ -47,4 +54,3 @@ export declare function reclaimPinnedRunSandboxes(cwd: string, runPaths: Prepare
47
54
  * be running is left alone with the reason (see preflightReclaimDecision).
48
55
  */
49
56
  export declare function reclaimPreflightSandboxes(cwd: string, hooks?: ReclaimHooks): Promise<ReclaimResult>;
50
- export {};