humanish 0.117.0 → 0.119.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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/analysis/admission.d.ts +23 -4
  3. package/dist/analysis/admission.js +47 -9
  4. package/dist/analysis/analysis-limits.d.ts +32 -0
  5. package/dist/analysis/analysis-limits.js +41 -3
  6. package/dist/analysis/automatic-config.d.ts +11 -1
  7. package/dist/analysis/automatic-config.js +26 -11
  8. package/dist/analysis/cohorts.d.ts +103 -0
  9. package/dist/analysis/cohorts.js +80 -0
  10. package/dist/analysis/evidence.d.ts +5 -10
  11. package/dist/analysis/evidence.js +37 -34
  12. package/dist/analysis/execute.d.ts +24 -7
  13. package/dist/analysis/execute.js +290 -307
  14. package/dist/analysis/responses.d.ts +19 -0
  15. package/dist/analysis/responses.js +201 -0
  16. package/dist/analysis/store.js +2 -1
  17. package/dist/analysis/validation.js +13 -7
  18. package/dist/cli/commands/analyze.js +10 -4
  19. package/dist/cli/commands/study-format.js +8 -11
  20. package/dist/cli/commands/study-route-shared-world.js +3 -1
  21. package/dist/cli/commands/study-run.js +1 -1
  22. package/dist/cli/doctor.js +11 -1
  23. package/dist/comms/lease-store.d.ts +1 -1
  24. package/dist/comms/lease-store.js +3 -2
  25. package/dist/comms/receiving-types.d.ts +2 -0
  26. package/dist/comms/receiving-types.js +2 -0
  27. package/dist/observer-app.html +2 -2
  28. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  29. package/dist/routes/computer-use/fanout-records.js +6 -1
  30. package/dist/routes/computer-use/participant-execution.d.ts +6 -4
  31. package/dist/routes/computer-use/participant-execution.js +42 -14
  32. package/dist/routes/computer-use/participant-model.js +18 -8
  33. package/dist/routes/computer-use/participant-runs.js +15 -9
  34. package/dist/routes/computer-use/plan.d.ts +3 -1
  35. package/dist/routes/computer-use/plan.js +38 -12
  36. package/dist/routes/computer-use/rerun-selection.js +14 -3
  37. package/dist/routes/computer-use/single-bundle.js +7 -4
  38. package/dist/routes/computer-use/subject-projection.js +2 -0
  39. package/dist/routes/computer-use/types.d.ts +13 -1
  40. package/dist/routes/scripted/plan.d.ts +4 -1
  41. package/dist/routes/scripted/plan.js +25 -2
  42. package/dist/routes/scripted/setup.js +1 -4
  43. package/dist/routes/scripted/types.d.ts +1 -1
  44. package/dist/routes/shared-world/bundle-records.js +14 -0
  45. package/dist/routes/shared-world/bundle.js +2 -1
  46. package/dist/routes/shared-world/external-public.js +15 -3
  47. package/dist/routes/shared-world/handoff.d.ts +2 -2
  48. package/dist/routes/shared-world/participant-specs.js +2 -2
  49. package/dist/routes/shared-world/plan.d.ts +10 -1
  50. package/dist/routes/shared-world/plan.js +86 -10
  51. package/dist/routes/shared-world/provisioned.js +15 -8
  52. package/dist/routes/shared-world/route.js +7 -4
  53. package/dist/routes/shared-world/types.d.ts +2 -0
  54. package/dist/run/bundle-shape.js +12 -2
  55. package/dist/run/bundle.d.ts +20 -3
  56. package/dist/run/participant-records.js +1 -0
  57. package/dist/run/projection.js +7 -2
  58. package/dist/run/run.js +4 -2
  59. package/dist/run/subject-name.d.ts +11 -0
  60. package/dist/run/subject-name.js +11 -0
  61. package/dist/study/arrivals.d.ts +114 -0
  62. package/dist/study/arrivals.js +204 -0
  63. package/dist/study/composition-rules.js +1 -1
  64. package/dist/study/config.js +6 -20
  65. package/dist/study/doctor.js +2 -1
  66. package/dist/study/keys.d.ts +1 -0
  67. package/dist/study/keys.js +2 -0
  68. package/dist/study/migrate/v2.js +3 -2
  69. package/dist/study/parse/actors.d.ts +2 -2
  70. package/dist/study/parse/actors.js +10 -4
  71. package/dist/study/parse/front.d.ts +1 -1
  72. package/dist/study/parse/front.js +36 -2
  73. package/dist/study/plan-base.d.ts +1 -1
  74. package/dist/study/plan-participants.d.ts +2 -0
  75. package/dist/study/plan-participants.js +3 -5
  76. package/dist/study/plan-types.d.ts +13 -3
  77. package/dist/study/plan.d.ts +4 -4
  78. package/dist/study/plan.js +21 -12
  79. package/dist/study/preflight.d.ts +2 -1
  80. package/dist/study/preflight.js +24 -3
  81. package/dist/study/routing.d.ts +5 -4
  82. package/dist/study/routing.js +16 -7
  83. package/dist/study/types.d.ts +14 -0
  84. package/dist/study/validation.d.ts +2 -2
  85. package/dist/study/validation.js +27 -14
  86. package/dist/study/warnings.js +11 -3
  87. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  88. package/dist/substrates/e2b/lifetime.js +134 -10
  89. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  90. package/dist/tui-app.js +112 -112
  91. package/dist/verify/artifacts.d.ts +19 -4
  92. package/dist/verify/artifacts.js +39 -8
  93. package/dist/verify/verify.js +6 -15
  94. package/docs/contracts/run-bundle.md +8 -7
  95. package/docs/contracts/schemas.md +33 -9
  96. package/docs/contracts/study-analysis.md +61 -8
  97. package/docs/principles/invariants-and-defaults.md +14 -14
  98. package/docs/product/automatic-analysis.md +10 -3
  99. package/package.json +1 -1
@@ -5,6 +5,7 @@ import type { BrowserSurface } from "../actors/scripted-browser/types.js";
5
5
  import type { ScriptedRefusal } from "../routes/scripted/plan.js";
6
6
  import type { TerminalRefusal } from "../routes/terminal/plan.js";
7
7
  import type { RunStudyProvenance } from "../run/study-provenance.js";
8
+ import type { ArrivalPlan } from "./arrivals.js";
8
9
  import type { ComputerUseRefusal } from "../routes/computer-use/plan.js";
9
10
  import type { SharedWorldRefusal } from "../routes/shared-world/plan.js";
10
11
  import type { ComputerUseParticipant, ExternalPublicParticipant, ProvisionedParticipant } from "./plan-participants.js";
@@ -63,7 +64,8 @@ interface PlanBase {
63
64
  /** The study's declared title, which bundles record. */
64
65
  readonly title?: string;
65
66
  readonly study?: RunStudyProvenance;
66
- /** Warnings about the study's own fields, which the run records in its bundle as warn events. */
67
+ /** Warnings about the study's own fields and the plan made from them, which the run records in
68
+ * its bundle as warn events. */
67
69
  readonly warnings?: readonly string[];
68
70
  /** A frozen copy owned by the plan. */
69
71
  readonly residual: Readonly<ResidualConfig>;
@@ -162,6 +164,8 @@ export interface ComputerUsePlan extends PlanBase {
162
164
  readonly runner: ComputerUseRunner;
163
165
  /** Declared cap clamped to [1, participants]; the route may only lower it from env. */
164
166
  readonly concurrency: number;
167
+ /** When the participants start and how many run at once, with every session at its budget. */
168
+ readonly arrivals: ArrivalPlan;
165
169
  readonly sessionBudgetMs: number;
166
170
  readonly sandboxMs: number;
167
171
  readonly caps: {
@@ -196,6 +200,11 @@ export interface SharedWorldPlan extends PlanBase {
196
200
  readonly plane: SharedWorldPlane;
197
201
  /** At least 2. */
198
202
  readonly concurrency: number;
203
+ /**
204
+ * When the participants start and how many run at once, with every session at its budget. On
205
+ * the provisioned plane the subject sandbox serves until `lastEndMs`.
206
+ */
207
+ readonly arrivals: ArrivalPlan;
199
208
  /** Each participant's session budget: execution.timeoutMs, else the plane's derived default. */
200
209
  readonly sessionTimeoutMs: number;
201
210
  readonly brain: Extract<Brain, {
@@ -258,9 +267,10 @@ export interface ScriptedPlan extends PlanBase {
258
267
  readonly scenarioRef: string;
259
268
  /** Empty only for a library caller's `count: 0`, which the parser refuses; that run has no sessions. */
260
269
  readonly surfaces: readonly BrowserSurface[];
261
- /** The declared persona and session timeout; the route supplies its defaults. */
270
+ /** The declared persona; the route supplies its default. */
262
271
  readonly personaId?: string;
263
- readonly sessionTimeoutMs?: number;
272
+ /** Each surface's journey budget: execution.timeoutMs, else 5 minutes. */
273
+ readonly sessionTimeoutMs: number;
264
274
  }
265
275
  export type StudyPlan = PreviewPlan | ComputerUsePlan | SharedWorldPlan | TerminalPlan | ScriptedPlan;
266
276
  interface PlannedStudy {
@@ -7,9 +7,9 @@ export { routeOf, type StudyRoute } from "./routing.js";
7
7
  export declare function resolveStudyDryRun(config: StudyConfig, override: boolean | undefined, fallback: boolean | undefined): boolean | undefined;
8
8
  /**
9
9
  * The plan a study runs under, built without reading files or the network. From the environment
10
- * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
11
- * ceiling, which no study file can know. Each route's planner makes every refusal that route
12
- * makes, in the route's order and with its codes and messages; the route's exported runner calls
13
- * the same planner.
10
+ * (options.env, else process.env, as the routes read it) it reads the operator's E2B plan limits,
11
+ * the sandbox ceiling and the concurrent sandboxes, which no study file can know. Each route's
12
+ * planner makes every refusal that route makes, in the route's order and with its codes and
13
+ * messages; the route's exported runner calls the same planner.
14
14
  */
15
15
  export declare function planStudy(config: StudyConfig, options: InternalRunStudyOptions, deps?: StudyDeps): PlanResult;
@@ -6,7 +6,7 @@ import { callerDrivingOf, planComputerUseStudy } from "../routes/computer-use/pl
6
6
  import { injectedBrowser, planScriptedStudy } from "../routes/scripted/plan.js";
7
7
  import { planSharedWorldStudy } from "../routes/shared-world/plan.js";
8
8
  import { planTerminalStudy } from "../routes/terminal/plan.js";
9
- import { sandboxCeiling } from "../substrates/e2b/lifetime.js";
9
+ import { concurrentSandboxes, sandboxCeiling, } from "../substrates/e2b/lifetime.js";
10
10
  import { localBrowserDefaults } from "../substrates/local/runtime-config.js";
11
11
  import { THIS_REPO_DRY_RUN_ONLY } from "./composition-rules.js";
12
12
  import { planBase } from "./plan-base.js";
@@ -65,21 +65,25 @@ function planPreview(config, options, input) {
65
65
  }
66
66
  /**
67
67
  * The plan a study runs under, built without reading files or the network. From the environment
68
- * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
69
- * ceiling, which no study file can know. Each route's planner makes every refusal that route
70
- * makes, in the route's order and with its codes and messages; the route's exported runner calls
71
- * the same planner.
68
+ * (options.env, else process.env, as the routes read it) it reads the operator's E2B plan limits,
69
+ * the sandbox ceiling and the concurrent sandboxes, which no study file can know. Each route's
70
+ * planner makes every refusal that route makes, in the route's order and with its codes and
71
+ * messages; the route's exported runner calls the same planner.
72
72
  */
73
73
  export function planStudy(config, options, deps = {}) {
74
74
  const study = localBrowserDefaults(config);
75
75
  const input = { dryRun: resolveStudyDryRun(study, options.dryRun, true) ?? true };
76
- const ceiling = sandboxCeiling(options.env ?? process.env);
77
- const result = planRoute(routeOf(config), study, options, input, deps, ceiling);
76
+ const env = options.env ?? process.env;
77
+ const e2b = {
78
+ sandboxCeiling: sandboxCeiling(env),
79
+ concurrentSandboxes: concurrentSandboxes(env),
80
+ };
81
+ const result = planRoute(routeOf(config), study, options, input, deps, e2b);
78
82
  if (!result.ok)
79
83
  return result;
80
84
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
81
85
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
82
- const warnings = recordedStudyWarnings(study);
86
+ const warnings = [...recordedStudyWarnings(study), ...(result.plan.warnings ?? [])];
83
87
  const plan = {
84
88
  ...result.plan,
85
89
  ...(options.study === undefined ? {} : { study: options.study }),
@@ -87,7 +91,8 @@ export function planStudy(config, options, deps = {}) {
87
91
  };
88
92
  return { ok: true, planned: { plan } };
89
93
  }
90
- function planRoute(route, study, options, input, deps, sandboxCeiling) {
94
+ function planRoute(route, study, options, input, deps, e2b) {
95
+ const { sandboxCeiling } = e2b;
91
96
  switch (route) {
92
97
  case "preview":
93
98
  return planPreview(study, options, input);
@@ -98,13 +103,13 @@ function planRoute(route, study, options, input, deps, sandboxCeiling) {
98
103
  driving: callerDrivingOf(options),
99
104
  ...(options.count === undefined ? {} : { countOverride: options.count }),
100
105
  ...(options.rerun === undefined ? {} : { rerun: options.rerun }),
101
- sandboxCeiling,
106
+ ...e2b,
102
107
  });
103
108
  case "shared-world":
104
109
  return planSharedWorldStudy(study, {
105
110
  ...input,
106
111
  hasRunSession: deps.runSession !== undefined,
107
- sandboxCeiling,
112
+ ...e2b,
108
113
  });
109
114
  case "terminal":
110
115
  return planTerminalStudy(study, {
@@ -113,6 +118,10 @@ function planRoute(route, study, options, input, deps, sandboxCeiling) {
113
118
  sandboxCeiling,
114
119
  });
115
120
  case "scripted":
116
- return planScriptedStudy(study, { ...input, injectedBrowser: injectedBrowser(deps) });
121
+ return planScriptedStudy(study, {
122
+ ...input,
123
+ injectedBrowser: injectedBrowser(deps),
124
+ sandboxCeiling,
125
+ });
117
126
  }
118
127
  }
@@ -3,6 +3,7 @@ import type { E2BDesktopModule } from "../substrates/e2b/sdk.js";
3
3
  import { type StudyConfig } from "./types.js";
4
4
  import { STUDY_CHECK_SCHEMA } from "./preflight-result.js";
5
5
  import { type StudyRoute } from "./plan.js";
6
+ import type { PlanRefusal } from "./plan-types.js";
6
7
  import { type StudyResolveFailure } from "./discover.js";
7
8
  export type StudyPreflightReachabilityMode = "metadata" | "public-preview" | "sandbox-loopback" | "prepared-host";
8
9
  export interface StudyPreflightCheck {
@@ -57,7 +58,7 @@ export interface StudyPreflightResult {
57
58
  spend: StudyPreflightSpend;
58
59
  warnings: string[];
59
60
  error?: {
60
- code: StudyResolveFailure["error"]["code"] | "HUMANISH_STUDY_PREFLIGHT_INVALID_OPTION" | "HUMANISH_STUDY_PREFLIGHT_UNSUPPORTED_ROUTE" | "HUMANISH_STUDY_PREFLIGHT_TARGET_POLICY" | "HUMANISH_STUDY_PREFLIGHT_ENV_MISSING" | "HUMANISH_STUDY_PREFLIGHT_E2B_REQUIRED" | "HUMANISH_STUDY_PREFLIGHT_TARGET_UNREACHABLE" | "HUMANISH_STUDY_PREFLIGHT_PROVISION_FAILED" | "HUMANISH_STUDY_PREFLIGHT_TEARDOWN_FAILED";
61
+ code: StudyResolveFailure["error"]["code"] | "HUMANISH_STUDY_PREFLIGHT_INVALID_OPTION" | "HUMANISH_STUDY_PREFLIGHT_UNSUPPORTED_ROUTE" | "HUMANISH_STUDY_PREFLIGHT_TARGET_POLICY" | "HUMANISH_STUDY_PREFLIGHT_ENV_MISSING" | "HUMANISH_STUDY_PREFLIGHT_E2B_REQUIRED" | "HUMANISH_STUDY_PREFLIGHT_TARGET_UNREACHABLE" | "HUMANISH_STUDY_PREFLIGHT_PROVISION_FAILED" | "HUMANISH_STUDY_PREFLIGHT_TEARDOWN_FAILED" | PlanRefusal["code"];
61
62
  message: string;
62
63
  };
63
64
  }
@@ -4,8 +4,9 @@ import path from "node:path";
4
4
  import { isLoopbackUrl } from "./parse/subject.js";
5
5
  import {} from "./types.js";
6
6
  import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./preflight-probes.js";
7
- import { digest, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
- import { routeOf } from "./plan.js";
7
+ import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
+ import { planStudy, routeOf } from "./plan.js";
9
+ import { describeArrivals } from "./arrivals.js";
9
10
  import { resolveStudyManifest } from "./discover.js";
10
11
  import { participantList } from "./study-fields.js";
11
12
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
@@ -42,6 +43,7 @@ export async function runStudyPreflight(options) {
42
43
  ? new Map()
43
44
  : (await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(resolved.config))).personas;
44
45
  const route = routeOf(resolved.config);
46
+ const env = options.env ?? process.env;
45
47
  const ctx = {
46
48
  cwd,
47
49
  study: options.study,
@@ -52,7 +54,7 @@ export async function runStudyPreflight(options) {
52
54
  route,
53
55
  reachability,
54
56
  timeoutMs,
55
- env: options.env ?? process.env,
57
+ env,
56
58
  hooks: options.hooks ?? {},
57
59
  checks: [
58
60
  { name: "study file", ok: true, message: `resolved ${resolved.origin} study file` },
@@ -65,6 +67,17 @@ export async function runStudyPreflight(options) {
65
67
  ...personaBackgroundWarnings(resolved.config.id, participants, personas),
66
68
  ],
67
69
  };
70
+ // The plan `humanish run <study>` makes before it creates anything: no run options, so the file's
71
+ // mode decides dry or live, and the same environment for the sandbox ceiling. A study the run
72
+ // would refuse fails here with the run's code, before any probe creates a sandbox.
73
+ const planned = planStudy(resolved.config, { cwd, env });
74
+ if (!planned.ok) {
75
+ const { code, message } = planned.refusal;
76
+ return fail(ctx, code, message, [{ name: "plan", ok: false, message }]);
77
+ }
78
+ const schedule = scheduleCheck(planned.planned.plan);
79
+ if (schedule !== undefined)
80
+ ctx.checks.push(schedule);
68
81
  const machine = machineCheck(ctx.config);
69
82
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
70
83
  if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))
@@ -109,6 +122,14 @@ export async function runStudyPreflight(options) {
109
122
  });
110
123
  }
111
124
  }
125
+ /** The plan's schedule, for a study that declares when its participants start. */
126
+ function scheduleCheck(plan) {
127
+ if (plan.route !== "computer-use" && plan.route !== "shared-world")
128
+ return undefined;
129
+ if (!plan.arrivals.declared)
130
+ return undefined;
131
+ return { name: "schedule", ok: true, message: describeArrivals(plan.arrivals) };
132
+ }
112
133
  /** What study check leaves to doctor: this machine's setup for the study, and the command. */
113
134
  function machineCheck(config) {
114
135
  const command = cli(`doctor --study ${config.id}`);
@@ -1,5 +1,6 @@
1
1
  import type { StudyConfig } from "./types.js";
2
- export declare const MAX_COMPUTER_USE_PARTICIPANTS = 16;
2
+ /** Why a study of `count` participants cannot run, or undefined when it can. */
3
+ export declare function participantCountReason(count: number): string | undefined;
3
4
  export declare function actorResolvesToComputerUse(type: string | undefined): boolean;
4
5
  export declare function registeredComputerUseActors(): string[];
5
6
  export declare function actorResolvesToScriptedBrowser(type: string | undefined): boolean;
@@ -9,11 +10,11 @@ export declare function registeredScriptedBrowserActors(): string[];
9
10
  export declare function actorResolvesToTerminal(type: string | undefined): boolean;
10
11
  export declare function registeredTerminalActors(): string[];
11
12
  /**
12
- * The declared fan-out participant count on the computer-use route: a `participants` list's
13
- * length, else a participant count, else 1. The single source of truth shared by the parser, the engine,
13
+ * The fan-out participant count on the computer-use route: a `participants` list's length, else
14
+ * the caller's count override, else a participant count, else 1. The single source of truth shared by the parser, the engine,
14
15
  * and the pre-flight plan so the participant count is computed the same way everywhere.
15
16
  */
16
- export declare function computerUseParticipantCount(config: StudyConfig): number;
17
+ export declare function computerUseParticipantCount(config: StudyConfig, countOverride?: number): number;
17
18
  /**
18
19
  * A participant's id: its declared roster id, else `lane-NN` (independent participants) or `role-NN`
19
20
  * (shared-world participants) from its 0-based position. The parser's filled email recipients and
@@ -1,9 +1,17 @@
1
1
  import { actorRegistry } from "../actors/registry.js";
2
2
  import { isLoopbackUrl } from "./parse/subject.js";
3
3
  import { participantList, declaredParticipantCount } from "./study-fields.js";
4
- // Hard cap on computer-use participants. No setting raises it: each participant is a paid desktop,
5
- // and they all run at once.
6
- export const MAX_COMPUTER_USE_PARTICIPANTS = 16;
4
+ // The most participants one study holds, on any route. One run keeps every participant's stream in
5
+ // one bundle, one Observer view and one analysis, and those are sized for 100: the analysis reads a
6
+ // bundle of at most 128 streams. How many run at once is a separate limit, the E2B plan's
7
+ // (concurrentSandboxes in substrates/e2b/lifetime.ts), and caps.maxUsd bounds what they spend.
8
+ const MAX_STUDY_PARTICIPANTS = 100;
9
+ /** Why a study of `count` participants cannot run, or undefined when it can. */
10
+ export function participantCountReason(count) {
11
+ return count > MAX_STUDY_PARTICIPANTS
12
+ ? `A study runs at most ${MAX_STUDY_PARTICIPANTS} participants, and this one has ${count}. One run keeps every participant's evidence in one bundle and one Observer view, and humanish supports runs of up to ${MAX_STUDY_PARTICIPANTS}; split the participants across studies.`
13
+ : undefined;
14
+ }
7
15
  // Run kinds whose evidence is the participant's screenshots. An actor that declares it produces none
8
16
  // cannot run on them: a code-only actor would claim a GUI flow it only reached through a shell.
9
17
  const SCREENSHOT_RUN_KINDS = new Set([
@@ -46,12 +54,13 @@ export function registeredTerminalActors() {
46
54
  return registeredActorsOn("terminal");
47
55
  }
48
56
  /**
49
- * The declared fan-out participant count on the computer-use route: a `participants` list's
50
- * length, else a participant count, else 1. The single source of truth shared by the parser, the engine,
57
+ * The fan-out participant count on the computer-use route: a `participants` list's length, else
58
+ * the caller's count override, else a participant count, else 1. The single source of truth shared by the parser, the engine,
51
59
  * and the pre-flight plan so the participant count is computed the same way everywhere.
52
60
  */
53
- export function computerUseParticipantCount(config) {
54
- return participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
61
+ export function computerUseParticipantCount(config, countOverride) {
62
+ return (participantList(config)?.length ??
63
+ Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1));
55
64
  }
56
65
  /**
57
66
  * A participant's id: its declared roster id, else `lane-NN` (independent participants) or `role-NN`
@@ -319,6 +319,12 @@ export interface StudyParticipantEntry {
319
319
  * into the follower missions at a host-first barrier. Inert/warned on every other route.
320
320
  */
321
321
  host?: boolean;
322
+ /**
323
+ * Computer use and shared world: when this participant starts, in milliseconds after the run
324
+ * starts its participants (0 to 86,400,000). A group's `startEveryMs` sets it for each member.
325
+ * Absent, the participant starts with the run. src/study/arrivals.ts schedules the start.
326
+ */
327
+ startAfterMs?: number;
322
328
  }
323
329
  export interface StudyActor {
324
330
  /**
@@ -783,6 +789,14 @@ export interface StudyParticipantGroup {
783
789
  count?: number;
784
790
  instruction?: string;
785
791
  }
792
+ /**
793
+ * The keys a `participants` list entry with `count` adds to an entry's. parseStudy expands the
794
+ * group into its members, and member k starts at `startAfterMs + (k - 1) * startEveryMs`.
795
+ */
796
+ export interface StudyParticipantListGroup {
797
+ count?: number;
798
+ startEveryMs?: number;
799
+ }
786
800
  /** The scripted surfaces: desktop, or desktop and mobile. */
787
801
  export type StudySurfaces = readonly ["desktop"] | readonly ["desktop", "mobile"];
788
802
  interface StudyParseSuccess {
@@ -2,8 +2,8 @@ import type { StudyConfig } from "./types.js";
2
2
  /**
3
3
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
4
4
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
5
- * that skipped the parser. It runs rosterStructuralValidationReason (id and device
6
- * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
5
+ * that skipped the parser. It runs rosterStructuralValidationReason (roster size, id and device
6
+ * validity, unique ids) first, then the route-scoped XOR, participant count and policy checks.
7
7
  */
8
8
  export declare function computerUseValidationReason(config: StudyConfig): string | null;
9
9
  /**
@@ -1,15 +1,17 @@
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";
9
+ import { isStartAfterMs, rosterStartsReason, startAfterMessage } from "./arrivals.js";
8
10
  /**
9
11
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
10
12
  * 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.
13
+ * that skipped the parser. It runs rosterStructuralValidationReason (roster size, id and device
14
+ * validity, unique ids) first, then the route-scoped XOR, participant count and policy checks.
13
15
  */
14
16
  export function computerUseValidationReason(config) {
15
17
  const roster = participantList(config);
@@ -42,9 +44,9 @@ export function computerUseValidationReason(config) {
42
44
  }
43
45
  }
44
46
  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
- }
47
+ const countReason = participantCountReason(participantCount);
48
+ if (countReason)
49
+ return countReason;
48
50
  // Public targets fan out into N independent worlds driving the same public app, which is an
49
51
  // ambiguous shared-world-ish shape, not a per-participant target swarm. Permit N>1 public runs only
50
52
  // when every roster entry declares its own target, making the adapter-owned topology explicit. But when
@@ -60,8 +62,8 @@ export function computerUseValidationReason(config) {
60
62
  return null;
61
63
  }
62
64
  /**
63
- * Engine-level path-token validation for configs supplied directly through the
64
- * public TypeScript/JavaScript API instead of parseStudy.
65
+ * Engine-level roster validation (its size and its ids' path tokens) for configs supplied directly
66
+ * through the public TypeScript/JavaScript API instead of parseStudy.
65
67
  */
66
68
  function rosterStructuralValidationReason(config) {
67
69
  const roster = participantList(config);
@@ -70,10 +72,16 @@ function rosterStructuralValidationReason(config) {
70
72
  if (!Array.isArray(roster) || roster.length === 0) {
71
73
  return "participants must be a non-empty array when set.";
72
74
  }
75
+ const countReason = participantCountReason(roster.length);
76
+ if (countReason)
77
+ return countReason;
73
78
  for (const [index, entry] of roster.entries()) {
74
79
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
75
80
  return `participants[${index}] must be an object.`;
76
81
  }
82
+ if (entry.startAfterMs !== undefined && !isStartAfterMs(entry.startAfterMs)) {
83
+ return startAfterMessage(`participants[${index}].startAfterMs`);
84
+ }
77
85
  const id = entry.id;
78
86
  if (id === undefined) {
79
87
  continue;
@@ -88,6 +96,7 @@ function rosterStructuralValidationReason(config) {
88
96
  }
89
97
  seenIds.add(id);
90
98
  }
99
+ return rosterStartsReason(roster) ?? null;
91
100
  }
92
101
  return null;
93
102
  }
@@ -195,6 +204,10 @@ export function receivingEmailValidationReason(config) {
195
204
  !["app-url", "clone", "local-tree"].includes(config.subject.source)) {
196
205
  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
206
  }
207
+ const participants = computerUseParticipantCount(config);
208
+ if (participants > MAX_RECEIVING_INBOXES) {
209
+ 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.`;
210
+ }
198
211
  if (config.actor?.type === "local-agent") {
199
212
  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
213
  }
@@ -245,14 +258,14 @@ export function waitLimitValidationReason(config) {
245
258
  }
246
259
  /**
247
260
  * 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.
261
+ * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0. An omitted
262
+ * concurrency runs every participant, up to the E2B plan's limit, which the planner checks. Both
263
+ * callers check it after their two-participant roster floor, so a one-participant roster gets the
264
+ * roster refusal, never this one.
252
265
  */
253
266
  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.
267
+ // An omitted value runs every participant, so it is checked as the participant count. A missing
268
+ // roster reads as 0 and is refused.
256
269
  const participants = participantList(config)?.length ?? 0;
257
270
  const concurrency = config.execution?.concurrency ?? participants;
258
271
  if (concurrency >= 2)
@@ -302,11 +302,19 @@ export function forwardDeclaredWarnings(config) {
302
302
  ];
303
303
  // A declared cap below the participant count is legal but loud: the roster promises N live actors
304
304
  // and the cap delivers waves of M. Say so up front (inspect + dry-run + run): a green run in
305
- // waves is otherwise indistinguishable from the all-live run the author meant.
305
+ // waves is otherwise indistinguishable from the all-live run the author meant. A roster that
306
+ // declares when its participants start never promised them all at once; the plan's schedule
307
+ // says who waits for a slot.
306
308
  {
307
- const participantCount = participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
309
+ const roster = participantList(config);
310
+ const participantCount = roster?.length ?? declaredParticipantCount(config) ?? 1;
308
311
  const cap = config.execution?.concurrency;
309
- if (routes.cua && cap !== undefined && participantCount > 1 && cap < participantCount) {
312
+ const scheduled = roster?.some((entry) => entry.startAfterMs !== undefined) === true;
313
+ if (routes.cua &&
314
+ !scheduled &&
315
+ cap !== undefined &&
316
+ participantCount > 1 &&
317
+ cap < participantCount) {
310
318
  warnings.push(`execution.concurrency ${cap} caps a ${participantCount}-participant roster: participants run in waves of ${cap}, never all live at once. Remove execution.concurrency (the default runs all ${participantCount} participants simultaneously) or set it to ${participantCount}; declare a lower cap only to bound simultaneous paid desktops.`);
311
319
  }
312
320
  }
@@ -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;