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
@@ -0,0 +1,114 @@
1
+ import type { RunParticipantArrival } from "../run/bundle.js";
2
+ /**
3
+ * The latest start a study may declare: 24 hours, the longest sandbox lifetime E2B documents. It
4
+ * also keeps every wait under Node's timer limit of 2^31 - 1 ms, past which a timer fires at once.
5
+ */
6
+ export declare const LATEST_START_AFTER_MS: number;
7
+ /** Whether `value` is a start offset humanish runs: whole milliseconds from 0 to 24 hours. */
8
+ export declare function isStartAfterMs(value: unknown): value is number;
9
+ /** The refusal of a start offset that is not one, naming its `field`. */
10
+ export declare function startAfterMessage(field: string): string;
11
+ /**
12
+ * Why a roster's declared starts cannot run, or undefined. A start orders participants against
13
+ * each other, so it needs two or more of them, and the host of an external public app opens the
14
+ * session the others join, so it starts with the run.
15
+ */
16
+ export declare function rosterStartsReason(roster: readonly {
17
+ readonly id?: string;
18
+ readonly startAfterMs?: number;
19
+ readonly host?: boolean;
20
+ }[]): string | undefined;
21
+ /** What the plan says about when a run's participants start, with every session at its full budget. */
22
+ export interface ArrivalPlan {
23
+ /** Whether any participant declares `startAfterMs`. */
24
+ readonly declared: boolean;
25
+ /** The earliest and the latest declared start, in ms after the run starts its participants. */
26
+ readonly firstStartMs: number;
27
+ readonly lastStartMs: number;
28
+ /** The most participants running at once. */
29
+ readonly peak: number;
30
+ /** How many participants wait past their time for a free slot, and the longest of those waits. */
31
+ readonly waiting: number;
32
+ readonly longestWaitMs: number;
33
+ /** When the last session ends at the latest, in ms after the run starts its participants. */
34
+ readonly lastEndMs: number;
35
+ /** The session budget every figure above assumes each participant uses. */
36
+ readonly sessionMs: number;
37
+ }
38
+ /** How the runtime starts participants: how many run at once, and for how long at most. */
39
+ export interface StartQueue {
40
+ readonly slots: number;
41
+ readonly sessionMs: number;
42
+ /**
43
+ * The roster index of a participant that runs on a slot of its own, outside the queue: the
44
+ * external-public host, which the others wait on. It counts against `slots`.
45
+ */
46
+ readonly ownSlot?: number;
47
+ }
48
+ /**
49
+ * The schedule a roster's declared offsets give under the queue (`undefined` is 0: the participant
50
+ * starts with the run). Participants start in schedule order, by offset and then roster position,
51
+ * each at its time or when a slot frees, whichever is later; this is the rule runOnSchedule
52
+ * follows.
53
+ */
54
+ export declare function planArrivals(offsets: readonly (number | undefined)[], queue: StartQueue): ArrivalPlan;
55
+ /**
56
+ * The plan's schedule, after a "schedule:" label: the first and last start, and how many run at
57
+ * once at the most.
58
+ */
59
+ export declare function describeArrivals(arrivals: ArrivalPlan): string;
60
+ /**
61
+ * A planner's arrival plan for its participants under the start queue, and the warnings the plan
62
+ * records about it. A participant's desktop is created when it starts, so on computer use a late
63
+ * start adds nothing to any sandbox's deadline; the provisioned shared world checks its app's
64
+ * sandbox with servedScheduleRefusal. `lowered` is the warning that the E2B plan's limit runs the
65
+ * roster in waves.
66
+ */
67
+ export declare function planStarts(participants: readonly {
68
+ readonly startAfterMs?: number;
69
+ }[], queue: StartQueue, lowered: string | undefined): {
70
+ arrivals: ArrivalPlan;
71
+ warnings: string[];
72
+ };
73
+ /**
74
+ * Why a sandbox that serves the app until the last participant ends, the provisioned shared
75
+ * world's subject sandbox, cannot live that long under `ceilingMs`; undefined when it can. Its
76
+ * deadline is the schedule's `lastEndMs` plus the time to provision and seed the subject and tear
77
+ * it down. The session alone is checked first (sandboxDeadlineRefusal), so this names what the
78
+ * schedule adds: a late start or a wait for a free slot.
79
+ */
80
+ export declare function servedScheduleRefusal(arrivals: ArrivalPlan, sandbox: {
81
+ readonly sessionMs: number;
82
+ readonly seed: readonly {
83
+ readonly timeoutMs?: number | undefined;
84
+ }[];
85
+ }, ceilingMs: number): string | undefined;
86
+ /** When a participant was due and when it started, in epoch ms. One that never started has no `startedAt`. */
87
+ export interface ParticipantArrival {
88
+ readonly scheduledAt: number;
89
+ readonly startedAt?: number;
90
+ }
91
+ /** A participant's `arrival` in run.json: its offset, and on a live run when it was due and started. */
92
+ export declare function arrivalRecord(startAfterMs: number | undefined, arrival: ParticipantArrival | undefined): RunParticipantArrival;
93
+ /** Where a participant sits in the run's schedule when its start comes. */
94
+ export interface ScheduledStart {
95
+ /** Its place in schedule order, from 0: the first participant to start has 0. */
96
+ readonly order: number;
97
+ /** Its time in epoch ms: the moment the run started its participants plus its offset. */
98
+ readonly scheduledAt: number;
99
+ }
100
+ /**
101
+ * Calls `start` for each item at its time, with at most `slots` calls in flight. Items start in
102
+ * schedule order; one whose time comes while every slot is busy waits for the next free slot. An
103
+ * abort of `signal` ends every wait at once, so `start` runs for the waiting items right away and
104
+ * decides whether to skip them. Results come back in the items' order.
105
+ */
106
+ export declare function runOnSchedule<T, R>(items: readonly T[], options: {
107
+ readonly startAfterMs: (item: T) => number | undefined;
108
+ readonly slots: number;
109
+ /** The run's clock in epoch ms, which must advance while a start waits. Defaults to Date.now. */
110
+ readonly now?: () => number;
111
+ /** Waits `ms` or until `signal` aborts. Defaults to node:timers/promises' setTimeout. */
112
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
113
+ readonly signal?: AbortSignal;
114
+ }, start: (item: T, index: number, scheduled: ScheduledStart) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,204 @@
1
+ // When each participant of a computer-use or shared-world run starts. A study may give each
2
+ // participant an offset from the moment the run starts its participants
3
+ // (`participants[].startAfterMs`). The plan simulates the start queue to report the schedule and
4
+ // when the last session ends at the latest; the routes start their participants through
5
+ // runOnSchedule, which waits for each one's time and then for a free slot. A participant's desktop
6
+ // is created when it starts, so a late start holds no sandbox before then.
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { formatDuration } from "../run/projection.js";
9
+ import { plural } from "../run/text.js";
10
+ import { ceilingAdvice, sandboxHeadroomMs } from "../substrates/e2b/lifetime.js";
11
+ /**
12
+ * The latest start a study may declare: 24 hours, the longest sandbox lifetime E2B documents. It
13
+ * also keeps every wait under Node's timer limit of 2^31 - 1 ms, past which a timer fires at once.
14
+ */
15
+ export const LATEST_START_AFTER_MS = 24 * 60 * 60_000;
16
+ /** Whether `value` is a start offset humanish runs: whole milliseconds from 0 to 24 hours. */
17
+ export function isStartAfterMs(value) {
18
+ return (typeof value === "number" &&
19
+ Number.isSafeInteger(value) &&
20
+ value >= 0 &&
21
+ value <= LATEST_START_AFTER_MS);
22
+ }
23
+ /** The refusal of a start offset that is not one, naming its `field`. */
24
+ export function startAfterMessage(field) {
25
+ return `${field} must be a whole number of milliseconds from 0 to ${LATEST_START_AFTER_MS} (24 hours): how long after the run starts its participants this one starts.`;
26
+ }
27
+ /**
28
+ * Why a roster's declared starts cannot run, or undefined. A start orders participants against
29
+ * each other, so it needs two or more of them, and the host of an external public app opens the
30
+ * session the others join, so it starts with the run.
31
+ */
32
+ export function rosterStartsReason(roster) {
33
+ if (roster.length === 1 && roster[0]?.startAfterMs !== undefined)
34
+ return "`startAfterMs` sets when a participant starts relative to the others, so it needs two or more participants, and this study has one. Remove it: a single participant starts with the run.";
35
+ const host = roster.find((entry) => entry.host === true && (entry.startAfterMs ?? 0) > 0);
36
+ if (host !== undefined)
37
+ return `The host participant${host.id === undefined ? "" : ` "${host.id}"`} opens the shared session the others join, so it starts with the run. Remove its \`startAfterMs\`, or set it to 0.`;
38
+ return undefined;
39
+ }
40
+ /**
41
+ * The schedule a roster's declared offsets give under the queue (`undefined` is 0: the participant
42
+ * starts with the run). Participants start in schedule order, by offset and then roster position,
43
+ * each at its time or when a slot frees, whichever is later; this is the rule runOnSchedule
44
+ * follows.
45
+ */
46
+ export function planArrivals(offsets, queue) {
47
+ const due = offsets.map((offset) => offset ?? 0);
48
+ const starts = queueStarts(due, queue);
49
+ let waiting = 0;
50
+ let longestWaitMs = 0;
51
+ for (const [index, start] of starts.entries()) {
52
+ const waitMs = start - due[index];
53
+ if (waitMs > 0)
54
+ waiting += 1;
55
+ longestWaitMs = Math.max(longestWaitMs, waitMs);
56
+ }
57
+ return {
58
+ declared: offsets.some((offset) => offset !== undefined),
59
+ firstStartMs: Math.min(...due),
60
+ lastStartMs: Math.max(...due),
61
+ peak: mostAtOnce(starts, queue.sessionMs),
62
+ waiting,
63
+ longestWaitMs,
64
+ lastEndMs: Math.max(...starts) + queue.sessionMs,
65
+ sessionMs: queue.sessionMs,
66
+ };
67
+ }
68
+ /**
69
+ * The plan's schedule, after a "schedule:" label: the first and last start, and how many run at
70
+ * once at the most.
71
+ */
72
+ export function describeArrivals(arrivals) {
73
+ const late = arrivals.waiting === 0
74
+ ? ""
75
+ : `; ${plural(arrivals.waiting, "participant")} then start late, waiting for a free slot, the latest by ${formatDuration(arrivals.longestWaitMs)}`;
76
+ return `the first participant starts at +${formatDuration(arrivals.firstStartMs)} and the last at +${formatDuration(arrivals.lastStartMs)}; at most ${arrivals.peak} run at once when every session uses its ${formatDuration(arrivals.sessionMs)} budget${late}.`;
77
+ }
78
+ /**
79
+ * A planner's arrival plan for its participants under the start queue, and the warnings the plan
80
+ * records about it. A participant's desktop is created when it starts, so on computer use a late
81
+ * start adds nothing to any sandbox's deadline; the provisioned shared world checks its app's
82
+ * sandbox with servedScheduleRefusal. `lowered` is the warning that the E2B plan's limit runs the
83
+ * roster in waves.
84
+ */
85
+ export function planStarts(participants, queue, lowered) {
86
+ const arrivals = planArrivals(participants.map((participant) => participant.startAfterMs), queue);
87
+ return { arrivals, warnings: startQueueWarnings(arrivals, queue, lowered) };
88
+ }
89
+ // A roster that starts together keeps `lowered`. A declared schedule spreads its participants
90
+ // itself, so it is warned only when it holds someone past their time, with `lowered` beside that
91
+ // when the limit is the reason.
92
+ function startQueueWarnings(arrivals, queue, lowered) {
93
+ if (!arrivals.declared || arrivals.waiting === 0)
94
+ return !arrivals.declared && lowered ? [lowered] : [];
95
+ const late = `${plural(arrivals.waiting, "participant")} would start after their time when every session uses its ${formatDuration(arrivals.sessionMs)} budget, the latest by ${formatDuration(arrivals.longestWaitMs)}: the run starts at most ${queue.slots} at once and holds the next one until a slot frees.`;
96
+ return lowered === undefined ? [late] : [late, lowered];
97
+ }
98
+ /**
99
+ * Why a sandbox that serves the app until the last participant ends, the provisioned shared
100
+ * world's subject sandbox, cannot live that long under `ceilingMs`; undefined when it can. Its
101
+ * deadline is the schedule's `lastEndMs` plus the time to provision and seed the subject and tear
102
+ * it down. The session alone is checked first (sandboxDeadlineRefusal), so this names what the
103
+ * schedule adds: a late start or a wait for a free slot.
104
+ */
105
+ export function servedScheduleRefusal(arrivals, sandbox, ceilingMs) {
106
+ if (arrivals.lastEndMs <= sandbox.sessionMs)
107
+ return undefined;
108
+ const headroomMs = sandboxHeadroomMs({ seed: sandbox.seed });
109
+ const deadlineMs = arrivals.lastEndMs + headroomMs;
110
+ if (deadlineMs <= ceilingMs)
111
+ return undefined;
112
+ const inMinutes = (ms) => Math.round(ms / 60_000);
113
+ const causes = [
114
+ ...(arrivals.lastStartMs > arrivals.firstStartMs
115
+ ? [
116
+ `the last one is due ${formatDuration(arrivals.lastStartMs - arrivals.firstStartMs)} after the first`,
117
+ ]
118
+ : []),
119
+ ...(arrivals.waiting > 0
120
+ ? [`${plural(arrivals.waiting, "participant")} wait for a free slot`]
121
+ : []),
122
+ ];
123
+ return `The participants use the app for up to ${formatDuration(arrivals.lastEndMs)} when every session uses its ${formatDuration(sandbox.sessionMs)} budget, because ${causes.join(" and ")}. The subject sandbox, which serves the app until every participant ends, then needs a ${inMinutes(deadlineMs)}m deadline, with ${inMinutes(headroomMs)}m to provision and seed the subject and tear it down, and a sandbox may not live longer than ${ceilingMs / 60_000}m. Start the last participant earlier, lower execution.timeoutMs, or run more participants at once.${ceilingAdvice(deadlineMs)}`;
124
+ }
125
+ /** The roster indexes in the order participants start: by offset, then roster position. */
126
+ function scheduleOrder(due) {
127
+ return due.map((_, index) => index).sort((a, b) => due[a] - due[b] || a - b);
128
+ }
129
+ /** Each participant's start in roster order, when every session holds its slot for `sessionMs`. */
130
+ function queueStarts(due, queue) {
131
+ const starts = [...due];
132
+ const queued = scheduleOrder(due).filter((index) => index !== queue.ownSlot);
133
+ const pooled = queue.ownSlot === undefined ? queue.slots : queue.slots - 1;
134
+ // When each slot is next free; a participant takes the one that frees first.
135
+ const free = Array.from({ length: Math.max(1, Math.min(pooled, queued.length)) }, () => 0);
136
+ for (const index of queued) {
137
+ const slot = free.indexOf(Math.min(...free));
138
+ const start = Math.max(due[index], free[slot]);
139
+ starts[index] = start;
140
+ free[slot] = start + queue.sessionMs;
141
+ }
142
+ return starts;
143
+ }
144
+ function mostAtOnce(starts, sessionMs) {
145
+ // An end sorts before a start at the same instant: that slot is free again.
146
+ const changes = starts
147
+ .flatMap((start) => [
148
+ { at: start, by: 1 },
149
+ { at: start + sessionMs, by: -1 },
150
+ ])
151
+ .sort((a, b) => a.at - b.at || a.by - b.by);
152
+ let live = 0;
153
+ let most = 0;
154
+ for (const change of changes) {
155
+ live += change.by;
156
+ most = Math.max(most, live);
157
+ }
158
+ return most;
159
+ }
160
+ /** A participant's `arrival` in run.json: its offset, and on a live run when it was due and started. */
161
+ export function arrivalRecord(startAfterMs, arrival) {
162
+ const iso = (ms) => new Date(ms).toISOString();
163
+ return {
164
+ startAfterMs: startAfterMs ?? 0,
165
+ ...(arrival === undefined ? {} : { scheduledAt: iso(arrival.scheduledAt) }),
166
+ ...(arrival?.startedAt === undefined ? {} : { startedAt: iso(arrival.startedAt) }),
167
+ };
168
+ }
169
+ /**
170
+ * Calls `start` for each item at its time, with at most `slots` calls in flight. Items start in
171
+ * schedule order; one whose time comes while every slot is busy waits for the next free slot. An
172
+ * abort of `signal` ends every wait at once, so `start` runs for the waiting items right away and
173
+ * decides whether to skip them. Results come back in the items' order.
174
+ */
175
+ export async function runOnSchedule(items, options, start) {
176
+ const now = options.now ?? Date.now;
177
+ const sleep = options.sleep ?? sleepUntilAborted;
178
+ const anchor = now();
179
+ const due = items.map((item) => options.startAfterMs(item) ?? 0);
180
+ const order = scheduleOrder(due);
181
+ const results = new Array(items.length);
182
+ let next = 0;
183
+ const workers = Math.max(1, Math.min(options.slots, items.length));
184
+ await Promise.all(Array.from({ length: workers }, async () => {
185
+ while (next < order.length) {
186
+ const position = next;
187
+ next += 1;
188
+ const index = order[position];
189
+ const scheduledAt = anchor + due[index];
190
+ // A timer can fire a millisecond before the clock it was set against reads its time.
191
+ for (let waitMs = scheduledAt - now(); waitMs > 0; waitMs = scheduledAt - now()) {
192
+ if (options.signal?.aborted === true)
193
+ break;
194
+ await sleep(waitMs, options.signal);
195
+ }
196
+ results[index] = await start(items[index], index, { order: position, scheduledAt });
197
+ }
198
+ }));
199
+ return results;
200
+ }
201
+ /** An abort ends the wait early; the caller reads the signal itself. */
202
+ function sleepUntilAborted(ms, signal) {
203
+ return delay(ms, undefined, signal === undefined ? {} : { signal }).catch(() => undefined);
204
+ }
@@ -118,7 +118,7 @@ function appUrlValidationReason(config) {
118
118
  return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
119
119
  }
120
120
  // Multi-participant fan-out is consumed on this route (`per-lane-worlds`; the shared cua
121
- // cross-validation below enforces the 16 cap, the per-participant target gates, and the
121
+ // cross-validation below enforces the participant bound, the per-participant target gates, and the
122
122
  // allowPublicTargets+N>1 rejection for ambiguous one-target fan-out).
123
123
  // Loopback by default; an owner may declare a public/preview target via policies.
124
124
  const targets = [config.subject.appUrl ?? "", ...declaredTargets(config)];
@@ -18,14 +18,14 @@
18
18
  // The terminal route consumes subject.product, `caps` and execution.{terminal,runtimeAuth}.
19
19
  //
20
20
  // NOTE on `participants`: a count (preview: the simulated count; computer use: N identical
21
- // participants, each its own desktop, capped at 16), `{ count, instruction }` (identical
21
+ // participants, each its own desktop, at most 100), `{ count, instruction }` (identical
22
22
  // participants with one steer), or a list of `{ id?, persona?, device?, instruction?, target? ... }`
23
23
  // entries, each its own desktop. A list entry with a `count` is a group that expands into
24
24
  // `<id>-01` to `<id>-NN`. A shared world takes a list of at least two. `participants[].device`
25
25
  // excludes a raw `execution.desktop.resolution`, and `participants[].target` is app-url computer
26
26
  // use only: an absolute browser URL this participant opens in place of `subject.appUrl`.
27
- // `execution.concurrency` bounds in-flight participants (default: all at once; env
28
- // HUMANISH_CUA_MAX_CONCURRENCY may only lower it).
27
+ // `execution.concurrency` bounds in-flight participants (default: all at once, up to the E2B
28
+ // plan's concurrent sandboxes; env HUMANISH_CUA_MAX_CONCURRENCY may only lower it).
29
29
  import { isLocalBrowserStudy, localBrowserDefaults, localBrowserUnsupportedReason, } from "../substrates/local/runtime-config.js";
30
30
  import { isRecord } from "../run/type-guards.js";
31
31
  import { compositionReason } from "./composition-rules.js";
@@ -36,9 +36,8 @@ import { parseCaps, parseDefaults, parseExecution, parsePolicies, parseReview, }
36
36
  import { ACTOR_REQUIRED, readStudyFront, routeNameReason, } from "./parse/front.js";
37
37
  import { parseSubject } from "./parse/subject.js";
38
38
  import { invalid, optionalStr, str } from "./parse/values.js";
39
- import { isComputerUseComposition, isSharedWorldComposition, routeOf } from "./routing.js";
39
+ import { isComputerUseComposition, routeOf } from "./routing.js";
40
40
  import { declaredParticipantIds } from "./plan-participants.js";
41
- import { declaredParticipantCount, participantList } from "./study-fields.js";
42
41
  import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
43
42
  import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
44
43
  import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
@@ -216,7 +215,8 @@ function parseParticipants(raw, read) {
216
215
  }
217
216
  /**
218
217
  * The checks that read more than one section of a parsed config, and the defaults they fill:
219
- * shared-world `execution.concurrency`, `comms.email.recipients` and the local browser's defaults.
218
+ * `comms.email.recipients` and the local browser's defaults. An omitted `execution.concurrency`
219
+ * stays unset: the planner resolves it from the participants and the E2B plan's limit.
220
220
  */
221
221
  function checkStudyConfig(config) {
222
222
  const smtpReason = smtpValidationReason(config);
@@ -231,20 +231,6 @@ function checkStudyConfig(config) {
231
231
  const waitLimitReason = waitLimitValidationReason(config);
232
232
  if (waitLimitReason)
233
233
  return invalid(waitLimitReason);
234
- // All-parallel default: a multi-participant study that does not declare execution.concurrency
235
- // runs every participant at once; the declared field is a cap the author chose, never a mode.
236
- // Independent computer-use participants resolve that default from the final participant count
237
- // when they plan, after any --count override, so the parser leaves it unset for them. A shared
238
- // world's roster is fixed, so its default is filled here for the envelopes and warnings that read
239
- // the parsed config.
240
- {
241
- const participantCount = participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
242
- if (participantCount > 1 &&
243
- config.execution?.concurrency === undefined &&
244
- isSharedWorldComposition(config)) {
245
- config.execution = { ...config.execution, concurrency: participantCount };
246
- }
247
- }
248
234
  // Email that just works: the funnel's only handoff to an actor is the per-participant
249
235
  // inbox instruction, gated on recipients[]. Guessed participant ids broke a field run:
250
236
  // recipients copied from a single-participant example matched nothing, so every actor was left inbox-blind with zero
@@ -5,6 +5,7 @@ import { capacityShortfall, describeCapacity, } from "../substrates/local/capaci
5
5
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
6
6
  import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
7
7
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
8
+ import { admissionRule } from "../analysis/admission.js";
8
9
  import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
9
10
  import { externalCatchHealthy } from "../comms/sandbox-catch.js";
10
11
  import { receivingRequiredKey } from "../comms/setup.js";
@@ -368,7 +369,7 @@ async function analysisCheck(analysis, args, checkAccount) {
368
369
  ok: true,
369
370
  ...(keyed ? {} : { status: "note" }),
370
371
  message: keyed
371
- ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if its expected cost plus a 10% margin is over $${analysis.maxCostUsd}; this is not a billing cap. Participant readiness is independent.`
372
+ ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
372
373
  : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
373
374
  };
374
375
  }
@@ -18,6 +18,7 @@ export declare const PARTICIPANT_ENTRY: {
18
18
  target: true;
19
19
  entry: true;
20
20
  host: true;
21
+ startAfterMs: true;
21
22
  reasoningEffort: true;
22
23
  stopWhen: {
23
24
  any: {
@@ -39,6 +39,7 @@ export const PARTICIPANT_ENTRY = {
39
39
  target: true,
40
40
  entry: true,
41
41
  host: true,
42
+ startAfterMs: true,
42
43
  reasoningEffort: true,
43
44
  stopWhen: STOP_WHEN,
44
45
  dwell: DWELL,
@@ -169,6 +170,7 @@ const STUDY_KEYS = {
169
170
  participants: {
170
171
  ...PARTICIPANT_ENTRY,
171
172
  count: true,
173
+ startEveryMs: true,
172
174
  },
173
175
  surfaces: true,
174
176
  caps: CAPS,
@@ -92,9 +92,10 @@ const V2_KEYS = {
92
92
  actors: {
93
93
  type: true,
94
94
  count: true,
95
- lanes: PARTICIPANT_ENTRY,
95
+ // startAfterMs arrived after v2, so a v2 participant cannot carry it.
96
+ lanes: without(PARTICIPANT_ENTRY, "startAfterMs"),
96
97
  // Roster groups are participants with a count; the parser expanded them into `lanes[]`.
97
- roster: { ...PARTICIPANT_ENTRY, count: true },
98
+ roster: { ...without(PARTICIPANT_ENTRY, "startAfterMs"), count: true },
98
99
  // maxWaitMs and idleWaitMs arrived after v2, so a v2 file cannot carry them.
99
100
  ...without(ACTOR, "type", "maxWaitMs", "idleWaitMs"),
100
101
  laneFocus: { id: true, label: true, instruction: true },
@@ -25,10 +25,10 @@ export interface ParticipantPaths {
25
25
  * Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
26
26
  * study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
27
27
  * Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
28
- * instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
28
+ * instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }`.
29
29
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
30
30
  * must be public-safe tokens; a device must be a known preset name. The
31
- * route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
31
+ * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
32
32
  * where the route is known.
33
33
  */
34
34
  export declare function parseParticipantEntries(raw: unknown, paths: ParticipantPaths): {
@@ -7,6 +7,7 @@ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js
7
7
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
8
8
  import { invalid, str } from "./values.js";
9
9
  import { isRecord } from "../../run/type-guards.js";
10
+ import { isStartAfterMs, startAfterMessage } from "../arrivals.js";
10
11
  // A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
11
12
  // it must be a public-safe path token, same shape as a study id.
12
13
  export const PARTICIPANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -96,10 +97,10 @@ export function parseActorFields(entry, path) {
96
97
  * Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
97
98
  * study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
98
99
  * Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
99
- * instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
100
+ * instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }`.
100
101
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
101
102
  * must be public-safe tokens; a device must be a known preset name. The
102
- * route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
103
+ * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
103
104
  * where the route is known.
104
105
  */
105
106
  export function parseParticipantEntries(raw, paths) {
@@ -107,13 +108,13 @@ export function parseParticipantEntries(raw, paths) {
107
108
  return { ok: true, value: undefined };
108
109
  }
109
110
  if (!Array.isArray(raw) || raw.length === 0) {
110
- return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
111
+ return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
111
112
  }
112
113
  const entries = [];
113
114
  const seenIds = new Set();
114
115
  for (const [entryIndex, entry] of raw.entries()) {
115
116
  if (!isRecord(entry)) {
116
- return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }).`);
117
+ return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }).`);
117
118
  }
118
119
  const parsedEntry = {};
119
120
  const id = str(entry.id);
@@ -199,6 +200,11 @@ export function parseParticipantEntries(raw, paths) {
199
200
  if (entry.host)
200
201
  parsedEntry.host = true;
201
202
  }
203
+ if (entry.startAfterMs !== undefined) {
204
+ if (!isStartAfterMs(entry.startAfterMs))
205
+ return invalid(startAfterMessage(`${paths.entry(entryIndex)}.startAfterMs`));
206
+ parsedEntry.startAfterMs = entry.startAfterMs;
207
+ }
202
208
  entries.push(parsedEntry);
203
209
  }
204
210
  return { ok: true, value: entries };
@@ -1,4 +1,4 @@
1
- import type { StudyRoute } from "../routing.js";
1
+ import { type StudyRoute } from "../routing.js";
2
2
  import type { StudyParseFailure, StudySurfaces } from "../types.js";
3
3
  /** A document's `participants`, read before the participant entries parse. */
4
4
  export interface Participants {
@@ -4,8 +4,10 @@
4
4
  import { isRecord } from "../../run/type-guards.js";
5
5
  import { findUnknownStudyKey } from "../keys.js";
6
6
  import { movedV2KeyReason } from "../migrate/v2.js";
7
+ import { participantCountReason } from "../routing.js";
7
8
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
8
9
  import { invalid, posInt } from "./values.js";
10
+ import { isStartAfterMs, LATEST_START_AFTER_MS, startAfterMessage } from "../arrivals.js";
9
11
  // The values `route:` takes: every route routeOf can return.
10
12
  const ROUTES = [
11
13
  "preview",
@@ -131,12 +133,19 @@ function homogeneousOf(raw) {
131
133
  return { ok: true, value: participants };
132
134
  }
133
135
  // A list entry with `count: n` is a group: n participants `<id>-01` to `<id>-NN`, even when n is 1.
134
- // config.ts then checks every entry and the expanded ids for collisions.
136
+ // config.ts then checks every entry and the expanded ids for collisions. The roster's size is
137
+ // checked before any group expands, so a large count is refused without building it.
135
138
  function entriesOf(raw) {
139
+ const size = raw.reduce((sum, entry) => sum + (isRecord(entry) && typeof entry.count === "number" ? (posInt(entry.count) ?? 1) : 1), 0);
140
+ const sizeReason = participantCountReason(size);
141
+ if (sizeReason)
142
+ return invalid(sizeReason);
136
143
  const entries = [];
137
144
  const source = [];
138
145
  for (const [index, entry] of raw.entries()) {
139
146
  if (!isRecord(entry) || entry.count === undefined) {
147
+ if (isRecord(entry) && entry.startEveryMs !== undefined)
148
+ return invalid(`participants[${index}].startEveryMs spreads a group's members over time, so it needs \`count\` on the same entry.`);
140
149
  entries.push(entry);
141
150
  source.push(index);
142
151
  continue;
@@ -151,15 +160,40 @@ function entriesOf(raw) {
151
160
  id.length > PARTICIPANT_ID_MAX_CHARS - 3) {
152
161
  return invalid(`participants[${index}] has a count, so it needs an \`id\` matching ${PARTICIPANT_ID_PATTERN} of at most ${PARTICIPANT_ID_MAX_CHARS - 3} characters. Its participants are <id>-01 to <id>-${String(count).padStart(2, "0")}.`);
153
162
  }
163
+ const starts = memberStarts(entry, index, id, count);
164
+ if (!starts.ok)
165
+ return starts;
154
166
  const shared = { ...entry };
155
167
  delete shared.count;
168
+ delete shared.startEveryMs;
156
169
  for (let n = 1; n <= count; n += 1) {
157
- entries.push({ ...shared, id: `${id}-${String(n).padStart(2, "0")}` });
170
+ const startAfterMs = starts.value(n);
171
+ entries.push({
172
+ ...shared,
173
+ id: `${id}-${String(n).padStart(2, "0")}`,
174
+ ...(startAfterMs === undefined ? {} : { startAfterMs }),
175
+ });
158
176
  source.push(index);
159
177
  }
160
178
  }
161
179
  return { ok: true, value: { entries, source } };
162
180
  }
181
+ // A group's `startEveryMs` gives member n the start `startAfterMs + (n - 1) * startEveryMs`.
182
+ // Without it every member keeps the entry's own `startAfterMs`, which the entry parser checks.
183
+ function memberStarts(entry, index, id, count) {
184
+ if (entry.startEveryMs === undefined)
185
+ return { ok: true, value: () => entry.startAfterMs };
186
+ const every = typeof entry.startEveryMs === "number" ? posInt(entry.startEveryMs) : undefined;
187
+ if (every === undefined)
188
+ return invalid(`participants[${index}].startEveryMs must be a whole number of milliseconds of 1 or more: how long after one member of the group the next one starts.`);
189
+ const first = entry.startAfterMs ?? 0;
190
+ if (!isStartAfterMs(first))
191
+ return invalid(startAfterMessage(`participants[${index}].startAfterMs`));
192
+ const last = first + (count - 1) * every;
193
+ if (last > LATEST_START_AFTER_MS)
194
+ return invalid(`participants[${index}] starts its last member, ${id}-${String(count).padStart(2, "0")}, ${last} ms after the run starts its participants, and a start must be at most ${LATEST_START_AFTER_MS} ms (24 hours). Lower startEveryMs, startAfterMs or count.`);
195
+ return { ok: true, value: (member) => first + (member - 1) * every };
196
+ }
163
197
  function surfacesOf(route, raw) {
164
198
  if (raw === undefined)
165
199
  return { ok: true, value: undefined };
@@ -1,6 +1,6 @@
1
1
  import type { Brain, ComputerUsePlan, NonEmpty, PlannedAnalysis, ProvisionedSubject, Requirement } from "./plan-types.js";
2
2
  import type { StudyConfig } from "./types.js";
3
- export type Base = Omit<ComputerUsePlan, "route" | "actor" | "runner" | "concurrency" | "sessionBudgetMs" | "sandboxMs" | "caps" | "rerun">;
3
+ export type Base = Omit<ComputerUsePlan, "route" | "actor" | "runner" | "concurrency" | "arrivals" | "sessionBudgetMs" | "sandboxMs" | "caps" | "rerun">;
4
4
  export declare function isNonEmpty<T>(values: readonly T[]): values is NonEmpty<T>;
5
5
  export declare function planCaps(config: StudyConfig): ComputerUsePlan["caps"];
6
6
  export declare function provisionedSubject(config: StudyConfig): ProvisionedSubject | undefined;
@@ -41,6 +41,8 @@ interface DesktopParticipant extends Participant {
41
41
  /** actor.idleWaitMs: how long a wait with no duration lasts while the participant only waits. */
42
42
  readonly idleWaitMs?: number;
43
43
  };
44
+ /** When it starts, in ms after the run starts its participants; absent, with the run. */
45
+ readonly startAfterMs?: number;
44
46
  }
45
47
  /** Only independent computer-use participants consume a task protocol and their own target. */
46
48
  export interface ComputerUseParticipant extends DesktopParticipant {
@@ -1,7 +1,7 @@
1
1
  // Who takes part in a study: one record per participant, built from the declared roster or a count.
2
2
  // Computer-use and shared-world participants share the desktop fields; each kind adds its own.
3
3
  import { resolveParticipantDevice } from "./device-presets.js";
4
- import { isSharedWorldComposition, participantIdAt } from "./routing.js";
4
+ import { computerUseParticipantCount, isSharedWorldComposition, participantIdAt, } from "./routing.js";
5
5
  import { participantList, declaredParticipantCount, participantInstruction, } from "./study-fields.js";
6
6
  function desktopParticipant(config, entry, index, kind, focus) {
7
7
  const actor = config.actor;
@@ -36,6 +36,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
36
36
  ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
37
37
  ...(idleWaitMs === undefined ? {} : { idleWaitMs }),
38
38
  },
39
+ ...(entry?.startAfterMs === undefined ? {} : { startAfterMs: entry.startAfterMs }),
39
40
  };
40
41
  }
41
42
  /**
@@ -45,10 +46,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
45
46
  export function computerUseParticipants(config, countOverride) {
46
47
  const actor = config.actor;
47
48
  const roster = participantList(config);
48
- const count = roster
49
- ? roster.length
50
- : Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1);
51
- return Array.from({ length: count }, (_, index) => {
49
+ return Array.from({ length: computerUseParticipantCount(config, countOverride) }, (_, index) => {
52
50
  const entry = roster?.[index];
53
51
  const focus = roster ? entry?.instruction : participantInstruction(config);
54
52
  return {