humanish 0.116.0 → 0.118.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 (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
  3. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  4. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  5. package/dist/actors/codex/restricted-participant.js +3 -2
  6. package/dist/actors/computer-use/actor.d.ts +4 -0
  7. package/dist/actors/computer-use/actor.js +2 -0
  8. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/actions.js +25 -18
  10. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  11. package/dist/actors/computer-use/loop/session.js +9 -1
  12. package/dist/actors/computer-use/loop/trace.js +1 -0
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/wait.d.ts +74 -0
  17. package/dist/actors/computer-use/wait.js +100 -0
  18. package/dist/actors/contract.d.ts +14 -0
  19. package/dist/analysis/admission.d.ts +7 -2
  20. package/dist/analysis/admission.js +12 -3
  21. package/dist/analysis/automatic-config.d.ts +6 -1
  22. package/dist/analysis/automatic-config.js +12 -11
  23. package/dist/analysis/execute.d.ts +19 -7
  24. package/dist/analysis/execute.js +55 -17
  25. package/dist/browser-control/protocol.d.ts +86 -6
  26. package/dist/browser-control/protocol.js +13 -6
  27. package/dist/cli/commands/analyze.js +2 -1
  28. package/dist/cli/commands/study-format.js +8 -11
  29. package/dist/cli/commands/study-route-computer-use.js +2 -2
  30. package/dist/cli/commands/study-route-scripted.js +2 -2
  31. package/dist/cli/commands/study-route-shared-world.js +5 -3
  32. package/dist/cli/commands/study-route-terminal.js +2 -2
  33. package/dist/cli/commands/study-run.js +3 -3
  34. package/dist/cli/commands/study.js +2 -2
  35. package/dist/cli/doctor.js +11 -1
  36. package/dist/cli/io.js +3 -2
  37. package/dist/comms/lease-store.d.ts +1 -1
  38. package/dist/comms/lease-store.js +3 -2
  39. package/dist/comms/receiving-types.d.ts +2 -0
  40. package/dist/comms/receiving-types.js +2 -0
  41. package/dist/guest/desktop-executor.js +3 -1
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  44. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  45. package/dist/routes/computer-use/fanout-records.js +2 -1
  46. package/dist/routes/computer-use/local-vm.js +5 -2
  47. package/dist/routes/computer-use/participant-model.js +9 -0
  48. package/dist/routes/computer-use/participant-runs.js +3 -9
  49. package/dist/routes/computer-use/plan.d.ts +3 -1
  50. package/dist/routes/computer-use/plan.js +29 -9
  51. package/dist/routes/computer-use/single-bundle.js +7 -4
  52. package/dist/routes/computer-use/subject-projection.js +2 -0
  53. package/dist/routes/computer-use/types.d.ts +1 -1
  54. package/dist/routes/scripted/plan.d.ts +4 -1
  55. package/dist/routes/scripted/plan.js +25 -2
  56. package/dist/routes/scripted/setup.js +1 -4
  57. package/dist/routes/scripted/types.d.ts +1 -1
  58. package/dist/routes/shared-world/participant-specs.js +2 -2
  59. package/dist/routes/shared-world/plan.d.ts +3 -1
  60. package/dist/routes/shared-world/plan.js +39 -7
  61. package/dist/routes/shared-world/route.js +2 -2
  62. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  63. package/dist/routes/terminal/live-sandbox.js +14 -11
  64. package/dist/routes/terminal/sandbox.d.ts +4 -3
  65. package/dist/routes/terminal/sandbox.js +5 -5
  66. package/dist/run/bundle-shape.js +2 -1
  67. package/dist/run/bundle.d.ts +5 -3
  68. package/dist/run/projection.js +7 -2
  69. package/dist/run/route-shell.js +10 -5
  70. package/dist/run/subject-name.d.ts +11 -0
  71. package/dist/run/subject-name.js +11 -0
  72. package/dist/study/automatic-analysis-plan.d.ts +28 -0
  73. package/dist/study/automatic-analysis-plan.js +57 -0
  74. package/dist/study/composition-rules.js +1 -1
  75. package/dist/study/config.js +10 -21
  76. package/dist/study/doctor.js +17 -3
  77. package/dist/study/keys.d.ts +2 -0
  78. package/dist/study/keys.js +2 -0
  79. package/dist/study/migrate/v2.js +2 -1
  80. package/dist/study/parse/actors.d.ts +1 -1
  81. package/dist/study/parse/actors.js +9 -1
  82. package/dist/study/parse/front.d.ts +1 -1
  83. package/dist/study/parse/front.js +7 -1
  84. package/dist/study/plan-base.d.ts +2 -0
  85. package/dist/study/plan-base.js +3 -0
  86. package/dist/study/plan-participants.d.ts +4 -0
  87. package/dist/study/plan-participants.js +6 -5
  88. package/dist/study/plan-types.d.ts +8 -3
  89. package/dist/study/plan.d.ts +4 -4
  90. package/dist/study/plan.js +21 -12
  91. package/dist/study/preflight-result.js +2 -2
  92. package/dist/study/preflight.d.ts +2 -1
  93. package/dist/study/preflight.js +12 -3
  94. package/dist/study/routing.d.ts +5 -4
  95. package/dist/study/routing.js +16 -7
  96. package/dist/study/summary.d.ts +3 -3
  97. package/dist/study/summary.js +3 -5
  98. package/dist/study/types.d.ts +14 -0
  99. package/dist/study/validation.d.ts +7 -2
  100. package/dist/study/validation.js +30 -14
  101. package/dist/study/warnings.js +12 -0
  102. package/dist/subject/serve.d.ts +3 -0
  103. package/dist/subject/serve.js +4 -1
  104. package/dist/substrates/detached.js +14 -7
  105. package/dist/substrates/e2b/desktop-executor.d.ts +0 -2
  106. package/dist/substrates/e2b/desktop-executor.js +3 -3
  107. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  108. package/dist/substrates/e2b/lifetime.js +134 -10
  109. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  110. package/dist/tui-app.js +119 -118
  111. package/dist/verify/artifacts.d.ts +19 -4
  112. package/dist/verify/artifacts.js +39 -8
  113. package/dist/verify/verify.js +6 -15
  114. package/docs/architecture/browser-control.md +10 -4
  115. package/docs/contracts/run-bundle.md +7 -6
  116. package/docs/contracts/schemas.md +33 -19
  117. package/docs/contracts/study-analysis.md +2 -2
  118. package/docs/principles/invariants-and-defaults.md +1 -1
  119. package/docs/product/automatic-analysis.md +4 -3
  120. package/package.json +2 -2
@@ -2,8 +2,8 @@ import { shellQuote } from "../shell.js";
2
2
  import { perceptualSignature } from "../../evidence/frame-signature.js";
3
3
  import { commandFailureInfo } from "../command-failure.js";
4
4
  import { ComputerUseExecutorError } from "../../actors/computer-use/executor-error.js";
5
+ import { CUA_WAIT_LIMITS } from "../../actors/computer-use/wait.js";
5
6
  import { xdotoolChord, xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
6
- const DEFAULT_WAIT_MS = 500;
7
7
  const DEFAULT_SCROLL_AMOUNT_PER_TICK = 100;
8
8
  const TYPE_COMMAND_TIMEOUT_MS = 15_000;
9
9
  const KEY_COMMAND_TIMEOUT_MS = 15_000;
@@ -145,7 +145,6 @@ async function typeText(desktop, text) {
145
145
  * screenshot or action is ever logged.
146
146
  */
147
147
  export function createE2BDesktopExecutor(desktop, options = {}) {
148
- const defaultWaitMs = options.defaultWaitMs ?? DEFAULT_WAIT_MS;
149
148
  const scrollAmountPerTick = options.scrollAmountPerTick ?? DEFAULT_SCROLL_AMOUNT_PER_TICK;
150
149
  return {
151
150
  async observe() {
@@ -258,7 +257,8 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
258
257
  return;
259
258
  }
260
259
  case "wait":
261
- await desktop.wait(action.ms ?? defaultWaitMs);
260
+ // The loop sends every wait with its length; planWait owns the lengths.
261
+ await desktop.wait(action.ms ?? CUA_WAIT_LIMITS.settleMs);
262
262
  return;
263
263
  case "screenshot":
264
264
  // No-op: the loop calls observe() separately to capture each frame, so
@@ -24,9 +24,80 @@ export declare function describeSandboxCeiling(ceiling: SandboxCeiling): string;
24
24
  * setting value that would admit it, when an E2B plan allows that long.
25
25
  */
26
26
  export declare function ceilingAdvice(deadlineMs: number): string;
27
- /** Server-side reclamation buffer past a participant's own wall-clock stop. */
28
- export declare const SANDBOX_TIMEOUT_BUFFER_MS: number;
29
- /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
30
- export declare const SUBJECT_PROVISION_BUDGET_MS: number;
27
+ /**
28
+ * How many sandboxes humanish runs at once, and whether it is the default or the setting; or why
29
+ * the setting cannot be read.
30
+ */
31
+ export type ConcurrentSandboxes = {
32
+ readonly ok: true;
33
+ readonly count: number;
34
+ readonly source: "default" | "setting";
35
+ } | {
36
+ readonly ok: false;
37
+ readonly message: string;
38
+ };
39
+ /**
40
+ * The concurrent sandbox limit: HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES when set, else 20. E2B
41
+ * refuses a create past the plan's limit, and neither its API nor its SDK says which plan a key is
42
+ * on, so the operator states it, as for the sandbox ceiling. A study that would run more desktops
43
+ * at once is refused or run in waves at plan time, before a create fails mid-run.
44
+ */
45
+ export declare function concurrentSandboxes(env: Readonly<Record<string, string | undefined>>): ConcurrentSandboxes;
46
+ /** The limit in use and where it came from, or why the setting cannot be read (doctor's row). */
47
+ export declare function describeConcurrentSandboxes(limit: ConcurrentSandboxes): string;
48
+ /**
49
+ * How many participants a hosted study runs at once: its `concurrency` (the declared
50
+ * execution.concurrency clamped to the roster, else every participant), lowered to what the plan
51
+ * runs. `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps
52
+ * for the whole run besides its participants' desktops, such as a shared world's app, and
53
+ * `minimum` is the fewest at once the route can run. A declared value that does not fit is a
54
+ * refusal naming the setting value that admits it. `lowered` is the warning a plan records when
55
+ * the limit holds an undeclared default to waves.
56
+ */
57
+ export declare function participantsAtOnce(study: {
58
+ readonly concurrency: number;
59
+ readonly declared: boolean;
60
+ readonly participants: number;
61
+ readonly alongside?: number;
62
+ readonly minimum?: number;
63
+ }, limit: ConcurrentSandboxes): {
64
+ readonly ok: true;
65
+ readonly count: number;
66
+ readonly lowered?: string;
67
+ } | {
68
+ readonly ok: false;
69
+ readonly message: string;
70
+ };
71
+ /**
72
+ * What a sandbox's server-side timeout adds to the session it hosts: the teardown buffer, and on a
73
+ * sandbox that serves the subject, the provisioning budget and each seed step's budget. A planner
74
+ * adds it to the session to get the deadline it checks against the ceiling, and the route adds it
75
+ * to set the timeout it asks E2B for.
76
+ */
77
+ export declare function sandboxHeadroomMs(servedSubject?: {
78
+ readonly seed: readonly {
79
+ readonly timeoutMs?: number | undefined;
80
+ }[];
81
+ }): number;
82
+ /** A sandbox a plan will ask E2B for, as its deadline refusal describes it. */
83
+ export interface PlannedSandbox {
84
+ /** How the refusal names the sandbox, as in "the subject sandbox". */
85
+ readonly name: string;
86
+ /** The session it hosts, and whether the study set it in execution.timeoutMs. */
87
+ readonly sessionMs: number;
88
+ readonly sessionDeclared: boolean;
89
+ /** The subject it serves, whose provisioning and seed steps add to its deadline. */
90
+ readonly servedSubject?: {
91
+ readonly seed: readonly {
92
+ readonly timeoutMs?: number | undefined;
93
+ }[];
94
+ } | undefined;
95
+ }
96
+ /**
97
+ * Why the sandbox's deadline, its session plus sandboxHeadroomMs, passes the `ceilingMs` ceiling,
98
+ * or undefined when it fits. The message shows the arithmetic, the longest execution.timeoutMs the
99
+ * ceiling leaves, and the setting value that would admit the study.
100
+ */
101
+ export declare function sandboxDeadlineRefusal(sandbox: PlannedSandbox, ceilingMs: number): string | undefined;
31
102
  /** The timeout of one E2B API request: HUMANISH_E2B_REQUEST_TIMEOUT_MS when set, else 60 s. */
32
103
  export declare function e2bRequestTimeoutMs(env: Record<string, string | undefined>): number;
@@ -1,7 +1,10 @@
1
- // How long an E2B sandbox may live, and the budgets the desktop routes add to a participant's own
2
- // deadline when they set a sandbox's server-side timeout. A dead host process can never orphan a
3
- // sandbox past that timeout, so every route derives it from these values.
1
+ // The operator's E2B plan limits humanish plans against: how long a sandbox may live and how many
2
+ // run at once. Also the budgets the desktop routes add to a participant's own deadline when they set
3
+ // a sandbox's server-side timeout. A dead host process can never orphan a sandbox past that
4
+ // timeout, so every route derives it from these values.
5
+ import { plural } from "../../run/text.js";
4
6
  import { readPositiveInt } from "../../study/parse/values.js";
7
+ import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
5
8
  /** The setting that tells humanish how long the operator's E2B plan lets a sandbox live. */
6
9
  const SANDBOX_CEILING_SETTING = "HUMANISH_E2B_MAX_SANDBOX_MINUTES";
7
10
  /** E2B's Hobby plan ends a sandbox after 1 hour, so a key on any plan can use this ceiling. */
@@ -15,17 +18,27 @@ const LARGEST_CEILING_MINUTES = 24 * 60;
15
18
  * to stay under the ceiling, and saying so at plan time beats a provider 400 after the plan printed.
16
19
  */
17
20
  export function sandboxCeiling(env) {
18
- const declared = env[SANDBOX_CEILING_SETTING]?.trim() ?? "";
19
- if (declared === "")
21
+ const minutes = planSetting(env, SANDBOX_CEILING_SETTING, LARGEST_CEILING_MINUTES);
22
+ if (minutes === undefined)
20
23
  return { ok: true, ms: DEFAULT_CEILING_MINUTES * 60_000, source: "default" };
21
- const minutes = /^\d+$/.test(declared) ? Number.parseInt(declared, 10) : Number.NaN;
22
- if (!(minutes >= 1 && minutes <= LARGEST_CEILING_MINUTES))
24
+ if (typeof minutes === "string")
23
25
  return {
24
26
  ok: false,
25
- message: `${SANDBOX_CEILING_SETTING} must be a whole number of minutes from 1 to ${LARGEST_CEILING_MINUTES} (got "${declared.slice(0, 40)}"). Set it to the sandbox lifetime your E2B plan allows: ${DEFAULT_CEILING_MINUTES} on Hobby, ${LARGEST_CEILING_MINUTES} on Pro. Unset, it is ${DEFAULT_CEILING_MINUTES}.`,
27
+ message: `${SANDBOX_CEILING_SETTING} must be a whole number of minutes from 1 to ${LARGEST_CEILING_MINUTES} (got "${minutes}"). Set it to the sandbox lifetime your E2B plan allows: ${DEFAULT_CEILING_MINUTES} on Hobby, ${LARGEST_CEILING_MINUTES} on Pro. Unset, it is ${DEFAULT_CEILING_MINUTES}.`,
26
28
  };
27
29
  return { ok: true, ms: minutes * 60_000, source: "setting" };
28
30
  }
31
+ /**
32
+ * A plan setting's whole number from 1 to `largest`: undefined when unset or blank, else the
33
+ * number, else the value as given (cut to 40 characters) for the refusal to quote.
34
+ */
35
+ function planSetting(env, name, largest) {
36
+ const declared = env[name]?.trim() ?? "";
37
+ if (declared === "")
38
+ return undefined;
39
+ const value = /^\d+$/.test(declared) ? Number.parseInt(declared, 10) : Number.NaN;
40
+ return value >= 1 && value <= largest ? value : declared.slice(0, 40);
41
+ }
29
42
  /** The ceiling in use and where it came from, or why the setting cannot be read (doctor's row). */
30
43
  export function describeSandboxCeiling(ceiling) {
31
44
  if (!ceiling.ok)
@@ -45,10 +58,121 @@ export function ceilingAdvice(deadlineMs) {
45
58
  ? ` If your E2B plan allows longer sandboxes (Pro allows ${LARGEST_CEILING_MINUTES} minutes), set ${SANDBOX_CEILING_SETTING} to ${needed} or more.`
46
59
  : ` No E2B plan humanish knows allows a sandbox past ${LARGEST_CEILING_MINUTES} minutes.`;
47
60
  }
61
+ /** The setting that tells humanish how many sandboxes the operator's E2B plan runs at once. */
62
+ const CONCURRENCY_SETTING = "HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES";
63
+ /** E2B's Hobby plan runs 20 sandboxes at once, so a key on any plan can use this limit. */
64
+ const DEFAULT_CONCURRENT_SANDBOXES = 20;
65
+ /** E2B's Pro plan runs 100 at once, and add-ons raise it to 1,100. */
66
+ const PRO_CONCURRENT_SANDBOXES = 100;
67
+ /** Enterprise plans run tens of thousands; the setting refuses only a value no plan could mean. */
68
+ const LARGEST_CONCURRENT_SANDBOXES = 1_000_000;
69
+ /**
70
+ * The concurrent sandbox limit: HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES when set, else 20. E2B
71
+ * refuses a create past the plan's limit, and neither its API nor its SDK says which plan a key is
72
+ * on, so the operator states it, as for the sandbox ceiling. A study that would run more desktops
73
+ * at once is refused or run in waves at plan time, before a create fails mid-run.
74
+ */
75
+ export function concurrentSandboxes(env) {
76
+ const count = planSetting(env, CONCURRENCY_SETTING, LARGEST_CONCURRENT_SANDBOXES);
77
+ if (count === undefined)
78
+ return { ok: true, count: DEFAULT_CONCURRENT_SANDBOXES, source: "default" };
79
+ if (typeof count === "string")
80
+ return {
81
+ ok: false,
82
+ message: `${CONCURRENCY_SETTING} must be a whole number of 1 or more (got "${count}"). Set it to how many sandboxes your E2B plan runs at once: ${DEFAULT_CONCURRENT_SANDBOXES} on Hobby, ${PRO_CONCURRENT_SANDBOXES} on Pro. Unset, it is ${DEFAULT_CONCURRENT_SANDBOXES}.`,
83
+ };
84
+ return { ok: true, count, source: "setting" };
85
+ }
86
+ /** The limit in use and where it came from, or why the setting cannot be read (doctor's row). */
87
+ export function describeConcurrentSandboxes(limit) {
88
+ if (!limit.ok)
89
+ return limit.message;
90
+ return limit.source === "setting"
91
+ ? `${limit.count} at once, from ${CONCURRENCY_SETTING}. A study that runs more desktops at once runs them in waves, and an execution.concurrency above it is refused before any sandbox is created.`
92
+ : `${limit.count} at once, E2B's Hobby limit. On a plan that runs more (Pro runs ${PRO_CONCURRENT_SANDBOXES}), set ${CONCURRENCY_SETTING} to that plan's limit.`;
93
+ }
94
+ /**
95
+ * How many participants a hosted study runs at once: its `concurrency` (the declared
96
+ * execution.concurrency clamped to the roster, else every participant), lowered to what the plan
97
+ * runs. `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps
98
+ * for the whole run besides its participants' desktops, such as a shared world's app, and
99
+ * `minimum` is the fewest at once the route can run. A declared value that does not fit is a
100
+ * refusal naming the setting value that admits it. `lowered` is the warning a plan records when
101
+ * the limit holds an undeclared default to waves.
102
+ */
103
+ export function participantsAtOnce(study, limit) {
104
+ if (!limit.ok)
105
+ return limit;
106
+ const alongside = study.alongside ?? 0;
107
+ const minimum = study.minimum ?? 1;
108
+ const room = limit.count - alongside;
109
+ const needed = Math.max(study.concurrency, minimum) + alongside;
110
+ const raise = ` If your E2B plan runs more (Pro runs ${PRO_CONCURRENT_SANDBOXES}), set ${CONCURRENCY_SETTING} to ${needed} or more.`;
111
+ const app = alongside === 0
112
+ ? ""
113
+ : ` beside the ${plural(alongside, "sandbox", "sandboxes")} the study keeps for its app`;
114
+ if (room < minimum)
115
+ return {
116
+ ok: false,
117
+ message: `This study runs at least ${minimum} participants at once${app}, and ${limitText(limit)}.${raise}`,
118
+ };
119
+ if (study.concurrency <= room)
120
+ return { ok: true, count: study.concurrency };
121
+ if (study.declared)
122
+ return {
123
+ ok: false,
124
+ message: `execution.concurrency runs ${study.concurrency} participants at once${app}, and ${limitText(limit)}. Lower execution.concurrency to ${room} or less, or remove it to run ${room} at a time.${raise}`,
125
+ };
126
+ return {
127
+ ok: true,
128
+ count: room,
129
+ lowered: `This study's ${study.participants} participants run ${room} at a time${app}, because ${limitText(limit)}.${raise}`,
130
+ };
131
+ }
132
+ function limitText(limit) {
133
+ return limit.source === "setting"
134
+ ? `${CONCURRENCY_SETTING} allows ${limit.count} sandboxes at once`
135
+ : `humanish runs at most ${limit.count} sandboxes at once, E2B Hobby's limit, when ${CONCURRENCY_SETTING} is unset`;
136
+ }
48
137
  /** Server-side reclamation buffer past a participant's own wall-clock stop. */
49
- export const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
138
+ const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
50
139
  /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
51
- export const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
140
+ const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
141
+ /**
142
+ * What a sandbox's server-side timeout adds to the session it hosts: the teardown buffer, and on a
143
+ * sandbox that serves the subject, the provisioning budget and each seed step's budget. A planner
144
+ * adds it to the session to get the deadline it checks against the ceiling, and the route adds it
145
+ * to set the timeout it asks E2B for.
146
+ */
147
+ export function sandboxHeadroomMs(servedSubject) {
148
+ if (servedSubject === undefined)
149
+ return SANDBOX_TIMEOUT_BUFFER_MS;
150
+ const seedMs = servedSubject.seed.reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0);
151
+ return SUBJECT_PROVISION_BUDGET_MS + seedMs + SANDBOX_TIMEOUT_BUFFER_MS;
152
+ }
153
+ /**
154
+ * Why the sandbox's deadline, its session plus sandboxHeadroomMs, passes the `ceilingMs` ceiling,
155
+ * or undefined when it fits. The message shows the arithmetic, the longest execution.timeoutMs the
156
+ * ceiling leaves, and the setting value that would admit the study.
157
+ */
158
+ export function sandboxDeadlineRefusal(sandbox, ceilingMs) {
159
+ const headroomMs = sandboxHeadroomMs(sandbox.servedSubject);
160
+ const deadlineMs = sandbox.sessionMs + headroomMs;
161
+ if (deadlineMs <= ceilingMs)
162
+ return undefined;
163
+ const inMinutes = (ms) => Math.round(ms / 60_000);
164
+ const session = sandbox.sessionDeclared
165
+ ? `execution.timeoutMs ${inMinutes(sandbox.sessionMs)}m`
166
+ : `The default session budget of ${inMinutes(sandbox.sessionMs)}m`;
167
+ const headroom = sandbox.servedSubject === undefined
168
+ ? `${inMinutes(headroomMs)}m of teardown buffer`
169
+ : `${inMinutes(headroomMs)}m to provision and seed the subject and tear it down`;
170
+ const roomMinutes = Math.floor((ceilingMs - headroomMs) / 60_000);
171
+ const lower = roomMinutes >= 1
172
+ ? `Lower execution.timeoutMs to at most ${roomMinutes}m.`
173
+ : "The provisioning and seed step budgets alone leave no session time under it, so shorten subject.state.seed[].timeoutMs.";
174
+ return `${session} derives a ${inMinutes(deadlineMs)}m deadline for ${sandbox.name}, and a sandbox may not live longer than ${ceilingMs / 60_000}m. The deadline is the session budget plus ${headroom}. ${lower}${ceilingAdvice(deadlineMs)}`;
175
+ }
52
176
  /** The timeout of one E2B API request: HUMANISH_E2B_REQUEST_TIMEOUT_MS when set, else 60 s. */
53
177
  export function e2bRequestTimeoutMs(env) {
54
178
  return readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
@@ -4,9 +4,8 @@
4
4
  // reading is one edit. What runs inside the sandbox (the caller's prepare hook, provisioning,
5
5
  // getHost) stays with each route.
6
6
  import { redactText } from "../../evidence/redaction.js";
7
- import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
8
7
  import { observeDesktopResources } from "./desktop-resources.js";
9
- import { SANDBOX_TIMEOUT_BUFFER_MS, SUBJECT_PROVISION_BUDGET_MS } from "./lifetime.js";
8
+ import { sandboxHeadroomMs } from "./lifetime.js";
10
9
  import { acquireE2BDesktopSandbox, readE2BRelease } from "./sandbox.js";
11
10
  export class E2BSubjectSandbox {
12
11
  sandboxId;
@@ -28,10 +27,7 @@ export class E2BSubjectSandbox {
28
27
  async acquire(request) {
29
28
  const { envs } = request;
30
29
  const { warnings, scrub } = this.run;
31
- const timeoutMs = request.sessionTimeoutMs +
32
- SUBJECT_PROVISION_BUDGET_MS +
33
- request.seed.reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0) +
34
- SANDBOX_TIMEOUT_BUFFER_MS;
30
+ const timeoutMs = request.sessionTimeoutMs + sandboxHeadroomMs({ seed: request.seed });
35
31
  const subject = await acquireE2BDesktopSandbox({
36
32
  module: request.module,
37
33
  options: {