humanish 0.118.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 (78) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/analysis/admission.d.ts +16 -2
  3. package/dist/analysis/admission.js +35 -6
  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 +5 -0
  7. package/dist/analysis/automatic-config.js +15 -1
  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 +7 -2
  13. package/dist/analysis/execute.js +242 -297
  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 +8 -3
  19. package/dist/cli/commands/study-route-computer-use.js +2 -2
  20. package/dist/cli/commands/study-route-scripted.js +2 -2
  21. package/dist/cli/commands/study-route-shared-world.js +2 -2
  22. package/dist/cli/commands/study-route-terminal.js +2 -2
  23. package/dist/cli/commands/study-run.js +3 -3
  24. package/dist/cli/commands/study.js +2 -2
  25. package/dist/cli/io.js +2 -3
  26. package/dist/observer-app.html +1 -1
  27. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  28. package/dist/routes/computer-use/fanout-records.js +4 -0
  29. package/dist/routes/computer-use/participant-execution.d.ts +6 -4
  30. package/dist/routes/computer-use/participant-execution.js +42 -14
  31. package/dist/routes/computer-use/participant-model.js +18 -8
  32. package/dist/routes/computer-use/participant-runs.js +12 -0
  33. package/dist/routes/computer-use/plan.js +23 -15
  34. package/dist/routes/computer-use/rerun-selection.js +14 -3
  35. package/dist/routes/computer-use/types.d.ts +13 -1
  36. package/dist/routes/shared-world/bundle-records.js +14 -0
  37. package/dist/routes/shared-world/bundle.js +2 -1
  38. package/dist/routes/shared-world/external-public.js +15 -3
  39. package/dist/routes/shared-world/handoff.d.ts +2 -2
  40. package/dist/routes/shared-world/plan.d.ts +7 -0
  41. package/dist/routes/shared-world/plan.js +79 -34
  42. package/dist/routes/shared-world/provisioned.js +15 -8
  43. package/dist/routes/shared-world/route.js +5 -2
  44. package/dist/routes/shared-world/types.d.ts +2 -0
  45. package/dist/run/bundle-shape.js +10 -1
  46. package/dist/run/bundle.d.ts +15 -0
  47. package/dist/run/participant-records.js +1 -0
  48. package/dist/run/route-shell.js +5 -10
  49. package/dist/run/run.js +4 -2
  50. package/dist/study/arrivals.d.ts +114 -0
  51. package/dist/study/arrivals.js +204 -0
  52. package/dist/study/doctor.js +2 -15
  53. package/dist/study/keys.d.ts +1 -0
  54. package/dist/study/keys.js +2 -0
  55. package/dist/study/migrate/v2.js +3 -2
  56. package/dist/study/parse/actors.d.ts +1 -1
  57. package/dist/study/parse/actors.js +9 -3
  58. package/dist/study/parse/front.js +29 -1
  59. package/dist/study/plan-base.d.ts +1 -3
  60. package/dist/study/plan-base.js +0 -3
  61. package/dist/study/plan-participants.d.ts +2 -0
  62. package/dist/study/plan-participants.js +1 -0
  63. package/dist/study/plan-types.d.ts +8 -3
  64. package/dist/study/preflight-result.js +2 -2
  65. package/dist/study/preflight.js +12 -0
  66. package/dist/study/summary.d.ts +3 -3
  67. package/dist/study/summary.js +5 -3
  68. package/dist/study/types.d.ts +14 -0
  69. package/dist/study/validation.js +5 -0
  70. package/dist/study/warnings.js +11 -3
  71. package/docs/contracts/run-bundle.md +1 -1
  72. package/docs/contracts/schemas.md +19 -1
  73. package/docs/contracts/study-analysis.md +59 -6
  74. package/docs/principles/invariants-and-defaults.md +14 -14
  75. package/docs/product/automatic-analysis.md +7 -1
  76. package/package.json +1 -1
  77. package/dist/study/automatic-analysis-plan.d.ts +0 -28
  78. package/dist/study/automatic-analysis-plan.js +0 -57
@@ -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
+ }
@@ -6,7 +6,7 @@ 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
8
  import { admissionRule } from "../analysis/admission.js";
9
- import { studyAnalysisBudget } from "./automatic-analysis-plan.js";
9
+ import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
10
10
  import { externalCatchHealthy } from "../comms/sandbox-catch.js";
11
11
  import { receivingRequiredKey } from "../comms/setup.js";
12
12
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
@@ -191,11 +191,7 @@ export async function studySetupChecks(args) {
191
191
  if (route === "scripted")
192
192
  checks.push(await scriptedBrowserCheck());
193
193
  checks.push(...subjectEnvChecks(requiredSubjectEnv(requirements), args));
194
- const analysis = studyAnalysisBudget(config.review?.analysis, route, plan.route === "computer-use"
195
- ? plan.runner.participants.length
196
- : plan.route === "shared-world"
197
- ? plan.plane.participants.length
198
- : 1);
194
+ const analysis = automaticAnalysisBudget(config.review?.analysis, route);
199
195
  if (analysis)
200
196
  checks.push(await analysisCheck(analysis, args, checkAccount));
201
197
  checks.push(checkScope(analysis));
@@ -352,15 +348,6 @@ function subjectEnvChecks(names, args) {
352
348
  }
353
349
  /** The post-run analysis: the Codex account, or the OpenAI key and the analysis cost limit. */
354
350
  async function analysisCheck(analysis, args, checkAccount) {
355
- if (analysis.skip !== undefined) {
356
- const explicit = analysis.trigger === "explicit";
357
- return {
358
- name: "post-run analysis",
359
- ok: !explicit,
360
- ...(explicit ? {} : { status: "note" }),
361
- message: `Will not run: this study has ${analysis.participants ?? "more"} participants, more than automatic analysis reads. The participants still run.${explicit ? " review.analysis asks for it, so the run will exit 2; set review.analysis: false or run fewer participants." : ""}`,
362
- };
363
- }
364
351
  if (analysis.provider === "codex") {
365
352
  const readiness = await checkAccount();
366
353
  const recovery = readiness.errorCode === "codex_unsupported_platform"
@@ -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,7 +25,7 @@ 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
31
  * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
@@ -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,7 +97,7 @@ 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
103
  * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
@@ -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 };
@@ -7,6 +7,7 @@ import { movedV2KeyReason } from "../migrate/v2.js";
7
7
  import { participantCountReason } from "../routing.js";
8
8
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
9
9
  import { invalid, posInt } from "./values.js";
10
+ import { isStartAfterMs, LATEST_START_AFTER_MS, startAfterMessage } from "../arrivals.js";
10
11
  // The values `route:` takes: every route routeOf can return.
11
12
  const ROUTES = [
12
13
  "preview",
@@ -143,6 +144,8 @@ function entriesOf(raw) {
143
144
  const source = [];
144
145
  for (const [index, entry] of raw.entries()) {
145
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.`);
146
149
  entries.push(entry);
147
150
  source.push(index);
148
151
  continue;
@@ -157,15 +160,40 @@ function entriesOf(raw) {
157
160
  id.length > PARTICIPANT_ID_MAX_CHARS - 3) {
158
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")}.`);
159
162
  }
163
+ const starts = memberStarts(entry, index, id, count);
164
+ if (!starts.ok)
165
+ return starts;
160
166
  const shared = { ...entry };
161
167
  delete shared.count;
168
+ delete shared.startEveryMs;
162
169
  for (let n = 1; n <= count; n += 1) {
163
- 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
+ });
164
176
  source.push(index);
165
177
  }
166
178
  }
167
179
  return { ok: true, value: { entries, source } };
168
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
+ }
169
197
  function surfacesOf(route, raw) {
170
198
  if (raw === undefined)
171
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;
@@ -25,6 +25,4 @@ export declare function planBase(config: StudyConfig, input: {
25
25
  readonly config?: PlannedAnalysis["config"] | undefined;
26
26
  readonly preferLargerOutput?: boolean | undefined;
27
27
  };
28
- /** How many participants the run has, which decides whether its analysis can run. */
29
- readonly participants?: number;
30
28
  }): Base;
@@ -1,7 +1,6 @@
1
1
  // What every route planner shares: the plan fields common to all routes, the frozen residual config,
2
2
  // and the requirement and subject helpers.
3
3
  import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
4
- import { analysisSkipFor } from "./automatic-analysis-plan.js";
5
4
  // A YAML alias can make a parsed record contain itself (an inline persona that names its own
6
5
  // anchor), and structuredClone keeps the cycle, so each object is frozen once.
7
6
  function deepFreeze(value, frozen = new WeakSet()) {
@@ -109,7 +108,6 @@ export function pricedModel(brain) {
109
108
  /** The fields every plan shares, from a config whose analysis already resolved. */
110
109
  export function planBase(config, input) {
111
110
  const analysis = input.analysis?.config;
112
- const skip = analysisSkipFor(input.participants ?? 1);
113
111
  return {
114
112
  studyId: config.id,
115
113
  ...(config.title === undefined ? {} : { title: config.title }),
@@ -122,7 +120,6 @@ export function planBase(config, input) {
122
120
  config: analysis,
123
121
  trigger: config.review?.analysis === undefined ? "default" : "explicit",
124
122
  preferLargerOutput: input.analysis?.preferLargerOutput === true,
125
- ...(skip === undefined ? {} : { skip }),
126
123
  },
127
124
  }),
128
125
  requirements: [],
@@ -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 {
@@ -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
  /**
@@ -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";
@@ -57,9 +58,6 @@ export interface PlannedAnalysis {
57
58
  /** "default" when the study declared no review.analysis. */
58
59
  readonly trigger: "default" | "explicit";
59
60
  readonly preferLargerOutput: boolean;
60
- /** Set when the study has more participants than one analysis reads: a live run records the
61
- * analysis as skipped for this reason and never starts it. */
62
- readonly skip?: "AUTOMATIC_ANALYSIS_PARTICIPANT_LIMIT";
63
61
  }
64
62
  interface PlanBase {
65
63
  readonly studyId: string;
@@ -166,6 +164,8 @@ export interface ComputerUsePlan extends PlanBase {
166
164
  readonly runner: ComputerUseRunner;
167
165
  /** Declared cap clamped to [1, participants]; the route may only lower it from env. */
168
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;
169
169
  readonly sessionBudgetMs: number;
170
170
  readonly sandboxMs: number;
171
171
  readonly caps: {
@@ -200,6 +200,11 @@ export interface SharedWorldPlan extends PlanBase {
200
200
  readonly plane: SharedWorldPlane;
201
201
  /** At least 2. */
202
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;
203
208
  /** Each participant's session budget: execution.timeoutMs, else the plane's derived default. */
204
209
  readonly sessionTimeoutMs: number;
205
210
  readonly brain: Extract<Brain, {
@@ -1,13 +1,13 @@
1
1
  // The preflight result a run returns, built from its context. preflight.ts and the probes in
2
2
  // preflight-probes.ts both finish through these, so they live below both.
3
- import { studyAnalysisBudget } from "./automatic-analysis-plan.js";
3
+ import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
4
4
  import { digestText } from "../evidence/redaction.js";
5
5
  import { computerUseParticipantCount } from "./routing.js";
6
6
  /** The schema of `study check` results: whether a study is ready to run on this machine. */
7
7
  export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
8
8
  export function finalize(ctx, args) {
9
9
  const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
10
- const analysis = studyAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
10
+ const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
11
11
  return {
12
12
  schema: STUDY_CHECK_SCHEMA,
13
13
  ...(analysis ? { analysis } : {}),
@@ -6,6 +6,7 @@ import {} from "./types.js";
6
6
  import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./preflight-probes.js";
7
7
  import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
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";
@@ -74,6 +75,9 @@ export async function runStudyPreflight(options) {
74
75
  const { code, message } = planned.refusal;
75
76
  return fail(ctx, code, message, [{ name: "plan", ok: false, message }]);
76
77
  }
78
+ const schedule = scheduleCheck(planned.planned.plan);
79
+ if (schedule !== undefined)
80
+ ctx.checks.push(schedule);
77
81
  const machine = machineCheck(ctx.config);
78
82
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
79
83
  if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))
@@ -118,6 +122,14 @@ export async function runStudyPreflight(options) {
118
122
  });
119
123
  }
120
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
+ }
121
133
  /** What study check leaves to doctor: this machine's setup for the study, and the command. */
122
134
  function machineCheck(config) {
123
135
  const command = cli(`doctor --study ${config.id}`);
@@ -1,5 +1,5 @@
1
1
  import { type LocalRuntimeStatus } from "../substrates/local/runtime.js";
2
- import { type StudyAnalysisBudget } from "./automatic-analysis-plan.js";
2
+ import { type AutomaticAnalysisBudget } from "../analysis/automatic-config.js";
3
3
  import { type KeyResolutionDeps } from "../keys/key-resolution.js";
4
4
  export declare const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
5
5
  export interface StudyCaps {
@@ -15,8 +15,8 @@ export interface StudySummary {
15
15
  message: string;
16
16
  };
17
17
  communications?: string;
18
- analysis?: StudyAnalysisBudget & {
19
- /** The budget line `run` and `study check` print, from formatStudyAnalysisBudget. */
18
+ analysis?: AutomaticAnalysisBudget & {
19
+ /** The budget line `run` and `study check` print, from formatAutomaticAnalysisBudget. */
20
20
  line: string;
21
21
  };
22
22
  schema: typeof STUDY_SUMMARY_SCHEMA;
@@ -11,7 +11,7 @@ import { localRuntimeStatus } from "../substrates/local/runtime.js";
11
11
  import { planStudy, resolveStudyDryRun, routeOf } from "./plan.js";
12
12
  import { localCodexParticipantCheck } from "./doctor.js";
13
13
  import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
14
- import { formatStudyAnalysisBudget, studyAnalysisBudget, } from "./automatic-analysis-plan.js";
14
+ import { automaticAnalysisBudget, formatAutomaticAnalysisBudget, } from "../analysis/automatic-config.js";
15
15
  import { DEFAULT_OPENAI_CU_MODEL, DEFAULT_OPENAI_CU_REASONING_EFFORT, } from "../actors/computer-use/openai-provider.js";
16
16
  import { inspectStudyManifest } from "./discover.js";
17
17
  import { computerUseParticipantCount, isComputerUseComposition } from "./routing.js";
@@ -123,7 +123,7 @@ export async function readStudySummary(cwd, study, options = {}) {
123
123
  missingKeys = missing;
124
124
  }
125
125
  // Computed once: a test-then-use pair reads as though the two calls could differ.
126
- const analysis = studyAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
126
+ const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
127
127
  const subject = subjectOf(config);
128
128
  const participants = participantsOf(inspected.config);
129
129
  const caps = summaryCapsOf(inspected.config);
@@ -140,7 +140,9 @@ export async function readStudySummary(cwd, study, options = {}) {
140
140
  ? await localCodexParticipantCheck({ env: options.env ?? process.env })
141
141
  : undefined;
142
142
  return {
143
- ...(analysis ? { analysis: { ...analysis, line: formatStudyAnalysisBudget(analysis) } } : {}),
143
+ ...(analysis
144
+ ? { analysis: { ...analysis, line: formatAutomaticAnalysisBudget(analysis) } }
145
+ : {}),
144
146
  ...(runtime
145
147
  ? { runtime: { ok: runtime.ok, installed: runtime.installed, message: runtime.message } }
146
148
  : {}),
@@ -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 {
@@ -6,6 +6,7 @@ import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
6
6
  import { declaredTargets } from "./plan-participants.js";
7
7
  import { actorResolvesToComputerUse, computerUseParticipantCount, participantCountReason, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
8
8
  import { participantList } from "./study-fields.js";
9
+ import { isStartAfterMs, rosterStartsReason, startAfterMessage } from "./arrivals.js";
9
10
  /**
10
11
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
11
12
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
@@ -78,6 +79,9 @@ function rosterStructuralValidationReason(config) {
78
79
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
79
80
  return `participants[${index}] must be an object.`;
80
81
  }
82
+ if (entry.startAfterMs !== undefined && !isStartAfterMs(entry.startAfterMs)) {
83
+ return startAfterMessage(`participants[${index}].startAfterMs`);
84
+ }
81
85
  const id = entry.id;
82
86
  if (id === undefined) {
83
87
  continue;
@@ -92,6 +96,7 @@ function rosterStructuralValidationReason(config) {
92
96
  }
93
97
  seenIds.add(id);
94
98
  }
99
+ return rosterStartsReason(roster) ?? null;
95
100
  }
96
101
  return null;
97
102
  }