humanish 0.140.0 → 0.141.0

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 (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actors/codex/restricted-participant.js +1 -1
  3. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  4. package/dist/actors/computer-use/external-handoff.js +83 -0
  5. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  6. package/dist/actors/computer-use/loop/observation.js +17 -1
  7. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  8. package/dist/analysis/analysis-limits.d.ts +6 -0
  9. package/dist/analysis/analysis-limits.js +6 -0
  10. package/dist/analysis/analysis-lock.d.ts +8 -0
  11. package/dist/analysis/analysis-lock.js +129 -0
  12. package/dist/analysis/automatic-completion.js +7 -0
  13. package/dist/analysis/execute.js +1 -1
  14. package/dist/analysis/job.d.ts +1 -0
  15. package/dist/analysis/job.js +6 -2
  16. package/dist/analysis/recording-ended.d.ts +16 -0
  17. package/dist/analysis/recording-ended.js +29 -0
  18. package/dist/analysis/service.d.ts +1 -3
  19. package/dist/analysis/service.js +23 -50
  20. package/dist/analysis/source.d.ts +1 -1
  21. package/dist/browser-control/protocol.d.ts +8 -0
  22. package/dist/browser-control/protocol.js +21 -0
  23. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  24. package/dist/cli/commands/analysis-signals.js +28 -6
  25. package/dist/cli/commands/runs.js +8 -2
  26. package/dist/cli/commands/study-route-computer-use.js +2 -2
  27. package/dist/cli/commands/study-route-scripted.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study-route-terminal.js +2 -2
  30. package/dist/cli/findings.d.ts +23 -0
  31. package/dist/cli/findings.js +80 -4
  32. package/dist/cli/io.js +1 -0
  33. package/dist/evidence/browser-hygiene.d.ts +12 -1
  34. package/dist/evidence/browser-hygiene.js +45 -2
  35. package/dist/feedback/export-bundle.js +1 -1
  36. package/dist/guest/external-links.d.ts +14 -0
  37. package/dist/guest/external-links.js +48 -0
  38. package/dist/guest/runtime-desktop.js +5 -2
  39. package/dist/observer/data.d.ts +4 -0
  40. package/dist/observer/run-routes.js +12 -3
  41. package/dist/observer-app.html +2 -2
  42. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  43. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  44. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  45. package/dist/routes/computer-use/live-phase.js +1 -1
  46. package/dist/routes/computer-use/setup.js +2 -2
  47. package/dist/routes/shared-world/route.js +1 -1
  48. package/dist/run/display.d.ts +6 -1
  49. package/dist/run/display.js +11 -0
  50. package/dist/run/host-suspension.js +4 -1
  51. package/dist/run/preflight-receipts.d.ts +3 -12
  52. package/dist/run/preflight-receipts.js +7 -67
  53. package/dist/run/process-owner.d.ts +28 -0
  54. package/dist/run/process-owner.js +83 -0
  55. package/dist/run/projection.js +1 -1
  56. package/dist/run/reclaim.d.ts +6 -0
  57. package/dist/run/reclaim.js +32 -2
  58. package/dist/run/route-shell.js +1 -1
  59. package/dist/run/run-index.d.ts +3 -1
  60. package/dist/run/run-index.js +23 -11
  61. package/dist/run/run.d.ts +16 -5
  62. package/dist/run/run.js +25 -10
  63. package/dist/run/status.d.ts +57 -2
  64. package/dist/run/status.js +87 -1
  65. package/dist/run/stored-runs.d.ts +3 -0
  66. package/dist/run/stored-runs.js +9 -4
  67. package/dist/study/run-study-events.d.ts +7 -1
  68. package/dist/substrates/e2b/desktop-browser.js +3 -1
  69. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  70. package/dist/substrates/e2b/desktop-executor.js +3 -0
  71. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  72. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  73. package/dist/substrates/local/runtime-release.js +8 -8
  74. package/dist/tui-app.js +115 -115
  75. package/docs/architecture/observer.md +7 -3
  76. package/docs/contracts/schemas.md +24 -3
  77. package/docs/contracts/study-analysis.md +1 -1
  78. package/docs/product/automatic-analysis.md +29 -7
  79. package/package.json +1 -1
  80. package/skills/humanish/SKILL.md +108 -5
@@ -1,4 +1,6 @@
1
+ import { externalHandoffReader } from "../../../substrates/e2b/desktop-handoff.js";
1
2
  import { createE2BDesktopExecutor } from "../../../substrates/e2b/desktop-executor.js";
3
+ import { e2bShell } from "../../../substrates/e2b/shell.js";
2
4
  import { participantInbox, planParticipantComms } from "./comms.js";
3
5
  import { participantBrowserStateObserver } from "./fidelity.js";
4
6
  import { networkHooks, planParticipantNetwork } from "./network.js";
@@ -37,7 +39,7 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
37
39
  let finalization;
38
40
  async function prepare() {
39
41
  if (preparationStarted || finalization)
40
- throw new Error("Participant desktop preparation can only start once, before finalization.");
42
+ throw new Error("Participant desktop preparation can only start once, before the desktop is released.");
41
43
  preparationStarted = true;
42
44
  const desktop = await acquireParticipantDesktop(ctx, state);
43
45
  // The device claim is verified in the sandbox, and fails closed.
@@ -52,7 +54,7 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
52
54
  async function openSession() {
53
55
  const { desktop, allocation } = state;
54
56
  if (!prepared || !desktop || !allocation || opened || finalization)
55
- throw new Error("The participant desktop must be prepared and may only be opened once, before finalization.");
57
+ throw new Error("The participant desktop must be prepared and may only be opened once, before the desktop is released.");
56
58
  opened = true;
57
59
  await startParticipantStream(ctx, state, desktop);
58
60
  const inbox = participantInbox({
@@ -64,21 +66,29 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
64
66
  });
65
67
  // Capture that started pairs each checkpoint with the time of the read before it.
66
68
  const network = networkHooks(state.network);
67
- const executor = createE2BDesktopExecutor(desktop, state.launchedBrowserFamily === "chromium"
68
- ? {
69
- observeBrowserState: participantBrowserStateObserver({
70
- desktop,
71
- spec,
72
- deps,
73
- targetUrl,
74
- launchIdentity: state.browserLaunchIdentity,
75
- targetId: state.browserTargetId,
76
- fidelity: state.fidelity,
77
- warnings,
78
- ...(network === undefined ? {} : { onClock: network.onClock }),
79
- }),
80
- }
81
- : {});
69
+ // A web target's browser launch installs the stand-ins that write handed-off links down
70
+ // (desktop-handoff.ts). Without them the log is absent and every read is empty.
71
+ const readExternalHandoffs = state.browserLaunched
72
+ ? externalHandoffReader(e2bShell(desktop), { requestTimeoutMs: deps.requestTimeoutMs })
73
+ : undefined;
74
+ const executor = createE2BDesktopExecutor(desktop, {
75
+ ...(readExternalHandoffs === undefined ? {} : { readExternalHandoffs }),
76
+ ...(state.launchedBrowserFamily === "chromium"
77
+ ? {
78
+ observeBrowserState: participantBrowserStateObserver({
79
+ desktop,
80
+ spec,
81
+ deps,
82
+ targetUrl,
83
+ launchIdentity: state.browserLaunchIdentity,
84
+ targetId: state.browserTargetId,
85
+ fidelity: state.fidelity,
86
+ warnings,
87
+ ...(network === undefined ? {} : { onClock: network.onClock }),
88
+ }),
89
+ }
90
+ : {}),
91
+ });
82
92
  return {
83
93
  executor: allocation.open(state.speech?.wrap(executor) ?? executor).executor,
84
94
  ...(inbox === undefined ? {} : { inbox }),
@@ -106,7 +106,7 @@ export async function finishE2BDesktop(ctx, state, failed) {
106
106
  await deps.receiving.finishParticipant(spec.planned.id);
107
107
  }
108
108
  catch {
109
- warnings.push("Real email finalization is incomplete. Inspect communication cleanup with humanish comms recover.");
109
+ warnings.push("Real email cleanup is incomplete. Inspect communication cleanup with humanish comms recover.");
110
110
  }
111
111
  }
112
112
  if (ctx.comms && state.commsCatch?.deployed.ready) {
@@ -12,12 +12,12 @@ export function createInProcessDesktop(deps) {
12
12
  return {
13
13
  async prepare() {
14
14
  if (preparationStarted || finalization)
15
- throw new Error("Participant desktop preparation can only start once, before finalization.");
15
+ throw new Error("Participant desktop preparation can only start once, before the desktop is released.");
16
16
  preparationStarted = true;
17
17
  },
18
18
  async openSession() {
19
19
  if (!preparationStarted || opened || finalization)
20
- throw new Error("The participant desktop must be prepared and may only be opened once, before finalization.");
20
+ throw new Error("The participant desktop must be prepared and may only be opened once, before the desktop is released.");
21
21
  opened = true;
22
22
  if (deps.inProcessExecutor === undefined)
23
23
  throw new Error("The in-process route needs RunStudyOptions.inProcess.");
@@ -110,7 +110,7 @@ export async function runStudyParticipants(setup, participants) {
110
110
  await receiving?.finish();
111
111
  }
112
112
  catch {
113
- receivingWarnings.push("Email finalization could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
113
+ receivingWarnings.push("Email cleanup could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
114
114
  }
115
115
  // Stop the flush timer on every exit, including a throw from the participants.
116
116
  await liveTrace.stop?.();
@@ -233,8 +233,8 @@ export async function startCuaRun(plan, input, admitted, scope) {
233
233
  const { cwd, descriptor, participantRuns, participantPlan, publicRepo } = admitted;
234
234
  const { appUrl } = admitted;
235
235
  // The run's status record exists from here on, so anything watching the runs directory can
236
- // tell which study this is and that it is alive. The fail-closed returns below leave it finished
237
- // with no outcome when the scope closes; a crash leaves it stale, which reads as interrupted.
236
+ // tell which study this is and that it is alive. The fail-closed returns below leave it
237
+ // interrupted with no outcome when the scope closes; a killed process leaves it stale.
238
238
  const started = await scope.startRun(plan, input, {
239
239
  cwd,
240
240
  prefix: "cua",
@@ -226,7 +226,7 @@ async function runPlane(ctx, live, results, plane) {
226
226
  await ctx.receiving?.finish();
227
227
  }
228
228
  catch {
229
- ctx.warnings.push("Email finalization could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
229
+ ctx.warnings.push("Email cleanup could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
230
230
  }
231
231
  // Stop the participants' flush timer on every exit, before the final write.
232
232
  await live.flush?.stop();
@@ -1,6 +1,6 @@
1
1
  import type { RunOutcome } from "./bundle.js";
2
2
  import type { ExecutionFailure } from "./judge.js";
3
- import { type RunLiveness } from "./status.js";
3
+ import { type RunAnalysisPhase, type RunLiveness } from "./status.js";
4
4
  /** The states a run is shown in. */
5
5
  export type RunDisplayState = "running" | "interrupted" | "passed" | "failed" | "blocked" | "timed_out" | "no_verdict" | "dry_run" | "unknown";
6
6
  /** A run as a surface shows it. */
@@ -31,6 +31,11 @@ export interface RunDisplayFacts {
31
31
  * show as passed.
32
32
  */
33
33
  export declare function runDisplay(facts: RunDisplayFacts): RunDisplay;
34
+ /**
35
+ * What a surface shows after a run's display while its automatic analysis runs, or once that
36
+ * analysis's process stopped without an end: the run's own result stays its participants'.
37
+ */
38
+ export declare function analysisPhaseLabel(phase: RunAnalysisPhase | undefined): string | undefined;
34
39
  /** A state in words: "passed", "timed out", "no verdict". */
35
40
  export declare function runDisplayLabel(state: RunDisplayState): string;
36
41
  /** What bundleDisplayFacts reads from run.json; a listing reads it from an unchecked file. */
@@ -61,6 +61,17 @@ export function runDisplay(facts) {
61
61
  return shown(facts.mode === "dry-run" ? "dry_run" : "no_verdict");
62
62
  return shown("unknown");
63
63
  }
64
+ /**
65
+ * What a surface shows after a run's display while its automatic analysis runs, or once that
66
+ * analysis's process stopped without an end: the run's own result stays its participants'.
67
+ */
68
+ export function analysisPhaseLabel(phase) {
69
+ if (phase?.state === "running")
70
+ return "analyzing";
71
+ if (phase?.state === "stopped")
72
+ return "analysis stopped";
73
+ return undefined;
74
+ }
64
75
  /** A state in words: "passed", "timed out", "no verdict". */
65
76
  export function runDisplayLabel(state) {
66
77
  return LABELS[state];
@@ -19,7 +19,10 @@ export const systemHostClock = {
19
19
  };
20
20
  /**
21
21
  * A wall-clock gap between two heartbeats this long or longer is a suspension. A shorter one is
22
- * most likely a busy event loop.
22
+ * most likely a busy event loop. It is twice RUN_STATUS_STALE_MS (src/run/status.ts) on purpose:
23
+ * that threshold is a reader's guess, made without the writer, so it errs early; this one is the
24
+ * writer's own record, and a route blames participant failures on it, so it errs late. A pause of
25
+ * 15 to 30 s reads as stale while it lasts and leaves no suspension on record.
23
26
  */
24
27
  const HOST_SUSPENSION_MIN_MS = 30_000;
25
28
  /** Run `onTick` every `intervalMs` on `clock`, and record each gap of 30 s or more. */
@@ -1,16 +1,7 @@
1
+ import { type ProcessOwner } from "./process-owner.js";
1
2
  import { type PreparedSelectedOutputDirectory } from "./contained-output.js";
2
- /**
3
- * Who opened a journal. A pid means nothing outside its host and pid namespace (a probe in a
4
- * container writing to a bind-mounted checkout has a pid the host cannot see), so reclaim trusts
5
- * a dead pid only when both match its own.
6
- */
7
- interface PreflightOwner {
8
- hostname: string;
9
- /** `pid:[inode]` from /proc/self/ns/pid, where the platform has one. */
10
- pidNamespace?: string;
11
- pid: number;
12
- /** Start time in clock ticks since boot (/proc/<pid>/stat), which tells a reused pid apart. */
13
- startTicks?: string;
3
+ /** Who opened a journal (src/run/process-owner.ts), when, and for how long. */
4
+ interface PreflightOwner extends ProcessOwner {
14
5
  createdAt: string;
15
6
  /** The probe's server-side timeout. */
16
7
  leaseMs: number;
@@ -3,11 +3,10 @@
3
3
  // removes the journal. A journal that stays means the probe's sandbox may still be running, and
4
4
  // `humanish reclaim --preflight` kills it by the recorded id once it is safe to.
5
5
  import { randomBytes } from "node:crypto";
6
- import { existsSync, readFileSync, readlinkSync } from "node:fs";
7
6
  import { readdir, rmdir, unlink } from "node:fs/promises";
8
- import { hostname } from "node:os";
9
7
  import path from "node:path";
10
8
  import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT } from "./sandbox-receipts.js";
9
+ import { currentProcessOwner, processOwnerGone, processOwnerOf, } from "./process-owner.js";
11
10
  import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, prepareManagedHumanishOutputDirectory, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
12
11
  const PREFLIGHT_DIR = "preflight";
13
12
  // Written when the probe finished without confirming its kill: the owner no longer holds the
@@ -15,39 +14,13 @@ const PREFLIGHT_DIR = "preflight";
15
14
  const ABANDONED_MARKER = "abandoned";
16
15
  const OWNER_FILE = "owner.json";
17
16
  const JOURNAL_NAME = /^preflight-\d+-[a-z0-9-]+$/;
18
- function ownerContext() {
19
- let pidNamespace;
20
- try {
21
- pidNamespace = readlinkSync("/proc/self/ns/pid");
22
- }
23
- catch {
24
- // No procfs: this platform has no pid namespaces to tell apart.
25
- }
26
- return { hostname: hostname(), ...(pidNamespace === undefined ? {} : { pidNamespace }) };
27
- }
28
- /** The process's start ticks; null when procfs says it is gone; undefined without procfs. */
29
- function startTicks(pid) {
30
- if (!existsSync("/proc/self/stat"))
31
- return undefined;
32
- try {
33
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
34
- // Field 22; the command name before it is parenthesized and may contain spaces.
35
- return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
36
- }
37
- catch {
38
- return null;
39
- }
40
- }
41
17
  /** Create this process's journal directory for one probe with the given lease. */
42
18
  export async function openPreflightJournal(cwd, leaseMs) {
43
19
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").toLowerCase();
44
20
  const id = `preflight-${process.pid}-${stamp}-${randomBytes(4).toString("hex")}`;
45
21
  const root = await prepareManagedHumanishOutputDirectory(cwd, PREFLIGHT_DIR, id);
46
- const ticks = startTicks(process.pid);
47
22
  const owner = {
48
- ...ownerContext(),
49
- pid: process.pid,
50
- ...(typeof ticks === "string" ? { startTicks: ticks } : {}),
23
+ ...currentProcessOwner(),
51
24
  createdAt: new Date().toISOString(),
52
25
  leaseMs,
53
26
  };
@@ -60,19 +33,12 @@ function parseOwner(read) {
60
33
  return undefined;
61
34
  try {
62
35
  const value = JSON.parse(read.bytes.toString("utf8"));
63
- if (typeof value.hostname !== "string" ||
64
- typeof value.pid !== "number" ||
36
+ const owner = processOwnerOf(value);
37
+ if (owner === undefined ||
65
38
  typeof value.createdAt !== "string" ||
66
39
  typeof value.leaseMs !== "number")
67
40
  return undefined;
68
- return {
69
- hostname: value.hostname,
70
- ...(typeof value.pidNamespace === "string" ? { pidNamespace: value.pidNamespace } : {}),
71
- pid: value.pid,
72
- ...(typeof value.startTicks === "string" ? { startTicks: value.startTicks } : {}),
73
- createdAt: value.createdAt,
74
- leaseMs: value.leaseMs,
75
- };
41
+ return { ...owner, createdAt: value.createdAt, leaseMs: value.leaseMs };
76
42
  }
77
43
  catch {
78
44
  return undefined;
@@ -133,34 +99,8 @@ export async function preflightReclaimDecision(journal, nowMs) {
133
99
  const owner = journal.owner;
134
100
  if (owner === undefined)
135
101
  return { reclaim: false, reason: `its owner record is missing or unreadable${until}` };
136
- const here = ownerContext();
137
- if (owner.hostname !== here.hostname || owner.pidNamespace !== here.pidNamespace)
138
- return {
139
- reclaim: false,
140
- reason: `it was opened on host ${owner.hostname} in another host or pid namespace, where this process cannot see whether it is still running${until}`,
141
- };
142
- const running = `process ${owner.pid} is still running${until}`;
143
- if (owner.pid === process.pid)
144
- return { reclaim: false, reason: running };
145
- const ticks = startTicks(owner.pid);
146
- if (ticks === null)
147
- return { reclaim: true };
148
- if (typeof ticks === "string") {
149
- // The pid now belongs to a different process: the owner is gone.
150
- return owner.startTicks !== undefined && ticks !== owner.startTicks
151
- ? { reclaim: true }
152
- : { reclaim: false, reason: running };
153
- }
154
- try {
155
- process.kill(owner.pid, 0);
156
- return { reclaim: false, reason: running };
157
- }
158
- catch (error) {
159
- // EPERM: the process exists but belongs to someone else.
160
- return error.code === "EPERM"
161
- ? { reclaim: false, reason: running }
162
- : { reclaim: true };
163
- }
102
+ const gone = processOwnerGone(owner);
103
+ return gone.gone ? { reclaim: true } : { reclaim: false, reason: `${gone.reason}${until}` };
164
104
  }
165
105
  /**
166
106
  * Remove a journal whose sandbox is confirmed gone. Only the files humanish writes there and the
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A process, as recorded by itself. A pid means nothing outside its host and pid namespace (a
3
+ * process in a container writing to a bind-mounted checkout has a pid the host cannot see), so a
4
+ * dead pid is trusted only when both match the reader's own.
5
+ */
6
+ export interface ProcessOwner {
7
+ hostname: string;
8
+ /** `pid:[inode]` from /proc/self/ns/pid, where the platform has one. */
9
+ pidNamespace?: string;
10
+ pid: number;
11
+ /** Start time in clock ticks since boot (/proc/<pid>/stat), which tells a reused pid apart. */
12
+ startTicks?: string;
13
+ }
14
+ /** This process, as an owner record names it. */
15
+ export declare function currentProcessOwner(): ProcessOwner;
16
+ /** The owner fields of a parsed record, or undefined when `hostname` or `pid` is missing. */
17
+ export declare function processOwnerOf(value: Record<string, unknown>): ProcessOwner | undefined;
18
+ /**
19
+ * Whether `owner` is gone: it ran in this host and pid namespace, and its pid is free or now
20
+ * belongs to a process that started at another time. Otherwise the reason says why it may still
21
+ * be running.
22
+ */
23
+ export declare function processOwnerGone(owner: ProcessOwner): {
24
+ gone: true;
25
+ } | {
26
+ gone: false;
27
+ reason: string;
28
+ };
@@ -0,0 +1,83 @@
1
+ // Which process owns a file humanish leaves on disk, and whether that process is still running.
2
+ // `reclaim --preflight` reads it from a probe's journal and the analysis lock from its owner file,
3
+ // so both decide "the owner is gone" by one rule.
4
+ import { existsSync, readFileSync, readlinkSync } from "node:fs";
5
+ import { hostname } from "node:os";
6
+ function ownerContext() {
7
+ let pidNamespace;
8
+ try {
9
+ pidNamespace = readlinkSync("/proc/self/ns/pid");
10
+ }
11
+ catch {
12
+ // No procfs: this platform has no pid namespaces to tell apart.
13
+ }
14
+ return { hostname: hostname(), ...(pidNamespace === undefined ? {} : { pidNamespace }) };
15
+ }
16
+ /** The process's start ticks; null when procfs says it is gone; undefined without procfs. */
17
+ function startTicks(pid) {
18
+ if (!existsSync("/proc/self/stat"))
19
+ return undefined;
20
+ try {
21
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
22
+ // Field 22; the command name before it is parenthesized and may contain spaces.
23
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** This process, as an owner record names it. */
30
+ export function currentProcessOwner() {
31
+ const ticks = startTicks(process.pid);
32
+ return {
33
+ ...ownerContext(),
34
+ pid: process.pid,
35
+ ...(typeof ticks === "string" ? { startTicks: ticks } : {}),
36
+ };
37
+ }
38
+ /** The owner fields of a parsed record, or undefined when `hostname` or `pid` is missing. */
39
+ export function processOwnerOf(value) {
40
+ if (typeof value.hostname !== "string" || typeof value.pid !== "number")
41
+ return undefined;
42
+ return {
43
+ hostname: value.hostname,
44
+ ...(typeof value.pidNamespace === "string" ? { pidNamespace: value.pidNamespace } : {}),
45
+ pid: value.pid,
46
+ ...(typeof value.startTicks === "string" ? { startTicks: value.startTicks } : {}),
47
+ };
48
+ }
49
+ /**
50
+ * Whether `owner` is gone: it ran in this host and pid namespace, and its pid is free or now
51
+ * belongs to a process that started at another time. Otherwise the reason says why it may still
52
+ * be running.
53
+ */
54
+ export function processOwnerGone(owner) {
55
+ const here = ownerContext();
56
+ if (owner.hostname !== here.hostname || owner.pidNamespace !== here.pidNamespace)
57
+ return {
58
+ gone: false,
59
+ reason: `it was opened on host ${owner.hostname} in another host or pid namespace, where this process cannot see whether it is still running`,
60
+ };
61
+ const running = `process ${owner.pid} is still running`;
62
+ if (owner.pid === process.pid)
63
+ return { gone: false, reason: running };
64
+ const ticks = startTicks(owner.pid);
65
+ if (ticks === null)
66
+ return { gone: true };
67
+ if (typeof ticks === "string") {
68
+ // The pid now belongs to a different process: the owner is gone.
69
+ return owner.startTicks !== undefined && ticks !== owner.startTicks
70
+ ? { gone: true }
71
+ : { gone: false, reason: running };
72
+ }
73
+ try {
74
+ process.kill(owner.pid, 0);
75
+ return { gone: false, reason: running };
76
+ }
77
+ catch (error) {
78
+ // EPERM: the process exists but belongs to someone else.
79
+ return error.code === "EPERM"
80
+ ? { gone: false, reason: running }
81
+ : { gone: true };
82
+ }
83
+ }
@@ -84,7 +84,7 @@ export function expectationFor(entries, mode) {
84
84
  .filter((value) => value !== undefined)
85
85
  .sort((a, b) => a - b);
86
86
  // A cost is unknown whether it was declared absent (`null`) or never recorded at all
87
- // (`undefined`: every fail-closed exit finalized with no outcome). Counting only the first
87
+ // (`undefined`, as earlier versions left a run closed without a bundle). Counting only the first
88
88
  // reports "~$1.20 median · 3 runs" for a sample where two runs were never priced, which claims a
89
89
  // denominator the figure does not have.
90
90
  const costUnknown = finished.filter((entry) => entry.estimatedCostUsd === null || entry.estimatedCostUsd === undefined).length;
@@ -25,6 +25,12 @@ export interface ReclaimResult {
25
25
  * route created none for this run. Reclaim then contacts E2B for nothing.
26
26
  */
27
27
  reason?: NoSandboxReason;
28
+ /**
29
+ * True when this reclaim found the run's status.json still `running` with a stale heartbeat and
30
+ * recorded it `interrupted` (reason `reclaimed`): the process is gone or no longer updating it,
31
+ * and its sandboxes are being stopped. Absent when it changed nothing.
32
+ */
33
+ markedInterrupted?: true;
28
34
  warnings: string[];
29
35
  error?: {
30
36
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
@@ -12,11 +12,11 @@
12
12
  import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
13
13
  import { modalAccount } from "../substrates/modal/sandbox.js";
14
14
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
15
- import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
15
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
16
16
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
17
17
  import { readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
18
18
  import { RUN_BUNDLE_FILE } from "./bundle.js";
19
- import { RUN_STATUS_FILE } from "./status.js";
19
+ import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, } from "./status.js";
20
20
  import { isRecord } from "./type-guards.js";
21
21
  import { isRunOutcome } from "./bundle-shape.js";
22
22
  import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
@@ -66,6 +66,15 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
66
66
  },
67
67
  };
68
68
  }
69
+ // A kill by hand, not a check and not the signal handler (whose run is alive and records its
70
+ // own interrupt): a run whose heartbeat stopped is over once its sandboxes are stopped.
71
+ const marked = hooks.check !== true &&
72
+ hooks.creates === undefined &&
73
+ (await markStaleRunInterrupted(runPaths, Date.now()).catch(() => false));
74
+ const result = await reclaimLocated(runPaths, base, hooks, warnings);
75
+ return marked ? { ...result, markedInterrupted: true } : result;
76
+ }
77
+ async function reclaimLocated(runPaths, base, hooks, warnings) {
69
78
  const runId = runIdOf(runPaths);
70
79
  // The signal handler passes the creates it stopped and decides from what they reported.
71
80
  const reason = hooks.creates === undefined ? await recordedNoSandbox(runPaths) : undefined;
@@ -424,3 +433,24 @@ async function reclaimRoot(root, label, hooks, warnings) {
424
433
  createsInFlight,
425
434
  };
426
435
  }
436
+ /**
437
+ * For `humanish reclaim`, which stops the run's sandboxes: a `running` record whose heartbeat is
438
+ * stale becomes `interrupted` with reason `reclaimed` and `completedAt` at its last heartbeat. A
439
+ * fresh record belongs to a live owner and is left alone, as is one that already ended. True when
440
+ * it wrote one.
441
+ */
442
+ async function markStaleRunInterrupted(runPaths, nowMs) {
443
+ const record = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
444
+ if (!isRunStatusRecord(record) || record.state !== "running")
445
+ return false;
446
+ if (classifyRunStatus(record, nowMs) === "running")
447
+ return false;
448
+ const marked = {
449
+ ...record,
450
+ state: "interrupted",
451
+ reason: "reclaimed",
452
+ completedAt: record.updatedAt,
453
+ };
454
+ await writeContainedOutputFile(runPaths, RUN_STATUS_FILE, `${JSON.stringify(marked, null, 2)}\n`, "utf8");
455
+ return true;
456
+ }
@@ -30,7 +30,7 @@ export async function admitRoute(shell) {
30
30
  // The scorer joins the input only; whatever the checks admitted stays what the run uses.
31
31
  const input = shell.withScorer?.(shell.input, scorer) ?? shell.input;
32
32
  const run = async () => {
33
- const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope));
33
+ const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope), { onStart: (runId) => input.emit?.({ type: "run-started", runId }) });
34
34
  return complete(input, result, finished);
35
35
  };
36
36
  return routeOutcome(shell.route, await (shell.commsSecrets ? withTransientCommsSecrets(run) : run()));
@@ -1,6 +1,6 @@
1
1
  import { AnalysisCostCache } from "./run-index-analysis.js";
2
2
  import type { RunAnalysisCost } from "./run-cost.js";
3
- import { type RunLiveness } from "./status.js";
3
+ import { type RunAnalysisPhase, type RunLiveness } from "./status.js";
4
4
  import { type RunStudyProvenance } from "./study-provenance.js";
5
5
  import type { ExecutionFailure } from "./judge.js";
6
6
  declare const RUN_INDEX_SCHEMA = "humanish.run-index.v1";
@@ -42,6 +42,8 @@ export interface RunIndexEntry {
42
42
  analysisCost?: RunAnalysisCost;
43
43
  /** Wall-clock span when both ends are known; used for per-study medians. */
44
44
  durationMs?: number;
45
+ /** The automatic analysis the run's own process started after the run ended, from status.json. */
46
+ analysis?: RunAnalysisPhase;
45
47
  }
46
48
  export interface RunIndexResult {
47
49
  schema: typeof RUN_INDEX_SCHEMA;
@@ -5,7 +5,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { runLiveness } from "./liveness.js";
7
7
  import { AnalysisCostCache, readIndexedAnalysisCost } from "./run-index-analysis.js";
8
- import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, } from "./status.js";
8
+ import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, runAnalysisPhase, } from "./status.js";
9
9
  import { studyProvenanceOf } from "./study-provenance.js";
10
10
  import { RUN_BUNDLE_FILE } from "./bundle.js";
11
11
  import { isRunOutcome } from "./bundle-shape.js";
@@ -63,6 +63,7 @@ async function readJson(file) {
63
63
  function entryFromStatus(record, nowMs) {
64
64
  const started = Date.parse(record.startedAt);
65
65
  const ended = record.completedAt === undefined ? Number.NaN : Date.parse(record.completedAt);
66
+ const analysis = runAnalysisPhase(record, nowMs);
66
67
  return {
67
68
  runId: record.runId,
68
69
  derivedFrom: "status",
@@ -88,6 +89,7 @@ function entryFromStatus(record, nowMs) {
88
89
  ? { estimatedCostComplete: record.outcome.estimatedCostComplete }
89
90
  : {}),
90
91
  ...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
92
+ ...(analysis === undefined ? {} : { analysis }),
91
93
  };
92
94
  }
93
95
  /** A status record this run can be classified from: well formed and naming this run. */
@@ -168,16 +170,8 @@ async function readEntry(runId, runDir, cache, nowMs) {
168
170
  const [statusKey, bundleKey] = await Promise.all([statKey(statusFile), statKey(bundleFile)]);
169
171
  const key = JSON.stringify([statusKey, bundleKey]);
170
172
  const cached = cache?.get(runId, key);
171
- if (cached !== undefined) {
172
- return cached.derivedFrom === "status" &&
173
- cached.updatedAt !== undefined &&
174
- cached.completedAt === undefined
175
- ? {
176
- ...cached,
177
- liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
178
- }
179
- : cached;
180
- }
173
+ if (cached !== undefined)
174
+ return withCurrentLiveness(cached, nowMs);
181
175
  const [status, raw] = await Promise.all([
182
176
  statusKey === null ? undefined : readJson(statusFile),
183
177
  bundleKey === null ? undefined : readJson(bundleFile),
@@ -196,6 +190,7 @@ async function readEntry(runId, runDir, cache, nowMs) {
196
190
  updatedAt: metadata.updatedAt,
197
191
  ...(metadata.completedAt === undefined ? {} : { completedAt: metadata.completedAt }),
198
192
  ...(metadata.durationMs === undefined ? {} : { durationMs: metadata.durationMs }),
193
+ ...(metadata.analysis === undefined ? {} : { analysis: metadata.analysis }),
199
194
  ...studyEntry(metadata.study),
200
195
  }),
201
196
  ...entryFromBundle(runId, bundle),
@@ -216,6 +211,23 @@ async function readEntry(runId, runDir, cache, nowMs) {
216
211
  cache?.set(runId, key, entry);
217
212
  return entry;
218
213
  }
214
+ /**
215
+ * A cached entry read again at `nowMs`. Its file has not changed, but a live run or a running
216
+ * analysis goes stale with time alone, so both are classified again from the cached heartbeat.
217
+ */
218
+ function withCurrentLiveness(cached, nowMs) {
219
+ if (cached.updatedAt === undefined)
220
+ return cached;
221
+ if (cached.derivedFrom === "status" && cached.completedAt === undefined)
222
+ return {
223
+ ...cached,
224
+ liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
225
+ };
226
+ if (cached.analysis === undefined || cached.analysis.state === "ended")
227
+ return cached;
228
+ const analysis = runAnalysisPhase({ updatedAt: cached.updatedAt, analysis: { startedAt: cached.analysis.startedAt } }, nowMs);
229
+ return analysis === undefined ? cached : { ...cached, analysis };
230
+ }
219
231
  /** Newest-first ordering: the most recent thing known about a run, else its id's own timestamp. */
220
232
  function sortKey(entry) {
221
233
  for (const candidate of [entry.completedAt, entry.updatedAt, entry.startedAt]) {