humanish 0.109.1 → 0.110.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/ARCHITECTURE.md +14 -11
  2. package/CHANGELOG.md +24 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +97 -48
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +11 -0
  11. package/dist/actors/local-agent/cli.js +103 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +18 -11
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/comms.js +25 -7
  16. package/dist/cli/commands/observe.js +4 -2
  17. package/dist/cli/commands/run-command.js +4 -3
  18. package/dist/cli/commands/run-signals.d.ts +2 -0
  19. package/dist/cli/commands/run-signals.js +96 -44
  20. package/dist/cli/commands/runs.d.ts +5 -0
  21. package/dist/cli/commands/runs.js +89 -67
  22. package/dist/cli/commands/setup.js +19 -5
  23. package/dist/cli/commands/study-format.js +29 -2
  24. package/dist/cli/commands/study-route-computer-use.js +2 -0
  25. package/dist/cli/commands/study-route-scripted.js +2 -0
  26. package/dist/cli/commands/study-route-shared-world.js +2 -0
  27. package/dist/cli/commands/study-route-terminal.js +2 -0
  28. package/dist/cli/commands/study.js +4 -3
  29. package/dist/cli/commands/tui.js +4 -3
  30. package/dist/cli/commands/watch.js +2 -2
  31. package/dist/cli/doctor.d.ts +7 -2
  32. package/dist/cli/doctor.js +47 -8
  33. package/dist/cli/findings.d.ts +103 -0
  34. package/dist/cli/findings.js +316 -0
  35. package/dist/cli/first-run-path.d.ts +5 -4
  36. package/dist/cli/first-run-path.js +14 -7
  37. package/dist/cli/io.d.ts +17 -3
  38. package/dist/cli/io.js +22 -4
  39. package/dist/cli/product-sentence.d.ts +3 -3
  40. package/dist/cli/product-sentence.js +3 -3
  41. package/dist/comms/receiving-runtime.js +1 -1
  42. package/dist/comms/setup.js +1 -1
  43. package/dist/evidence/redaction.d.ts +7 -3
  44. package/dist/evidence/redaction.js +68 -24
  45. package/dist/feedback/export-bundle.js +6 -1
  46. package/dist/feedback/export.js +3 -0
  47. package/dist/keys/env-file.d.ts +4 -0
  48. package/dist/keys/env-file.js +17 -0
  49. package/dist/keys/key-resolution.d.ts +7 -0
  50. package/dist/keys/key-resolution.js +10 -8
  51. package/dist/observer/data.d.ts +14 -1
  52. package/dist/observer/data.js +19 -9
  53. package/dist/observer/library.d.ts +4 -0
  54. package/dist/observer/library.js +18 -15
  55. package/dist/observer/render.js +8 -2
  56. package/dist/observer/run-routes.d.ts +2 -0
  57. package/dist/observer/run-routes.js +43 -34
  58. package/dist/observer-app.html +3 -3
  59. package/dist/routes/computer-use/bundle.js +3 -1
  60. package/dist/routes/computer-use/fanout-records.js +1 -1
  61. package/dist/routes/computer-use/live-phase.d.ts +1 -0
  62. package/dist/routes/computer-use/participant-execution.js +3 -1
  63. package/dist/routes/computer-use/participant-model.js +5 -2
  64. package/dist/routes/computer-use/plan.js +4 -4
  65. package/dist/routes/computer-use/preflight.js +2 -2
  66. package/dist/routes/computer-use/result.js +17 -28
  67. package/dist/routes/computer-use/single-bundle.js +7 -1
  68. package/dist/routes/computer-use/types.d.ts +6 -0
  69. package/dist/routes/scripted/bundle.js +4 -2
  70. package/dist/routes/scripted/plan.js +2 -2
  71. package/dist/routes/scripted/result.d.ts +3 -5
  72. package/dist/routes/scripted/result.js +16 -22
  73. package/dist/routes/scripted/scenario.js +4 -4
  74. package/dist/routes/scripted/types.d.ts +1 -0
  75. package/dist/routes/shared-world/bundle-records.js +6 -1
  76. package/dist/routes/shared-world/bundle.js +2 -0
  77. package/dist/routes/shared-world/plan.js +1 -1
  78. package/dist/routes/shared-world/result.js +22 -22
  79. package/dist/routes/shared-world/route.js +1 -1
  80. package/dist/routes/shared-world/types.d.ts +2 -0
  81. package/dist/routes/terminal/bundle.js +4 -2
  82. package/dist/routes/terminal/credentials.js +1 -1
  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 +23 -17
  87. package/dist/routes/terminal/plan.js +4 -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/session.js +1 -1
  91. package/dist/routes/terminal/types.d.ts +3 -0
  92. package/dist/run/bundle-shape.d.ts +3 -1
  93. package/dist/run/bundle-shape.js +26 -1
  94. package/dist/run/bundle.d.ts +30 -0
  95. package/dist/run/display.d.ts +59 -0
  96. package/dist/run/display.js +106 -0
  97. package/dist/run/dry-run.js +11 -7
  98. package/dist/run/judge.d.ts +3 -2
  99. package/dist/run/judge.js +10 -0
  100. package/dist/run/pricing.js +1 -1
  101. package/dist/run/projection.d.ts +0 -2
  102. package/dist/run/projection.js +0 -11
  103. package/dist/run/reclaim-outcomes.d.ts +89 -0
  104. package/dist/run/reclaim-outcomes.js +213 -0
  105. package/dist/run/reclaim.d.ts +18 -14
  106. package/dist/run/reclaim.js +179 -105
  107. package/dist/run/results.d.ts +11 -18
  108. package/dist/run/results.js +2 -2
  109. package/dist/run/run-index.d.ts +11 -1
  110. package/dist/run/run-index.js +11 -0
  111. package/dist/run/run.d.ts +34 -18
  112. package/dist/run/run.js +177 -72
  113. package/dist/run/sandbox-creates.d.ts +59 -0
  114. package/dist/run/sandbox-creates.js +138 -0
  115. package/dist/run/sandbox-ids.d.ts +53 -0
  116. package/dist/run/sandbox-ids.js +198 -0
  117. package/dist/run/sandbox-receipts.d.ts +16 -0
  118. package/dist/run/sandbox-receipts.js +70 -0
  119. package/dist/run/stats.d.ts +5 -0
  120. package/dist/run/stats.js +8 -4
  121. package/dist/run/status.d.ts +6 -9
  122. package/dist/run/status.js +7 -20
  123. package/dist/run/stored-runs.d.ts +5 -14
  124. package/dist/run/stored-runs.js +31 -137
  125. package/dist/run/streams.d.ts +3 -2
  126. package/dist/run-study.js +6 -1
  127. package/dist/study/doctor.d.ts +1 -1
  128. package/dist/study/doctor.js +7 -4
  129. package/dist/study/init-templates.js +2 -2
  130. package/dist/study/init.js +14 -3
  131. package/dist/study/keys.d.ts +12 -0
  132. package/dist/study/keys.js +32 -0
  133. package/dist/study/persona.d.ts +4 -0
  134. package/dist/study/persona.js +4 -2
  135. package/dist/study/types.d.ts +1 -1
  136. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  137. package/dist/substrates/e2b/peer-install.js +161 -0
  138. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  139. package/dist/substrates/e2b/sandbox.js +128 -35
  140. package/dist/substrates/e2b/sdk.d.ts +33 -0
  141. package/dist/substrates/e2b/sdk.js +21 -21
  142. package/dist/substrates/shell.d.ts +2 -0
  143. package/dist/substrates/shell.js +4 -0
  144. package/dist/tui-app.js +133 -133
  145. package/dist/verify/artifacts.d.ts +4 -0
  146. package/dist/verify/artifacts.js +8 -0
  147. package/dist/verify/liveness.d.ts +19 -3
  148. package/dist/verify/liveness.js +53 -28
  149. package/dist/verify/verify.d.ts +4 -1
  150. package/dist/verify/verify.js +27 -3
  151. package/docs/architecture/actor-contract.md +2 -1
  152. package/docs/architecture/observer.md +22 -7
  153. package/docs/architecture/restricted-codex-analysis.md +1 -1
  154. package/docs/architecture/task-protocol-support.md +13 -13
  155. package/docs/architecture/terminal-product-route.md +1 -1
  156. package/docs/contracts/run-bundle.md +90 -7
  157. package/docs/contracts/schemas.md +39 -22
  158. package/docs/contracts/study-analysis.md +45 -0
  159. package/docs/contracts/study-costs.md +8 -0
  160. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  161. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  162. package/docs/principles/invariants-and-defaults.md +11 -8
  163. package/package.json +5 -3
  164. package/skills/humanish/SKILL.md +18 -4
@@ -1,18 +1,23 @@
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
14
  import { resolveRunPath } from "./locate.js";
10
- import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
11
- import { destroyE2BSandbox } from "../substrates/e2b/sandbox.js";
15
+ import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
16
+ import { sandboxOwnerTags, } from "./sandbox-creates.js";
17
+ import { earlierOutcomes, GONE, RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, receiptWriter, rootState, searchTagSets, settledWithin, targetSet, } from "./reclaim-outcomes.js";
12
18
  import { redactText, toErrorMessage } from "../evidence/redaction.js";
13
19
  import { runIdOf } from "./paths.js";
14
- const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
15
- export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
20
+ export { RECLAIM_RECEIPT_ARTIFACT };
16
21
  export function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
17
22
  return reclaimRun(cwd, runInput, () => resolveRunPath(cwd, runInput), hooks);
18
23
  }
@@ -23,23 +28,30 @@ export function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
23
28
  export function reclaimPinnedRunSandboxes(cwd, runPaths, hooks = {}) {
24
29
  return reclaimRun(cwd, runIdOf(runPaths), () => Promise.resolve(runPaths), hooks);
25
30
  }
26
- async function reclaimRun(cwd, runInput, locate, hooks) {
27
- const warnings = [];
28
- const base = {
31
+ function emptyResult(cwd, runId, hooks, warnings) {
32
+ return {
29
33
  schema: RECLAIM_RESULT_SCHEMA,
34
+ ok: false,
35
+ state: "unknown",
36
+ mode: hooks.check === true ? "check" : "kill",
30
37
  cwd,
31
- runId: runInput,
38
+ runId,
32
39
  receiptCount: 0,
33
40
  outcomes: [],
41
+ tagSearch: { status: "not-run", found: 0 },
42
+ createsInFlight: 0,
34
43
  warnings,
35
44
  };
45
+ }
46
+ async function reclaimRun(cwd, runInput, locate, hooks) {
47
+ const warnings = [];
48
+ const base = emptyResult(cwd, runInput, hooks, warnings);
36
49
  if (e2bDebugMode())
37
- return { ...base, ok: false, error: E2B_DEBUG_REFUSAL };
50
+ return { ...base, error: E2B_DEBUG_REFUSAL };
38
51
  const runPaths = await locate();
39
52
  if (!runPaths) {
40
53
  return {
41
54
  ...base,
42
- ok: false,
43
55
  error: {
44
56
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND",
45
57
  message: `No run found for "${runInput}" (use \`humanish runs\` to list runs).`,
@@ -47,37 +59,31 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
47
59
  };
48
60
  }
49
61
  const runId = runIdOf(runPaths);
50
- const journal = await reclaimJournal(runPaths, hooks);
51
- if (journal.kind === "empty") {
52
- // Empty: nothing journaled means either no sandbox was ever created (cheap interrupt:
53
- // nothing to reclaim) or the run predates receipts (0.35.x and earlier, where the create-time TTL
54
- // is the only backstop for those). Either way there is no id to act on, and saying so beats
55
- // pretending a scan happened.
56
- 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.");
57
- return { ...base, runId, ok: true };
58
- }
59
- if (journal.kind === "module-unavailable") {
62
+ const reclaimed = await reclaimRoot(runPaths, runId, hooks, warnings);
63
+ if (reclaimed.kind === "module-unavailable")
60
64
  return {
61
65
  ...base,
62
66
  runId,
63
- receiptCount: journal.receiptCount,
64
- ok: false,
65
- error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: journal.message },
67
+ receiptCount: reclaimed.receiptCount,
68
+ error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: reclaimed.message },
66
69
  };
67
- }
68
- if (journal.kind === "unreadable") {
70
+ if (reclaimed.kind === "unreadable")
69
71
  return {
70
72
  ...base,
71
73
  runId,
72
- ok: false,
73
74
  error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: UNREADABLE_MESSAGE },
74
75
  };
75
- }
76
- const { receiptCount, outcomes } = journal;
77
- const result = { ...base, runId, receiptCount, outcomes, ok: allGone(outcomes) };
78
- // Keep the reclaim record next to the run it cleaned: what was attempted, what happened, when.
79
- await writeReclaimReceipt(runPaths, runId, receiptCount, outcomes, warnings);
80
- return result;
76
+ const { state, receiptCount, outcomes, tagSearch, createsInFlight } = reclaimed;
77
+ return {
78
+ ...base,
79
+ ok: state === "clean",
80
+ state,
81
+ runId,
82
+ receiptCount,
83
+ outcomes,
84
+ tagSearch,
85
+ createsInFlight,
86
+ };
81
87
  }
82
88
  /**
83
89
  * Kill the sandboxes left by interrupted `humanish study check` probes. A probe journals under
@@ -87,52 +93,71 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
87
93
  */
88
94
  export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
89
95
  const warnings = [];
96
+ const base = emptyResult(cwd, "preflight", hooks, warnings);
97
+ if (e2bDebugMode())
98
+ return { ...base, error: E2B_DEBUG_REFUSAL };
90
99
  const outcomes = [];
91
100
  let receiptCount = 0;
92
- let unreadable = false;
93
- const base = { schema: RECLAIM_RESULT_SCHEMA, cwd, runId: "preflight", warnings };
94
- if (e2bDebugMode())
95
- return { ...base, ok: false, receiptCount, outcomes, error: E2B_DEBUG_REFUSAL };
101
+ const states = [];
102
+ // Not run until a journal is reclaimed; then done unless one journal's search was not.
103
+ const tagSearch = { status: "not-run", found: 0 };
96
104
  const journals = await listPreflightJournals(cwd);
97
105
  if (journals.length === 0) {
98
- warnings.push("No preflight journals in .humanish/preflight: every probe's teardown was confirmed, or no probe ran here. Nothing to reclaim by id.");
106
+ warnings.push("No preflight journals in .humanish/preflight: every probe's teardown was confirmed, or no probe ran here. Nothing to reclaim.");
99
107
  }
100
108
  for (const journal of journals) {
101
109
  const decision = await preflightReclaimDecision(journal, Date.now());
102
110
  if (!decision.reclaim) {
103
111
  warnings.push(`Preflight ${journal.id} left alone: ${decision.reason}.`);
112
+ states.push("unknown");
104
113
  continue;
105
114
  }
106
- const reclaimed = await reclaimJournal(journal.root, hooks);
115
+ const reclaimed = await reclaimRoot(journal.root, journal.id, hooks, warnings);
107
116
  if (reclaimed.kind === "unreadable") {
108
117
  // The receipt may name a live sandbox; the journal stays for a later reclaim.
109
- unreadable = true;
110
118
  warnings.push(`Preflight ${journal.id} left alone: ${UNREADABLE_MESSAGE}`);
119
+ states.push("unknown");
111
120
  continue;
112
121
  }
113
122
  if (reclaimed.kind === "module-unavailable") {
114
123
  return {
115
124
  ...base,
116
- ok: false,
117
125
  receiptCount: receiptCount + reclaimed.receiptCount,
118
126
  outcomes,
119
127
  error: { code: "HUMANISH_RECLAIM_MODULE_UNAVAILABLE", message: reclaimed.message },
120
128
  };
121
129
  }
122
- if (reclaimed.kind === "done") {
123
- receiptCount += reclaimed.receiptCount;
124
- outcomes.push(...reclaimed.outcomes);
125
- if (!allGone(reclaimed.outcomes)) {
126
- // The journal stays so a later reclaim can try again; the receipt says what happened.
127
- await writeReclaimReceipt(journal.root, journal.id, reclaimed.receiptCount, reclaimed.outcomes, warnings);
128
- continue;
129
- }
130
+ receiptCount += reclaimed.receiptCount;
131
+ outcomes.push(...reclaimed.outcomes);
132
+ const { state } = reclaimed;
133
+ states.push(state);
134
+ tagSearch.found += reclaimed.tagSearch.found;
135
+ if (tagSearch.status === "not-run" || reclaimed.tagSearch.status !== "done") {
136
+ tagSearch.status = reclaimed.tagSearch.status;
137
+ if (reclaimed.tagSearch.detail !== undefined)
138
+ tagSearch.detail = reclaimed.tagSearch.detail;
139
+ }
140
+ // The journal stays until its sandboxes are known to be gone, so a later reclaim can try
141
+ // again; its receipt says what happened. Elapsed time alone never removes it: a probe can
142
+ // create later than its journal says, and a journal from before owner lines cannot show that
143
+ // nothing it made is still running. A check never removes it.
144
+ if (state !== "clean" || hooks.check === true) {
145
+ if (state === "unknown" && reclaimed.exhausted && hooks.check !== true)
146
+ 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}.`);
147
+ continue;
130
148
  }
131
149
  await discardPreflightJournal(journal, [RECLAIM_RECEIPT_ARTIFACT]).catch((error) => {
132
150
  warnings.push(`Preflight ${journal.id} was reclaimed but its journal could not be removed: ${redactText(toErrorMessage(error))}`);
133
151
  });
134
152
  }
135
- return { ...base, ok: !unreadable && allGone(outcomes), receiptCount, outcomes };
153
+ const state = combinedState(states);
154
+ return { ...base, ok: state === "clean", state, receiptCount, outcomes, tagSearch };
155
+ }
156
+ function combinedState(states) {
157
+ for (const worst of ["running", "unconfirmed", "unknown"])
158
+ if (states.includes(worst))
159
+ return worst;
160
+ return "clean";
136
161
  }
137
162
  // In debug mode the SDK returns true from Sandbox.kill without contacting E2B, so every receipt
138
163
  // would read killed and preflight journals would be discarded for sandboxes that may still run.
@@ -141,63 +166,112 @@ const E2B_DEBUG_REFUSAL = {
141
166
  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.`,
142
167
  };
143
168
  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.";
144
- /** Kill every sandbox one receipts journal records, once per sandbox, by exact id. */
145
- async function reclaimJournal(root, hooks) {
169
+ /**
170
+ * Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
171
+ * creates this process reported, and E2B's tagged list, each once by exact id.
172
+ */
173
+ async function reclaimRoot(root, label, hooks, warnings) {
146
174
  const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
147
- if (bytes === null)
148
- return (await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT))
149
- ? { kind: "empty" }
150
- : { kind: "unreadable" };
151
- const receipts = parseSandboxReceipts(bytes.toString("utf8"));
152
- // Load the E2B SDK only when an E2B receipt needs it.
175
+ if (bytes === null && !(await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT)))
176
+ return { kind: "unreadable" };
177
+ const journal = bytes === null ? "" : bytes.toString("utf8");
178
+ const receipts = parseSandboxReceipts(journal);
179
+ const owners = parseSandboxOwners(journal, label);
180
+ const check = hooks.check === true;
181
+ const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
182
+ // The SDK lists by tag as well as killing by id, so it is needed even with no receipt. Without
183
+ // it, an E2B receipt cannot be acted on; with no E2B receipt, only the tag search is lost.
153
184
  let e2b;
185
+ let moduleError;
154
186
  try {
155
- if (receipts.some((receipt) => receipt.provider === "e2b"))
156
- e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
187
+ e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
157
188
  }
158
189
  catch (error) {
159
- return {
160
- kind: "module-unavailable",
161
- receiptCount: receipts.length,
162
- message: `Cannot load @e2b/desktop to kill by id: ${redactText(toErrorMessage(error))}`,
163
- };
190
+ moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
191
+ if (receipts.some((receipt) => receipt.provider === "e2b") || hooks.creates !== undefined)
192
+ return { kind: "module-unavailable", receiptCount: receipts.length, message: moduleError };
164
193
  }
165
- const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
166
- const destroy = async (receipt) => {
167
- switch (receipt.provider) {
168
- case "e2b":
169
- return destroyE2BSandbox(e2b, receipt.sandboxId, { requestTimeoutMs });
170
- default:
171
- return {
172
- state: "unsupported-provider",
173
- 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`,
174
- };
175
- }
176
- };
177
- const outcomes = [];
178
- const seen = new Set();
194
+ // An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
195
+ // and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
196
+ const earlier = check ? [] : await earlierOutcomes(root, label);
197
+ const set = targetSet({
198
+ e2b,
199
+ check,
200
+ requestTimeoutMs,
201
+ earlier,
202
+ persist: receiptWriter(root, label, receipts.length, check, warnings),
203
+ });
204
+ // The watch replays the creates reported so far, synchronously, then each one an in-flight
205
+ // create reports, until reclaim returns. The replayed ones are matched to receipts first, so a
206
+ // sandbox the route already released is skipped whichever record names it.
207
+ const replayed = new Map();
208
+ let replaying = true;
209
+ const unwatch = hooks.creates?.watch((sandbox) => {
210
+ if (replaying)
211
+ replayed.set(sandbox.sandboxId, sandbox);
212
+ else
213
+ set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
214
+ });
215
+ replaying = false;
179
216
  for (const receipt of receipts) {
180
- // One attempt per sandbox, however many receipts raced.
181
- const key = JSON.stringify([receipt.provider, receipt.sandboxId]);
182
- if (seen.has(key))
183
- continue;
184
- seen.add(key);
185
- outcomes.push({
186
- sandboxId: receipt.sandboxId,
187
- laneId: receipt.laneId,
188
- ...(await destroy(receipt)),
189
- });
190
- }
191
- return { kind: "done", receiptCount: receipts.length, outcomes };
192
- }
193
- function allGone(outcomes) {
194
- return outcomes.every((outcome) => outcome.state === "killed" || outcome.state === "already-gone");
195
- }
196
- async function writeReclaimReceipt(root, runId, receiptCount, outcomes, warnings) {
197
- try {
198
- await writeContainedOutputFile(root, RECLAIM_RECEIPT_ARTIFACT, `${JSON.stringify({ schema: RECLAIM_RESULT_SCHEMA, at: new Date().toISOString(), runId, receiptCount, outcomes }, null, 2)}\n`, "utf8");
217
+ const released = receipt.provider === "e2b" && replayed.get(receipt.sandboxId)?.released;
218
+ set.add(receipt.provider, receipt.sandboxId, receipt.laneId, "receipt", released === true);
199
219
  }
200
- catch (error) {
201
- warnings.push(`Reclaim ran but its receipt could not be written: ${redactText(toErrorMessage(error))}`);
220
+ for (const sandbox of replayed.values())
221
+ set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
222
+ await set.start();
223
+ if (hooks.creates !== undefined)
224
+ await settledWithin(hooks.creates.settled, hooks.createsWaitMs);
225
+ // The tags the run recorded before each create, and this directory's own: a copied or moved
226
+ // run directory computes other tags than the ones its sandboxes carry.
227
+ const search = await searchTagSets(e2b, [...owners, sandboxOwnerTags(root)], {
228
+ requestTimeoutMs,
229
+ unavailable: moduleError ?? "no E2B SDK",
230
+ });
231
+ let found = 0;
232
+ for (const listed of search.sandboxes) {
233
+ const metadata = listed.metadata ?? {};
234
+ const participant = metadata.participantId ?? metadata.kind ?? "unknown";
235
+ if (set.add("e2b", listed.sandboxId, participant, "tag"))
236
+ found += 1;
202
237
  }
238
+ const tagSearch = {
239
+ status: search.status,
240
+ found,
241
+ ...(search.detail === undefined ? {} : { detail: search.detail }),
242
+ };
243
+ if (search.status !== "done")
244
+ 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.`);
245
+ // A create that reports its id while the search or a kill is still running is killed too.
246
+ await set.drain();
247
+ unwatch?.();
248
+ const createsInFlight = hooks.creates?.inFlight() ?? 0;
249
+ // An empty search proves nothing for a run that never recorded its tags: one from humanish
250
+ // 0.110.0 or earlier, or whose owner lines were lost. The process that made the creates knows.
251
+ const provenance = owners.length > 0 || hooks.creates !== undefined;
252
+ if (!provenance)
253
+ 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.`);
254
+ // A carried sandbox from a create or a tag search carries this run's tags; if the finished
255
+ // search, with the tags the run recorded, no longer lists it, it is gone. One from an older
256
+ // receipt, or from a run with no recorded tags, is left as it was recorded.
257
+ const covered = provenance && tagSearch.status === "done";
258
+ const outcomes = set.snapshot(earlier.map((outcome) => GONE.has(outcome.state) || outcome.source === "receipt" || !covered
259
+ ? outcome
260
+ : {
261
+ ...outcome,
262
+ state: "already-gone",
263
+ detail: "an earlier reclaim left it unconfirmed, and E2B no longer lists it with this run's tags",
264
+ }));
265
+ const searched = tagSearch.status === "done" && createsInFlight === 0;
266
+ const state = rootState(outcomes, check, searched && provenance);
267
+ await set.finish(outcomes, state);
268
+ return {
269
+ kind: "done",
270
+ state,
271
+ exhausted: searched && outcomes.every((outcome) => GONE.has(outcome.state)),
272
+ receiptCount: receipts.length,
273
+ outcomes,
274
+ tagSearch,
275
+ createsInFlight,
276
+ };
203
277
  }
@@ -34,24 +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"];
44
+ /** "[redacted-sandbox-id]" from 0.110; earlier files hold the raw id. */
40
45
  id: string;
41
- /** Cleanup reads recorded evidence and never kills a sandbox, so it never writes `killed`. */
42
- status: "already_clean" | "failed" | "skipped";
46
+ idDigest?: string;
47
+ status: "already_clean" | "failed" | "skipped" | "killed";
43
48
  message: string;
44
49
  }
45
- /** A resource line as a stored cleanup.json holds it. v0.12.23 through v0.15.0 killed sandboxes. */
46
- export interface StoredCleanupResourceResult extends Omit<CleanupResourceResult, "status"> {
47
- status: CleanupResourceResult["status"] | "killed";
48
- }
49
50
  export interface CleanupAdapterResult {
50
51
  id: string;
51
52
  ok: boolean;
52
53
  message: string;
53
54
  }
54
- export interface CleanupResult {
55
+ /** cleanup.json as any release through 0.110.0 wrote it. */
56
+ export interface StoredCleanupResult {
55
57
  schema: typeof CLEANUP_SCHEMA;
56
58
  ok: boolean;
57
59
  cwd: string;
@@ -62,23 +64,14 @@ export interface CleanupResult {
62
64
  checkedAt: string;
63
65
  summary: {
64
66
  resources: number;
65
- /** Always 0 now; written so readers from earlier releases still accept the file. */
66
67
  killed: number;
67
68
  alreadyClean: number;
68
69
  failed: number;
69
70
  skipped: number;
70
71
  };
71
- resources: CleanupResourceResult[];
72
+ resources: StoredCleanupResourceResult[];
72
73
  adapterResults: CleanupAdapterResult[];
73
74
  warnings: string[];
74
- error?: {
75
- code: "HUMANISH_RUN_NOT_FOUND" | "HUMANISH_INVALID_RUN_BUNDLE";
76
- message: string;
77
- };
78
- }
79
- /** cleanup.json as any release wrote it; see StoredCleanupResourceResult. */
80
- export interface StoredCleanupResult extends Omit<CleanupResult, "resources"> {
81
- resources: StoredCleanupResourceResult[];
82
75
  }
83
76
  export interface RunPointer {
84
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 }),
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,