humanish 0.117.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 (80) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/analysis/admission.d.ts +7 -2
  3. package/dist/analysis/admission.js +12 -3
  4. package/dist/analysis/automatic-config.d.ts +6 -1
  5. package/dist/analysis/automatic-config.js +12 -11
  6. package/dist/analysis/execute.d.ts +19 -7
  7. package/dist/analysis/execute.js +55 -17
  8. package/dist/cli/commands/analyze.js +2 -1
  9. package/dist/cli/commands/study-format.js +8 -11
  10. package/dist/cli/commands/study-route-computer-use.js +2 -2
  11. package/dist/cli/commands/study-route-scripted.js +2 -2
  12. package/dist/cli/commands/study-route-shared-world.js +5 -3
  13. package/dist/cli/commands/study-route-terminal.js +2 -2
  14. package/dist/cli/commands/study-run.js +3 -3
  15. package/dist/cli/commands/study.js +2 -2
  16. package/dist/cli/doctor.js +11 -1
  17. package/dist/cli/io.js +3 -2
  18. package/dist/comms/lease-store.d.ts +1 -1
  19. package/dist/comms/lease-store.js +3 -2
  20. package/dist/comms/receiving-types.d.ts +2 -0
  21. package/dist/comms/receiving-types.js +2 -0
  22. package/dist/observer-app.html +2 -2
  23. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  24. package/dist/routes/computer-use/fanout-records.js +2 -1
  25. package/dist/routes/computer-use/participant-runs.js +3 -9
  26. package/dist/routes/computer-use/plan.d.ts +3 -1
  27. package/dist/routes/computer-use/plan.js +25 -7
  28. package/dist/routes/computer-use/single-bundle.js +7 -4
  29. package/dist/routes/computer-use/subject-projection.js +2 -0
  30. package/dist/routes/scripted/plan.d.ts +4 -1
  31. package/dist/routes/scripted/plan.js +25 -2
  32. package/dist/routes/scripted/setup.js +1 -4
  33. package/dist/routes/scripted/types.d.ts +1 -1
  34. package/dist/routes/shared-world/participant-specs.js +2 -2
  35. package/dist/routes/shared-world/plan.d.ts +3 -1
  36. package/dist/routes/shared-world/plan.js +37 -6
  37. package/dist/routes/shared-world/route.js +2 -2
  38. package/dist/run/bundle-shape.js +2 -1
  39. package/dist/run/bundle.d.ts +5 -3
  40. package/dist/run/projection.js +7 -2
  41. package/dist/run/route-shell.js +10 -5
  42. package/dist/run/subject-name.d.ts +11 -0
  43. package/dist/run/subject-name.js +11 -0
  44. package/dist/study/automatic-analysis-plan.d.ts +28 -0
  45. package/dist/study/automatic-analysis-plan.js +57 -0
  46. package/dist/study/composition-rules.js +1 -1
  47. package/dist/study/config.js +6 -20
  48. package/dist/study/doctor.js +17 -3
  49. package/dist/study/parse/actors.d.ts +1 -1
  50. package/dist/study/parse/actors.js +1 -1
  51. package/dist/study/parse/front.d.ts +1 -1
  52. package/dist/study/parse/front.js +7 -1
  53. package/dist/study/plan-base.d.ts +2 -0
  54. package/dist/study/plan-base.js +3 -0
  55. package/dist/study/plan-participants.js +2 -5
  56. package/dist/study/plan-types.d.ts +8 -3
  57. package/dist/study/plan.d.ts +4 -4
  58. package/dist/study/plan.js +21 -12
  59. package/dist/study/preflight-result.js +2 -2
  60. package/dist/study/preflight.d.ts +2 -1
  61. package/dist/study/preflight.js +12 -3
  62. package/dist/study/routing.d.ts +5 -4
  63. package/dist/study/routing.js +16 -7
  64. package/dist/study/summary.d.ts +3 -3
  65. package/dist/study/summary.js +3 -5
  66. package/dist/study/validation.d.ts +2 -2
  67. package/dist/study/validation.js +22 -14
  68. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  69. package/dist/substrates/e2b/lifetime.js +134 -10
  70. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  71. package/dist/tui-app.js +112 -112
  72. package/dist/verify/artifacts.d.ts +19 -4
  73. package/dist/verify/artifacts.js +39 -8
  74. package/dist/verify/verify.js +6 -15
  75. package/docs/contracts/run-bundle.md +7 -6
  76. package/docs/contracts/schemas.md +14 -8
  77. package/docs/contracts/study-analysis.md +2 -2
  78. package/docs/principles/invariants-and-defaults.md +1 -1
  79. package/docs/product/automatic-analysis.md +4 -3
  80. package/package.json +1 -1
@@ -1,15 +1,16 @@
1
1
  import { isMaxOutputTokens } from "../actors/output-token-limit.js";
2
+ import { MAX_RECEIVING_INBOXES } from "../comms/receiving-types.js";
2
3
  import { waitFieldsReason } from "../actors/computer-use/wait.js";
3
4
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./parse/actors.js";
4
5
  import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
5
6
  import { declaredTargets } from "./plan-participants.js";
6
- import { actorResolvesToComputerUse, computerUseParticipantCount, MAX_COMPUTER_USE_PARTICIPANTS, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
7
+ import { actorResolvesToComputerUse, computerUseParticipantCount, participantCountReason, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
7
8
  import { participantList } from "./study-fields.js";
8
9
  /**
9
10
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
10
11
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
11
- * that skipped the parser. It runs rosterStructuralValidationReason (id and device
12
- * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
12
+ * that skipped the parser. It runs rosterStructuralValidationReason (roster size, id and device
13
+ * validity, unique ids) first, then the route-scoped XOR, participant count and policy checks.
13
14
  */
14
15
  export function computerUseValidationReason(config) {
15
16
  const roster = participantList(config);
@@ -42,9 +43,9 @@ export function computerUseValidationReason(config) {
42
43
  }
43
44
  }
44
45
  const participantCount = computerUseParticipantCount(config);
45
- if (participantCount > MAX_COMPUTER_USE_PARTICIPANTS) {
46
- return `A computer-use study runs at most ${MAX_COMPUTER_USE_PARTICIPANTS} participants, and this one declares ${participantCount}. Each participant is a paid desktop and they all run at once, so no setting raises the cap; split the roster across studies.`;
47
- }
46
+ const countReason = participantCountReason(participantCount);
47
+ if (countReason)
48
+ return countReason;
48
49
  // Public targets fan out into N independent worlds driving the same public app, which is an
49
50
  // ambiguous shared-world-ish shape, not a per-participant target swarm. Permit N>1 public runs only
50
51
  // when every roster entry declares its own target, making the adapter-owned topology explicit. But when
@@ -60,8 +61,8 @@ export function computerUseValidationReason(config) {
60
61
  return null;
61
62
  }
62
63
  /**
63
- * Engine-level path-token validation for configs supplied directly through the
64
- * public TypeScript/JavaScript API instead of parseStudy.
64
+ * Engine-level roster validation (its size and its ids' path tokens) for configs supplied directly
65
+ * through the public TypeScript/JavaScript API instead of parseStudy.
65
66
  */
66
67
  function rosterStructuralValidationReason(config) {
67
68
  const roster = participantList(config);
@@ -70,6 +71,9 @@ function rosterStructuralValidationReason(config) {
70
71
  if (!Array.isArray(roster) || roster.length === 0) {
71
72
  return "participants must be a non-empty array when set.";
72
73
  }
74
+ const countReason = participantCountReason(roster.length);
75
+ if (countReason)
76
+ return countReason;
73
77
  for (const [index, entry] of roster.entries()) {
74
78
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
75
79
  return `participants[${index}] must be an object.`;
@@ -195,6 +199,10 @@ export function receivingEmailValidationReason(config) {
195
199
  !["app-url", "clone", "local-tree"].includes(config.subject.source)) {
196
200
  return "Real email receiving requires a hosted computer-use browser study with an app-url, clone, or local-tree subject. Scripted, terminal, desktop-cli and local-app routes are unsupported.";
197
201
  }
202
+ const participants = computerUseParticipantCount(config);
203
+ if (participants > MAX_RECEIVING_INBOXES) {
204
+ return `Real email receiving leases one inbox per participant, at most ${MAX_RECEIVING_INBOXES} per run, and this study has ${participants} participants. Run at most ${MAX_RECEIVING_INBOXES} participants with a real inbox, or split them across studies.`;
205
+ }
198
206
  if (config.actor?.type === "local-agent") {
199
207
  return "Real email receiving is unavailable for local-agent: its host process does not isolate the inbox management credential. Use a hosted first-party computer-use actor.";
200
208
  }
@@ -245,14 +253,14 @@ export function waitLimitValidationReason(config) {
245
253
  }
246
254
  /**
247
255
  * Shared-world participants share one live app, so at least two must be live at once. The
248
- * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0; the parser
249
- * fills an omitted concurrency with the participant count. Both callers check it after their
250
- * two-participant roster floor, so a one-participant roster gets the roster refusal, never this
251
- * one.
256
+ * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0. An omitted
257
+ * concurrency runs every participant, up to the E2B plan's limit, which the planner checks. Both
258
+ * callers check it after their two-participant roster floor, so a one-participant roster gets the
259
+ * roster refusal, never this one.
252
260
  */
253
261
  function sharedWorldConcurrencyReason(config) {
254
- // Direct library callers skip the parser, so an omitted value defaults here exactly as the
255
- // route does: to the participant count. A missing roster reads as 0 and is refused.
262
+ // An omitted value runs every participant, so it is checked as the participant count. A missing
263
+ // roster reads as 0 and is refused.
256
264
  const participants = participantList(config)?.length ?? 0;
257
265
  const concurrency = config.execution?.concurrency ?? participants;
258
266
  if (concurrency >= 2)
@@ -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: {