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
@@ -1,19 +1,27 @@
1
- // `humanish reclaim`: kill an interrupted run's sandboxes by the exact ids the run journaled at
2
- // create time (sandbox-receipts.ndjson), and report what happened to each. It reads one file inside
3
- // the managed run dir, kills by id, and never lists the account: an account-wide operation once
4
- // destroyed unrelated infrastructure. Without it, orphaned sandboxes run until the server-side
5
- // create-time TTL.
1
+ // `humanish reclaim`: stop an interrupted run's sandboxes and report what happened to each. It
2
+ // finds them three ways: the exact ids the run journaled at create time (sandbox-receipts.ndjson),
3
+ // the ids a create in this process reported before its receipt landed (the signal handler only),
4
+ // and E2B's list of sandboxes tagged with the run's owner tags (sandboxOwnerTags), which covers a
5
+ // sandbox whose id never reached the run. The list is filtered server-side by the run's tags and
6
+ // each result is checked against every tag again, so it can only return this run's sandboxes; an
7
+ // account-wide operation once destroyed unrelated infrastructure. Kills run concurrently, and
8
+ // reclaim-receipt.json is written before the first kill and after each one, so an exit at any
9
+ // point leaves a record of every sandbox not yet confirmed gone. `--check` asks E2B the same
10
+ // questions and kills nothing.
6
11
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
7
- import { containedPathAbsent, readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
12
+ import { containedPathAbsent, readContainedRegularFile, } from "./contained-output.js";
8
13
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
9
- import { resolveRunPath } from "./locate.js";
10
- import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
11
- import { destroyE2BSandbox } from "../substrates/e2b/sandbox.js";
12
- import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
13
- import { scrubSandboxIds } from "./sandbox-ids.js";
14
+ import { readRunJsonIfExists, resolveRunPath } from "./locate.js";
15
+ import { RUN_BUNDLE_FILE } from "./bundle.js";
16
+ import { RUN_STATUS_FILE } from "./status.js";
17
+ import { isRecord } from "./type-guards.js";
18
+ import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
19
+ import { sandboxOwnerTags, } from "./sandbox-creates.js";
20
+ import { earlierOutcomes, GONE, RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, receiptWriter, rootState, searchTagSets, settledWithin, targetSet, } from "./reclaim-outcomes.js";
21
+ import { redactText, toErrorMessage } from "../evidence/redaction.js";
14
22
  import { runIdOf } from "./paths.js";
15
- const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
16
- export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
23
+ import { cli } from "../cli/invocation.js";
24
+ export { RECLAIM_RECEIPT_ARTIFACT };
17
25
  export function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
18
26
  return reclaimRun(cwd, runInput, () => resolveRunPath(cwd, runInput), hooks);
19
27
  }
@@ -24,61 +32,90 @@ export function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
24
32
  export function reclaimPinnedRunSandboxes(cwd, runPaths, hooks = {}) {
25
33
  return reclaimRun(cwd, runIdOf(runPaths), () => Promise.resolve(runPaths), hooks);
26
34
  }
27
- async function reclaimRun(cwd, runInput, locate, hooks) {
28
- const warnings = [];
29
- const base = {
35
+ function emptyResult(cwd, runId, hooks, warnings) {
36
+ return {
30
37
  schema: RECLAIM_RESULT_SCHEMA,
38
+ ok: false,
39
+ state: "unknown",
40
+ mode: hooks.check === true ? "check" : "kill",
31
41
  cwd,
32
- runId: runInput,
42
+ runId,
33
43
  receiptCount: 0,
34
44
  outcomes: [],
45
+ tagSearch: { status: "not-run", found: 0 },
46
+ createsInFlight: 0,
35
47
  warnings,
36
48
  };
49
+ }
50
+ async function reclaimRun(cwd, runInput, locate, hooks) {
51
+ const warnings = [];
52
+ const base = emptyResult(cwd, runInput, hooks, warnings);
37
53
  if (e2bDebugMode())
38
- return { ...base, ok: false, error: E2B_DEBUG_REFUSAL };
54
+ return { ...base, error: E2B_DEBUG_REFUSAL };
39
55
  const runPaths = await locate();
40
56
  if (!runPaths) {
41
57
  return {
42
58
  ...base,
43
- ok: false,
44
59
  error: {
45
60
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND",
46
- message: `No run found for "${runInput}" (use \`humanish runs\` to list runs).`,
61
+ message: `No run found for "${runInput}" (use \`${cli("runs")}\` to list runs).`,
47
62
  },
48
63
  };
49
64
  }
50
65
  const runId = runIdOf(runPaths);
51
- const journal = await reclaimJournal(runPaths, hooks);
52
- if (journal.kind === "empty") {
53
- // Empty: nothing journaled means either no sandbox was ever created (cheap interrupt:
54
- // nothing to reclaim) or the run predates receipts (0.35.x and earlier, where the create-time TTL
55
- // is the only backstop for those). Either way there is no id to act on, and saying so beats
56
- // pretending a scan happened.
57
- warnings.push("No sandbox-receipts.ndjson in this run dir: either no sandbox was created before the interrupt, or the run predates create-time receipts. Nothing to reclaim by id; server-side kill-on-timeout covers anything that did exist.");
58
- return { ...base, runId, ok: true };
59
- }
60
- if (journal.kind === "module-unavailable") {
66
+ // The signal handler passes the creates it stopped and decides from what they reported.
67
+ if (hooks.creates === undefined && (await recordedDryRun(runPaths)))
68
+ return { ...base, ok: true, state: "clean", runId, reason: "dry-run" };
69
+ const reclaimed = await reclaimRoot(runPaths, runId, hooks, warnings);
70
+ if (reclaimed.kind === "module-unavailable")
61
71
  return {
62
72
  ...base,
63
73
  runId,
64
- receiptCount: journal.receiptCount,
65
- ok: false,
66
- error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: journal.message },
74
+ receiptCount: reclaimed.receiptCount,
75
+ error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: reclaimed.message },
67
76
  };
68
- }
69
- if (journal.kind === "unreadable") {
77
+ if (reclaimed.kind === "unreadable")
70
78
  return {
71
79
  ...base,
72
80
  runId,
73
- ok: false,
74
81
  error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: UNREADABLE_MESSAGE },
75
82
  };
83
+ const { state, receiptCount, outcomes, tagSearch, createsInFlight } = reclaimed;
84
+ return {
85
+ ...base,
86
+ ok: state === "clean",
87
+ state,
88
+ runId,
89
+ receiptCount,
90
+ outcomes,
91
+ tagSearch,
92
+ createsInFlight,
93
+ };
94
+ }
95
+ /**
96
+ * Whether the run records that it was a dry run: run.json and status.json, each one present, say
97
+ * `dry-run`. A run with neither record is not taken for one. A journal with any line in it, or an
98
+ * earlier reclaim receipt that names a sandbox, means a create ran, so that run is reclaimed as a
99
+ * live one whatever its records say.
100
+ */
101
+ async function recordedDryRun(runPaths) {
102
+ const modes = [];
103
+ for (const file of [RUN_BUNDLE_FILE, RUN_STATUS_FILE]) {
104
+ const record = await readRunJsonIfExists(runPaths, file);
105
+ if (isRecord(record))
106
+ modes.push(record.mode);
107
+ }
108
+ if (modes.length === 0 || modes.some((mode) => mode !== "dry-run"))
109
+ return false;
110
+ if (!(await containedPathAbsent(runPaths, RECLAIM_RECEIPT_ARTIFACT))) {
111
+ const earlier = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
112
+ if (!isRecord(earlier) || !Array.isArray(earlier.outcomes) || earlier.outcomes.length > 0)
113
+ return false;
76
114
  }
77
- const { receiptCount, outcomes } = journal;
78
- const result = { ...base, runId, receiptCount, outcomes, ok: allGone(outcomes) };
79
- // Keep the reclaim record next to the run it cleaned: what was attempted, what happened, when.
80
- await writeReclaimReceipt(runPaths, runId, receiptCount, outcomes, warnings);
81
- return result;
115
+ if (await containedPathAbsent(runPaths, SANDBOX_RECEIPTS_ARTIFACT))
116
+ return true;
117
+ const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
118
+ return journal !== null && journal.toString("utf8").trim() === "";
82
119
  }
83
120
  /**
84
121
  * Kill the sandboxes left by interrupted `humanish study check` probes. A probe journals under
@@ -88,52 +125,71 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
88
125
  */
89
126
  export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
90
127
  const warnings = [];
128
+ const base = emptyResult(cwd, "preflight", hooks, warnings);
129
+ if (e2bDebugMode())
130
+ return { ...base, error: E2B_DEBUG_REFUSAL };
91
131
  const outcomes = [];
92
132
  let receiptCount = 0;
93
- let unreadable = false;
94
- const base = { schema: RECLAIM_RESULT_SCHEMA, cwd, runId: "preflight", warnings };
95
- if (e2bDebugMode())
96
- return { ...base, ok: false, receiptCount, outcomes, error: E2B_DEBUG_REFUSAL };
133
+ const states = [];
134
+ // Not run until a journal is reclaimed; then done unless one journal's search was not.
135
+ const tagSearch = { status: "not-run", found: 0 };
97
136
  const journals = await listPreflightJournals(cwd);
98
137
  if (journals.length === 0) {
99
- warnings.push("No preflight journals in .humanish/preflight: every probe's teardown was confirmed, or no probe ran here. Nothing to reclaim by id.");
138
+ warnings.push("No preflight journals in .humanish/preflight: every probe's teardown was confirmed, or no probe ran here. Nothing to reclaim.");
100
139
  }
101
140
  for (const journal of journals) {
102
141
  const decision = await preflightReclaimDecision(journal, Date.now());
103
142
  if (!decision.reclaim) {
104
143
  warnings.push(`Preflight ${journal.id} left alone: ${decision.reason}.`);
144
+ states.push("unknown");
105
145
  continue;
106
146
  }
107
- const reclaimed = await reclaimJournal(journal.root, hooks);
147
+ const reclaimed = await reclaimRoot(journal.root, journal.id, hooks, warnings);
108
148
  if (reclaimed.kind === "unreadable") {
109
149
  // The receipt may name a live sandbox; the journal stays for a later reclaim.
110
- unreadable = true;
111
150
  warnings.push(`Preflight ${journal.id} left alone: ${UNREADABLE_MESSAGE}`);
151
+ states.push("unknown");
112
152
  continue;
113
153
  }
114
154
  if (reclaimed.kind === "module-unavailable") {
115
155
  return {
116
156
  ...base,
117
- ok: false,
118
157
  receiptCount: receiptCount + reclaimed.receiptCount,
119
158
  outcomes,
120
159
  error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: reclaimed.message },
121
160
  };
122
161
  }
123
- if (reclaimed.kind === "done") {
124
- receiptCount += reclaimed.receiptCount;
125
- outcomes.push(...reclaimed.outcomes);
126
- if (!allGone(reclaimed.outcomes)) {
127
- // The journal stays so a later reclaim can try again; the receipt says what happened.
128
- await writeReclaimReceipt(journal.root, journal.id, reclaimed.receiptCount, reclaimed.outcomes, warnings);
129
- continue;
130
- }
162
+ receiptCount += reclaimed.receiptCount;
163
+ outcomes.push(...reclaimed.outcomes);
164
+ const { state } = reclaimed;
165
+ states.push(state);
166
+ tagSearch.found += reclaimed.tagSearch.found;
167
+ if (tagSearch.status === "not-run" || reclaimed.tagSearch.status !== "done") {
168
+ tagSearch.status = reclaimed.tagSearch.status;
169
+ if (reclaimed.tagSearch.detail !== undefined)
170
+ tagSearch.detail = reclaimed.tagSearch.detail;
171
+ }
172
+ // The journal stays until its sandboxes are known to be gone, so a later reclaim can try
173
+ // again; its receipt says what happened. Elapsed time alone never removes it: a probe can
174
+ // create later than its journal says, and a journal from before owner lines cannot show that
175
+ // nothing it made is still running. A check never removes it.
176
+ if (state !== "clean" || hooks.check === true) {
177
+ if (state === "unknown" && reclaimed.exhausted && hooks.check !== true)
178
+ warnings.push(`Preflight ${journal.id} is kept: humanish cannot rule out a sandbox it made. Once the E2B dashboard shows no sandbox for it, delete .humanish/preflight/${journal.id}.`);
179
+ continue;
131
180
  }
132
181
  await discardPreflightJournal(journal, [RECLAIM_RECEIPT_ARTIFACT]).catch((error) => {
133
182
  warnings.push(`Preflight ${journal.id} was reclaimed but its journal could not be removed: ${redactText(toErrorMessage(error))}`);
134
183
  });
135
184
  }
136
- return { ...base, ok: !unreadable && allGone(outcomes), receiptCount, outcomes };
185
+ const state = combinedState(states);
186
+ return { ...base, ok: state === "clean", state, receiptCount, outcomes, tagSearch };
187
+ }
188
+ function combinedState(states) {
189
+ for (const worst of ["running", "unconfirmed", "unknown"])
190
+ if (states.includes(worst))
191
+ return worst;
192
+ return "clean";
137
193
  }
138
194
  // In debug mode the SDK returns true from Sandbox.kill without contacting E2B, so every receipt
139
195
  // would read killed and preflight journals would be discarded for sandboxes that may still run.
@@ -142,69 +198,112 @@ const E2B_DEBUG_REFUSAL = {
142
198
  message: `${E2B_DEBUG_ENV}=true puts the E2B SDK in debug mode, where Sandbox.kill reports success without contacting E2B. Nothing was killed and the receipts were kept; unset ${E2B_DEBUG_ENV} and run reclaim again.`,
143
199
  };
144
200
  const UNREADABLE_MESSAGE = "sandbox-receipts.ndjson is present but could not be read safely (not a single regular file, or a read error). Nothing was killed and the receipts were kept; check the file, then run reclaim again.";
145
- /** Kill every sandbox one receipts journal records, once per sandbox, by exact id. */
146
- async function reclaimJournal(root, hooks) {
201
+ /**
202
+ * Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
203
+ * creates this process reported, and E2B's tagged list, each once by exact id.
204
+ */
205
+ async function reclaimRoot(root, label, hooks, warnings) {
147
206
  const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
148
- if (bytes === null)
149
- return (await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT))
150
- ? { kind: "empty" }
151
- : { kind: "unreadable" };
152
- const receipts = parseSandboxReceipts(bytes.toString("utf8"));
153
- // Load the E2B SDK only when an E2B receipt needs it.
207
+ if (bytes === null && !(await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT)))
208
+ return { kind: "unreadable" };
209
+ const journal = bytes === null ? "" : bytes.toString("utf8");
210
+ const receipts = parseSandboxReceipts(journal);
211
+ const owners = parseSandboxOwners(journal, label);
212
+ const check = hooks.check === true;
213
+ const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
214
+ // The SDK lists by tag as well as killing by id, so it is needed even with no receipt. Without
215
+ // it, an E2B receipt cannot be acted on; with no E2B receipt, only the tag search is lost.
154
216
  let e2b;
217
+ let moduleError;
155
218
  try {
156
- if (receipts.some((receipt) => receipt.provider === "e2b"))
157
- e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
219
+ e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
158
220
  }
159
221
  catch (error) {
160
- return {
161
- kind: "module-unavailable",
162
- receiptCount: receipts.length,
163
- message: `Cannot load @e2b/desktop to kill by id: ${redactText(toErrorMessage(error))}`,
164
- };
222
+ moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
223
+ if (receipts.some((receipt) => receipt.provider === "e2b") || hooks.creates !== undefined)
224
+ return { kind: "module-unavailable", receiptCount: receipts.length, message: moduleError };
165
225
  }
166
- const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
167
- const destroy = async (receipt) => {
168
- switch (receipt.provider) {
169
- case "e2b":
170
- return destroyE2BSandbox(e2b, receipt.sandboxId, { requestTimeoutMs });
171
- default:
172
- return {
173
- state: "unsupported-provider",
174
- detail: `provider ${JSON.stringify(receipt.provider)} is not supported by this humanish version; nothing was attempted, and that provider's own timeout is the backstop`,
175
- };
176
- }
177
- };
178
- const outcomes = [];
179
- const seen = new Set();
226
+ // An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
227
+ // and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
228
+ const earlier = check ? [] : await earlierOutcomes(root, label);
229
+ const set = targetSet({
230
+ e2b,
231
+ check,
232
+ requestTimeoutMs,
233
+ earlier,
234
+ persist: receiptWriter(root, label, receipts.length, check, warnings),
235
+ });
236
+ // The watch replays the creates reported so far, synchronously, then each one an in-flight
237
+ // create reports, until reclaim returns. The replayed ones are matched to receipts first, so a
238
+ // sandbox the route already released is skipped whichever record names it.
239
+ const replayed = new Map();
240
+ let replaying = true;
241
+ const unwatch = hooks.creates?.watch((sandbox) => {
242
+ if (replaying)
243
+ replayed.set(sandbox.sandboxId, sandbox);
244
+ else
245
+ set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
246
+ });
247
+ replaying = false;
180
248
  for (const receipt of receipts) {
181
- // One attempt per sandbox, however many receipts raced.
182
- const key = JSON.stringify([receipt.provider, receipt.sandboxId]);
183
- if (seen.has(key))
184
- continue;
185
- seen.add(key);
186
- // The result and reclaim-receipt.json name the sandbox by digest, and a provider error that
187
- // quotes the id is scrubbed; the raw id stays in sandbox-receipts.ndjson.
188
- const attempt = await destroy(receipt);
189
- outcomes.push({
190
- sandboxId: REDACTED_SANDBOX_ID,
191
- sandboxIdDigest: sandboxIdDigest(receipt.sandboxId),
192
- laneId: receipt.laneId,
193
- ...("detail" in attempt
194
- ? { ...attempt, detail: scrubSandboxIds(attempt.detail, [receipt.sandboxId]) }
195
- : attempt),
196
- });
197
- }
198
- return { kind: "done", receiptCount: receipts.length, outcomes };
199
- }
200
- function allGone(outcomes) {
201
- return outcomes.every((outcome) => outcome.state === "killed" || outcome.state === "already-gone");
202
- }
203
- async function writeReclaimReceipt(root, runId, receiptCount, outcomes, warnings) {
204
- try {
205
- await writeContainedOutputFile(root, RECLAIM_RECEIPT_ARTIFACT, `${JSON.stringify({ schema: RECLAIM_RESULT_SCHEMA, at: new Date().toISOString(), runId, receiptCount, outcomes }, null, 2)}\n`, "utf8");
249
+ const released = receipt.provider === "e2b" && replayed.get(receipt.sandboxId)?.released;
250
+ set.add(receipt.provider, receipt.sandboxId, receipt.laneId, "receipt", released === true);
206
251
  }
207
- catch (error) {
208
- warnings.push(`Reclaim ran but its receipt could not be written: ${redactText(toErrorMessage(error))}`);
252
+ for (const sandbox of replayed.values())
253
+ set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
254
+ await set.start();
255
+ if (hooks.creates !== undefined)
256
+ await settledWithin(hooks.creates.settled, hooks.createsWaitMs);
257
+ // The tags the run recorded before each create, and this directory's own: a copied or moved
258
+ // run directory computes other tags than the ones its sandboxes carry.
259
+ const search = await searchTagSets(e2b, [...owners, sandboxOwnerTags(root)], {
260
+ requestTimeoutMs,
261
+ unavailable: moduleError ?? "no E2B SDK",
262
+ });
263
+ let found = 0;
264
+ for (const listed of search.sandboxes) {
265
+ const metadata = listed.metadata ?? {};
266
+ const participant = metadata.participantId ?? metadata.kind ?? "unknown";
267
+ if (set.add("e2b", listed.sandboxId, participant, "tag"))
268
+ found += 1;
209
269
  }
270
+ const tagSearch = {
271
+ status: search.status,
272
+ found,
273
+ ...(search.detail === undefined ? {} : { detail: search.detail }),
274
+ };
275
+ if (search.status !== "done")
276
+ warnings.push(`Could not search E2B for sandboxes tagged with ${label}: ${search.detail ?? search.status}. A sandbox whose id never reached a receipt would not be found.`);
277
+ // A create that reports its id while the search or a kill is still running is killed too.
278
+ await set.drain();
279
+ unwatch?.();
280
+ const createsInFlight = hooks.creates?.inFlight() ?? 0;
281
+ // An empty search proves nothing for a run that never recorded its tags: one from humanish
282
+ // 0.110.0 or earlier, or whose owner lines were lost. The process that made the creates knows.
283
+ const provenance = owners.length > 0 || hooks.creates !== undefined;
284
+ if (!provenance)
285
+ warnings.push(`${label} records no owner tags, so a sandbox whose id never reached a receipt cannot be ruled out: the run is from humanish 0.110.0 or earlier, it created no sandbox, or its journal lost those lines. Each sandbox's create-time timeout is the backstop.`);
286
+ // A carried sandbox from a create or a tag search carries this run's tags; if the finished
287
+ // search, with the tags the run recorded, no longer lists it, it is gone. One from an older
288
+ // receipt, or from a run with no recorded tags, is left as it was recorded.
289
+ const covered = provenance && tagSearch.status === "done";
290
+ const outcomes = set.snapshot(earlier.map((outcome) => GONE.has(outcome.state) || outcome.source === "receipt" || !covered
291
+ ? outcome
292
+ : {
293
+ ...outcome,
294
+ state: "already-gone",
295
+ detail: "an earlier reclaim left it unconfirmed, and E2B no longer lists it with this run's tags",
296
+ }));
297
+ const searched = tagSearch.status === "done" && createsInFlight === 0;
298
+ const state = rootState(outcomes, check, searched && provenance);
299
+ await set.finish(outcomes, state);
300
+ return {
301
+ kind: "done",
302
+ state,
303
+ exhausted: searched && outcomes.every((outcome) => GONE.has(outcome.state)),
304
+ receiptCount: receipts.length,
305
+ outcomes,
306
+ tagSearch,
307
+ createsInFlight,
308
+ };
210
309
  }
@@ -34,27 +34,26 @@ export interface RunResult {
34
34
  message: string;
35
35
  };
36
36
  }
37
- export interface CleanupResourceResult {
37
+ /**
38
+ * A resource line of cleanup.json. `humanish cleanup` wrote the file through 0.110.0, and verify
39
+ * still reads one a run kept. v0.12.23 through v0.15.0 killed sandboxes and wrote `killed`.
40
+ */
41
+ export interface StoredCleanupResourceResult {
38
42
  provider: RunProviderResource["provider"];
39
43
  kind: RunProviderResource["kind"];
40
- /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
44
+ /** "[redacted-sandbox-id]" from 0.110; earlier files hold the raw id. */
41
45
  id: string;
42
- /** The id's digest, which matches its receipt; absent when the record had neither. */
43
46
  idDigest?: string;
44
- /** Cleanup reads recorded evidence and never kills a sandbox, so it never writes `killed`. */
45
- status: "already_clean" | "failed" | "skipped";
47
+ status: "already_clean" | "failed" | "skipped" | "killed";
46
48
  message: string;
47
49
  }
48
- /** A resource line as a stored cleanup.json holds it. v0.12.23 through v0.15.0 killed sandboxes. */
49
- export interface StoredCleanupResourceResult extends Omit<CleanupResourceResult, "status"> {
50
- status: CleanupResourceResult["status"] | "killed";
51
- }
52
50
  export interface CleanupAdapterResult {
53
51
  id: string;
54
52
  ok: boolean;
55
53
  message: string;
56
54
  }
57
- export interface CleanupResult {
55
+ /** cleanup.json as any release through 0.110.0 wrote it. */
56
+ export interface StoredCleanupResult {
58
57
  schema: typeof CLEANUP_SCHEMA;
59
58
  ok: boolean;
60
59
  cwd: string;
@@ -65,23 +64,14 @@ export interface CleanupResult {
65
64
  checkedAt: string;
66
65
  summary: {
67
66
  resources: number;
68
- /** Always 0 now; written so readers from earlier releases still accept the file. */
69
67
  killed: number;
70
68
  alreadyClean: number;
71
69
  failed: number;
72
70
  skipped: number;
73
71
  };
74
- resources: CleanupResourceResult[];
72
+ resources: StoredCleanupResourceResult[];
75
73
  adapterResults: CleanupAdapterResult[];
76
74
  warnings: string[];
77
- error?: {
78
- code: "HUMANISH_RUN_NOT_FOUND" | "HUMANISH_INVALID_RUN_BUNDLE";
79
- message: string;
80
- };
81
- }
82
- /** cleanup.json as any release wrote it; see StoredCleanupResourceResult. */
83
- export interface StoredCleanupResult extends Omit<CleanupResult, "resources"> {
84
- resources: StoredCleanupResourceResult[];
85
75
  }
86
76
  export interface RunPointer {
87
77
  schema: "humanish.latest-run.v1";
@@ -1,3 +1,3 @@
1
- // The run command's options and result, the cleanup result (cleanup.json) and the latest-run
2
- // pointer (latest.json). None of them are stored in run.json.
1
+ // The run command's options and result, the cleanup.json shape releases through 0.110.0 wrote, and
2
+ // the latest-run pointer (latest.json). None of them are stored in run.json.
3
3
  export const CLEANUP_SCHEMA = "humanish.cleanup-result.v1";
@@ -2,6 +2,7 @@ import { AnalysisCostCache } from "./run-index-analysis.js";
2
2
  import type { RunAnalysisCost } from "./run-cost.js";
3
3
  import { type RunLiveness, type RunStatusRecord } from "./status.js";
4
4
  import { type RunStudyProvenance } from "./study-provenance.js";
5
+ import type { ExecutionFailure } from "./judge.js";
5
6
  declare const RUN_INDEX_SCHEMA = "humanish.run-index.v1";
6
7
  export interface RunIndexEntry {
7
8
  runId: string;
@@ -20,6 +21,14 @@ export interface RunIndexEntry {
20
21
  updatedAt?: string;
21
22
  completedAt?: string;
22
23
  verdict?: string;
24
+ /**
25
+ * The run's own ok, from run.json's outcome as status.json copies it, or from the status record
26
+ * alone for a run recorded before run.json carried one. Absent when the run recorded none.
27
+ * runDisplay (src/run/display.ts) reads it with `verdict` and `liveness`.
28
+ */
29
+ ok?: boolean;
30
+ /** The first execution failure that failed the run, beside `ok`. */
31
+ failure?: ExecutionFailure;
23
32
  participants?: {
24
33
  total: number;
25
34
  reachedGoal: number;
@@ -92,13 +101,14 @@ interface BundleFacts {
92
101
  estimatedTotalUsd?: number | null;
93
102
  fullyEstimated?: unknown;
94
103
  };
104
+ outcome?: unknown;
95
105
  }
96
106
  /**
97
107
  * One run's liveness from files already read: its status record when usable, else its bundle.
98
108
  * `readRunIndex` reads in the same order; `humanish verify` calls this with both in hand. `record`
99
109
  * is the status record the liveness came from, when there was one.
100
110
  */
101
- export declare function runLiveness(runId: string, status: unknown, bundle: Pick<BundleFacts, "simulations">, nowMs: number): {
111
+ export declare function runLiveness(runId: string, status: unknown, bundle: Pick<BundleFacts, "simulations" | "outcome">, nowMs: number): {
102
112
  liveness: RunLiveness;
103
113
  record?: RunStatusRecord;
104
114
  };
@@ -19,6 +19,7 @@ import { AnalysisCostCache, readIndexedAnalysisCost } from "./run-index-analysis
19
19
  import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, } from "./status.js";
20
20
  import { studyProvenanceOf } from "./study-provenance.js";
21
21
  import { RUN_BUNDLE_FILE } from "./bundle.js";
22
+ import { isRunOutcome } from "./bundle-shape.js";
22
23
  const RUN_INDEX_SCHEMA = "humanish.run-index.v1";
23
24
  /**
24
25
  * A process-lifetime cache. Deliberately explicit rather than module-global state: a caller that
@@ -88,6 +89,10 @@ function entryFromStatus(record, nowMs) {
88
89
  updatedAt: record.updatedAt,
89
90
  ...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
90
91
  ...(record.outcome?.verdict === undefined ? {} : { verdict: record.outcome.verdict }),
92
+ ...(typeof record.outcome?.ok === "boolean" ? { ok: record.outcome.ok } : {}),
93
+ ...(record.outcome?.execution?.failures[0] === undefined
94
+ ? {}
95
+ : { failure: record.outcome.execution.failures[0] }),
91
96
  ...(record.outcome?.participants === undefined
92
97
  ? {}
93
98
  : { participants: record.outcome.participants }),
@@ -112,6 +117,8 @@ function usableStatusRecord(raw, runId) {
112
117
  * reading of "it started, and nothing here says it finished" is interrupted, not finished.
113
118
  */
114
119
  function bundleLiveness(bundle) {
120
+ if (isRunOutcome(bundle.outcome))
121
+ return bundle.outcome.state;
115
122
  const inProgress = (bundle.simulations ?? []).some((simulation) => simulation?.status === "running");
116
123
  return inProgress ? "interrupted" : "finished";
117
124
  }
@@ -129,6 +136,8 @@ function studyEntry(study) {
129
136
  return study === undefined ? {} : { study };
130
137
  }
131
138
  function entryFromBundle(runId, bundle) {
139
+ const outcome = isRunOutcome(bundle.outcome) ? bundle.outcome : undefined;
140
+ const failure = outcome?.state === "finished" ? outcome.execution.failures[0] : undefined;
132
141
  return {
133
142
  runId,
134
143
  derivedFrom: "bundle",
@@ -137,6 +146,8 @@ function entryFromBundle(runId, bundle) {
137
146
  ...studyEntry(studyProvenanceOf(bundle)),
138
147
  ...(bundle.createdAt === undefined ? {} : { startedAt: bundle.createdAt }),
139
148
  ...(bundle.review?.verdict === undefined ? {} : { verdict: bundle.review.verdict }),
149
+ ...(outcome === undefined ? {} : { ok: outcome.ok }),
150
+ ...(failure === undefined ? {} : { failure }),
140
151
  ...(bundle.review?.participants === undefined
141
152
  ? {}
142
153
  : { participants: bundle.review.participants }),
@@ -1,6 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveRunsRoot } from "./paths.js";
4
+ import { cli } from "../cli/invocation.js";
4
5
  /**
5
6
  * The message for a run that is not there: how to start one when the project has no runs, or how
6
7
  * to list them when it has. Only the runs directory's entry names are read.
@@ -10,6 +11,6 @@ export async function runNotFoundMessage(cwdInput, runInput) {
10
11
  const entries = await readdir(resolveRunsRoot(cwd)).catch(() => []);
11
12
  const hasRun = entries.some((name) => !name.startsWith(".") && !name.endsWith(".json"));
12
13
  return hasRun
13
- ? `No run ${runInput}; humanish runs lists them.`
14
- : `No runs in ${cwd} yet; start one with humanish run first-run.`;
14
+ ? `No run ${runInput}; ${cli("runs")} lists them.`
15
+ : `No runs in ${cwd} yet; start one with ${cli("run first-run")}.`;
15
16
  }