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
@@ -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,7 @@ 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";
13
14
  /**
14
15
  * The preview route's run. The run scope closes the run it started on every exit, including
15
16
  * the fail-closed ones. It renders an Observer only when `options.observer` asks for one.
@@ -123,16 +124,17 @@ async function runDryRunInScope(options, scope) {
123
124
  review: createReviewSummary(judgment.verdict),
124
125
  feedbackCandidates: [],
125
126
  };
126
- const finished = await run.finish(bundle);
127
- const observer = options.observer === undefined ? undefined : await renderPreviewObserver(finished, warnings);
128
127
  // The preview's policy keeps an Observer that did not render a warning: the bundle is still
129
128
  // evidence, and `export` can render it later.
130
129
  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 });
130
+ const execution = judgeExecution([], policy);
131
+ const finished = await run.finish(bundle, {
132
+ ok: resultOk({ judgment, execution, scorerFailures: [], policy }),
133
+ execution,
134
+ policy,
135
+ });
136
+ const observer = options.observer === undefined ? undefined : await renderPreviewObserver(finished, warnings);
137
+ const { ok } = finished.outcome;
136
138
  return {
137
139
  schema: "humanish.run-result.v1",
138
140
  ok,
@@ -175,6 +177,8 @@ Mode: ${bundle.mode}
175
177
 
176
178
  Verdict: ${verdictText(bundle.review.verdict, bundle.mode)}
177
179
 
180
+ Outcome: ${reviewOutcome(bundle)}
181
+
178
182
  ${bundle.review.summary}
179
183
 
180
184
  ## 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
@@ -108,6 +108,16 @@ export function verdictForStatus(status) {
108
108
  export function judgmentOf(verdict, dryRun) {
109
109
  return { verdict, passed: dryRun || verdict === "pass" };
110
110
  }
111
+ /** The kinds of execution failure a run records. */
112
+ export const EXECUTION_FAILURE_KINDS = [
113
+ "harness",
114
+ "provider-cleanup",
115
+ "provider-policy",
116
+ "sandbox-cleanup",
117
+ "evidence",
118
+ "cap",
119
+ "run",
120
+ ];
111
121
  /**
112
122
  * The execution failure for one sandbox whose release is unconfirmed. `warning` is the route's
113
123
  * release warning, already scrubbed. `recovery` says how to release it by hand; without one,
@@ -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,25 +1,23 @@
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
- sandboxId: string;
7
- laneId: string;
8
- /** killed = kill(id) confirmed; already-gone = the server no longer knows the id (TTL or a
9
- * prior cleanup got it); kill-failed = the attempt errored (the TTL backstop still applies);
10
- * unsupported-provider = the receipt names a provider this version cannot reach, so nothing
11
- * was attempted. */
12
- state: "killed" | "already-gone" | "kill-failed" | "unsupported-provider";
13
- detail?: string;
14
- }
5
+ export { RECLAIM_RECEIPT_ARTIFACT };
15
6
  export interface ReclaimResult {
16
7
  schema: typeof RECLAIM_RESULT_SCHEMA;
8
+ /** True only when `state` is clean. */
17
9
  ok: boolean;
10
+ state: ReclaimState;
11
+ /** `check` kills nothing and writes nothing. */
12
+ mode: "kill" | "check";
18
13
  cwd: string;
19
14
  runId: string;
20
- /** 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). */
21
16
  receiptCount: number;
22
17
  outcomes: ReclaimOutcome[];
18
+ tagSearch: ReclaimTagSearch;
19
+ /** Creates that had not returned when reclaim finished; only the signal handler sees any. */
20
+ createsInFlight: number;
23
21
  warnings: string[];
24
22
  error?: {
25
23
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
@@ -30,6 +28,13 @@ export interface ReclaimHooks {
30
28
  /** Tests inject a fake @e2b/desktop module; default loads the real one. */
31
29
  loadModule?: () => Promise<E2BDesktopModule>;
32
30
  requestTimeoutMs?: number;
31
+ /** Ask E2B whether each sandbox still exists, and kill nothing. */
32
+ check?: boolean;
33
+ /** The signal handler's view of this run's creates: the ids they reported, and those still
34
+ * in flight, whose sandboxes are killed as their ids arrive. */
35
+ creates?: StoppedSandboxCreates;
36
+ /** How long to wait for in-flight creates before the tag search runs anyway. */
37
+ createsWaitMs?: number;
33
38
  }
34
39
  export declare function reclaimRunSandboxes(cwd: string, runInput: string, hooks?: ReclaimHooks): Promise<ReclaimResult>;
35
40
  /**
@@ -44,4 +49,3 @@ export declare function reclaimPinnedRunSandboxes(cwd: string, runPaths: Prepare
44
49
  * be running is left alone with the reason (see preflightReclaimDecision).
45
50
  */
46
51
  export declare function reclaimPreflightSandboxes(cwd: string, hooks?: ReclaimHooks): Promise<ReclaimResult>;
47
- export {};