humanish 0.125.0 → 0.127.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 (128) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  5. package/dist/actors/computer-use/loop/clock.js +27 -17
  6. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  7. package/dist/actors/computer-use/loop/debrief.js +15 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +10 -5
  9. package/dist/actors/computer-use/loop/ending.js +12 -7
  10. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  11. package/dist/actors/computer-use/loop/observation.js +19 -1
  12. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  13. package/dist/actors/computer-use/loop/session.js +4 -1
  14. package/dist/actors/computer-use/loop/trace.js +4 -2
  15. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  16. package/dist/actors/computer-use/loop.js +5 -5
  17. package/dist/actors/computer-use/openai-provider.js +1 -1
  18. package/dist/actors/contract.d.ts +31 -8
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/goal-source.d.ts +32 -2
  21. package/dist/actors/goal-source.js +41 -2
  22. package/dist/actors/stop-cause.js +1 -0
  23. package/dist/analysis/evidence-sources.js +2 -2
  24. package/dist/analysis/types.d.ts +2 -2
  25. package/dist/analysis/validation.js +2 -1
  26. package/dist/cli/commands/study.js +8 -0
  27. package/dist/evidence/redaction.d.ts +1 -0
  28. package/dist/evidence/redaction.js +1 -0
  29. package/dist/evidence/trace-page.d.ts +6 -0
  30. package/dist/evidence/trace-page.js +90 -0
  31. package/dist/observer/data.js +8 -5
  32. package/dist/observer/feed-cursors.d.ts +14 -0
  33. package/dist/observer/feed-cursors.js +61 -0
  34. package/dist/observer/participant-items.d.ts +7 -0
  35. package/dist/observer/pinned-files.d.ts +2 -1
  36. package/dist/observer/pinned-files.js +2 -2
  37. package/dist/observer/run-routes.d.ts +1 -1
  38. package/dist/observer/run-routes.js +38 -17
  39. package/dist/observer-app.html +7 -7
  40. package/dist/routes/computer-use/bundle.js +4 -0
  41. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  42. package/dist/routes/computer-use/diagnostics.js +3 -3
  43. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  44. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  45. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  46. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  47. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  48. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  49. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  50. package/dist/routes/computer-use/fanout-bundle.js +3 -0
  51. package/dist/routes/computer-use/fanout-records.js +5 -1
  52. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  54. package/dist/routes/computer-use/participant-execution.js +6 -2
  55. package/dist/routes/computer-use/participant-facts.js +4 -1
  56. package/dist/routes/computer-use/participant-model.js +16 -6
  57. package/dist/routes/computer-use/participant-runs.d.ts +10 -10
  58. package/dist/routes/computer-use/participant-runs.js +54 -84
  59. package/dist/routes/computer-use/plan.d.ts +2 -0
  60. package/dist/routes/computer-use/plan.js +65 -17
  61. package/dist/routes/computer-use/rerun-selection.js +8 -5
  62. package/dist/routes/computer-use/setup.d.ts +1 -0
  63. package/dist/routes/computer-use/setup.js +29 -1
  64. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  65. package/dist/routes/computer-use/single-bundle.js +11 -1
  66. package/dist/routes/computer-use/types.d.ts +18 -13
  67. package/dist/routes/computer-use/types.js +0 -3
  68. package/dist/routes/shared-world/bundle-records.js +4 -1
  69. package/dist/routes/shared-world/external-public.js +1 -1
  70. package/dist/routes/shared-world/participant-specs.js +3 -2
  71. package/dist/routes/shared-world/plan.d.ts +2 -0
  72. package/dist/routes/shared-world/plan.js +8 -3
  73. package/dist/routes/shared-world/provisioned.js +1 -1
  74. package/dist/run/bundle.d.ts +11 -1
  75. package/dist/run/contained-output.d.ts +7 -3
  76. package/dist/run/contained-output.js +11 -7
  77. package/dist/run/judge.d.ts +14 -4
  78. package/dist/run/judge.js +17 -12
  79. package/dist/run/live-items.d.ts +19 -0
  80. package/dist/run/live-items.js +28 -8
  81. package/dist/run/notes.js +1 -1
  82. package/dist/run/outcomes.d.ts +8 -2
  83. package/dist/run/outcomes.js +56 -14
  84. package/dist/run/stream-trace.d.ts +25 -4
  85. package/dist/run/stream-trace.js +55 -17
  86. package/dist/run/streams.d.ts +16 -0
  87. package/dist/study/arrivals.d.ts +105 -34
  88. package/dist/study/arrivals.js +179 -74
  89. package/dist/study/bundle-estimate.d.ts +12 -10
  90. package/dist/study/bundle-estimate.js +138 -72
  91. package/dist/study/desktop-files.d.ts +37 -0
  92. package/dist/study/desktop-files.js +174 -0
  93. package/dist/study/keys.d.ts +8 -0
  94. package/dist/study/keys.js +3 -0
  95. package/dist/study/migrate/v2.js +12 -3
  96. package/dist/study/parse/actors.d.ts +1 -1
  97. package/dist/study/parse/actors.js +28 -20
  98. package/dist/study/parse/clock.d.ts +18 -0
  99. package/dist/study/parse/clock.js +36 -0
  100. package/dist/study/plan-participants.d.ts +19 -10
  101. package/dist/study/plan-participants.js +13 -9
  102. package/dist/study/plan-types.d.ts +6 -1
  103. package/dist/study/plan.d.ts +2 -1
  104. package/dist/study/plan.js +28 -15
  105. package/dist/study/preflight.js +4 -0
  106. package/dist/study/types.d.ts +35 -5
  107. package/dist/study/validation.js +3 -0
  108. package/dist/study/warnings.d.ts +2 -2
  109. package/dist/study/warnings.js +7 -0
  110. package/dist/subject/local-tree-archive.d.ts +7 -0
  111. package/dist/subject/local-tree-archive.js +12 -0
  112. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  113. package/dist/substrates/e2b/desktop-browser.js +6 -2
  114. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  115. package/dist/substrates/e2b/desktop-clock.js +29 -0
  116. package/dist/substrates/e2b/lifetime.d.ts +22 -8
  117. package/dist/substrates/e2b/lifetime.js +54 -10
  118. package/dist/substrates/e2b/sdk.d.ts +6 -0
  119. package/dist/verify/pages.d.ts +7 -0
  120. package/dist/verify/pages.js +50 -0
  121. package/dist/verify/verify.js +10 -3
  122. package/docs/architecture/actor-contract.md +2 -0
  123. package/docs/architecture/external-public-shared-world.md +4 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/policy.md +7 -0
  126. package/docs/contracts/run-bundle.md +31 -1
  127. package/docs/contracts/schemas.md +93 -11
  128. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  // When each participant of a computer-use or shared-world run starts, and on computer use when it
2
2
  // ends. A study may give each participant an offset from the moment the run starts its participants
3
3
  // (`participants[].startAfterMs`) and, on computer use, an end on the same clock
4
- // (`participants[].endAfterMs`), with a wind-down before it in which each turn tells the
5
- // participant the time left (`windDownMs`). The plan simulates the start queue to report the schedule and
6
- // when the last session ends at the latest; the routes start their participants through
4
+ // (`participants[].endAfterMs`) or a cap on its own session (`maxSessionMs`), with a wind-down
5
+ // before either in which each turn tells the participant the time left (`windDownMs`). Each
6
+ // participant's ParticipantSchedule resolves those fields once; everything that reads a
7
+ // participant's time reads the schedule. The plan simulates the start queue to report the schedule
8
+ // and when the last session ends at the latest; the routes start their participants through
7
9
  // runOnSchedule, which waits for each one's time and then for a free slot. A participant's desktop
8
10
  // is created when it starts, so a late start holds no sandbox before then.
9
11
  import { setTimeout as delay } from "node:timers/promises";
@@ -22,8 +24,8 @@ export function isStartAfterMs(value) {
22
24
  value >= 0 &&
23
25
  value <= LATEST_START_AFTER_MS);
24
26
  }
25
- /** How long before its end a participant is told the time left, unless it declares `windDownMs`. */
26
- export const DEFAULT_WIND_DOWN_MS = 10 * 60_000;
27
+ /** The longest default wind-down: 10 minutes is enough to wrap up what a participant is doing. */
28
+ const MAX_DEFAULT_WIND_DOWN_MS = 10 * 60_000;
27
29
  /**
28
30
  * Room a scheduled participant's session keeps past its end for the turn in flight and the closing
29
31
  * report. The loop ends the session at the end; this keeps the session's own time limit, and the
@@ -32,13 +34,18 @@ export const DEFAULT_WIND_DOWN_MS = 10 * 60_000;
32
34
  const SCHEDULED_END_ROOM_MS = 5 * 60_000;
33
35
  /**
34
36
  * Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
35
- * milliseconds up to 24 hours, one at or before its start, or a wind-down that is not a whole
36
- * number of milliseconds or has no end. `path` names the entry, as in "participants[1]".
37
+ * milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
38
+ * milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
39
+ * of milliseconds or has neither. `path` names the entry, as in "participants[1]".
37
40
  */
38
41
  export function windowReason(entry, path) {
39
- const { endAfterMs, windDownMs } = entry;
40
- if (windDownMs !== undefined && endAfterMs === undefined)
41
- return `${path}.windDownMs is how long before its endAfterMs a participant is told the time left, so it needs ${path}.endAfterMs. Add the end, or remove windDownMs.`;
42
+ const { endAfterMs, maxSessionMs, windDownMs } = entry;
43
+ if (maxSessionMs !== undefined && endAfterMs !== undefined)
44
+ return `${path} sets both endAfterMs and maxSessionMs. endAfterMs holds the participant until a set time, and maxSessionMs lets it end its session sooner, up to a cap, so a participant takes one of them. Keep endAfterMs for one that stays until a set time, or maxSessionMs for one that may leave when it is done.`;
45
+ if (maxSessionMs !== undefined && !(isStartAfterMs(maxSessionMs) && maxSessionMs > 0))
46
+ return `${path}.maxSessionMs must be a whole number of milliseconds from 1 to ${LATEST_START_AFTER_MS} (24 hours): the longest the participant's session lasts, counted from its start.`;
47
+ if (windDownMs !== undefined && endAfterMs === undefined && maxSessionMs === undefined)
48
+ return `${path}.windDownMs is how long before its endAfterMs or maxSessionMs a participant is told the time left, so it needs one of them. Add an end, or remove windDownMs.`;
42
49
  if (windDownMs !== undefined && !isStartAfterMs(windDownMs))
43
50
  return `${path}.windDownMs must be a whole number of milliseconds from 0 to ${LATEST_START_AFTER_MS} (24 hours): how long before its end the participant is told the time left.`;
44
51
  if (endAfterMs === undefined)
@@ -53,58 +60,120 @@ export function windowReason(entry, path) {
53
60
  return undefined;
54
61
  }
55
62
  /**
56
- * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS in
57
- * place of the study's `sessionMs` (execution.timeoutMs or its default); else `sessionMs`.
63
+ * The schedule of a participants entry that windowReason accepts. A cap wins over an end, which
64
+ * windowReason refuses beside it anyway.
58
65
  */
59
- export function participantSessionMs(participant, sessionMs) {
60
- if (participant.endAfterMs === undefined)
61
- return sessionMs;
62
- return participant.endAfterMs - (participant.startAfterMs ?? 0) + SCHEDULED_END_ROOM_MS;
66
+ export function participantSchedule(entry) {
67
+ const start = entry.startAfterMs === undefined ? {} : { startAfterMs: entry.startAfterMs };
68
+ if (entry.maxSessionMs !== undefined)
69
+ return {
70
+ ...start,
71
+ end: { kind: "session_cap", ...endTiming(entry.maxSessionMs, entry.windDownMs) },
72
+ };
73
+ if (entry.endAfterMs === undefined)
74
+ return start;
75
+ const windowMs = entry.endAfterMs - (entry.startAfterMs ?? 0);
76
+ return {
77
+ ...start,
78
+ end: {
79
+ kind: "scheduled_end",
80
+ endAfterMs: entry.endAfterMs,
81
+ ...endTiming(windowMs, entry.windDownMs),
82
+ },
83
+ };
63
84
  }
64
85
  /**
65
- * When a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, on the
66
- * same clock, or undefined when it declares no end. A participant that waited for a slot still ends
67
- * at its declared time.
86
+ * A window's wind-down and session budget. Without a declared `windDownMs` the wind-down is a
87
+ * quarter of the window, at most 10 minutes. A fixed 10 minutes covered the whole of a window of
88
+ * 10 minutes or less, so a short session was told its end from the first turn. A quarter leaves
89
+ * most of a short session without a countdown, and a window of 40 minutes or more keeps the 10
90
+ * minutes.
68
91
  */
69
- export function scheduledEndAt(participant, scheduledAt) {
70
- if (participant.endAfterMs === undefined)
71
- return undefined;
92
+ function endTiming(windowMs, windDownMs) {
72
93
  return {
73
- atMs: scheduledAt - (participant.startAfterMs ?? 0) + participant.endAfterMs,
74
- windDownMs: participant.windDownMs ?? DEFAULT_WIND_DOWN_MS,
94
+ windowMs,
95
+ windDownMs: windDownMs ?? Math.min(MAX_DEFAULT_WIND_DOWN_MS, Math.floor(windowMs / 4)),
96
+ sessionMs: windowMs + SCHEDULED_END_ROOM_MS,
75
97
  };
76
98
  }
77
99
  /**
78
- * Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that long,
79
- * or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
80
- * route adds to a session for setup and teardown (sandboxHeadroomMs).
100
+ * The schedule a rerun gives a participant: it starts with the rerun, and a scheduled end keeps its
101
+ * time on the rerun's clock and its wind-down, so its window runs from the rerun's start.
102
+ */
103
+ export function startingWithTheRun(schedule) {
104
+ const { end } = schedule;
105
+ if (end === undefined)
106
+ return {};
107
+ if (end.kind === "session_cap")
108
+ return { end };
109
+ return participantSchedule({ endAfterMs: end.endAfterMs, windDownMs: end.windDownMs });
110
+ }
111
+ /**
112
+ * A participant's own session budget and sandbox deadline. With an end or a cap it is the end's
113
+ * `sessionMs`, and the sandbox keeps the study sandbox's headroom over the study session on top;
114
+ * without one, the study's own.
81
115
  */
82
- export function windowDeadlineRefusal(participants, sandbox, ceilingMs) {
116
+ export function participantSession(schedule, study) {
117
+ const sessionMs = schedule.end?.sessionMs ?? study.sessionMs;
118
+ return { sessionMs, sandboxMs: study.sandboxMs - study.sessionMs + sessionMs };
119
+ }
120
+ /**
121
+ * How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
122
+ * undefined without an end or a cap. A scheduled end comes its window after the participant's
123
+ * time, which is its `endAfterMs` on the run's clock, even when it waited for a slot; a cap is
124
+ * whole from wherever its session starts.
125
+ */
126
+ export function participantEnd(schedule, scheduledAt) {
127
+ const { end } = schedule;
128
+ if (end === undefined)
129
+ return undefined;
130
+ if (end.kind === "session_cap")
131
+ return { kind: "session_cap", afterMs: end.windowMs, windDownMs: end.windDownMs };
132
+ return { kind: "scheduled_end", atMs: scheduledAt + end.windowMs, windDownMs: end.windDownMs };
133
+ }
134
+ /**
135
+ * Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
136
+ * long, or undefined. Its deadline is its participantSession's, from the study's session budget and
137
+ * sandbox deadline.
138
+ */
139
+ export function windowDeadlineRefusal(participants, study, ceilingMs) {
83
140
  const inMinutes = (ms) => Math.round(ms / 60_000);
84
- for (const participant of participants) {
85
- if (participant.endAfterMs === undefined)
141
+ for (const { id, schedule } of participants) {
142
+ const { end } = schedule;
143
+ if (end === undefined)
86
144
  continue;
87
- const deadlineMs = participantSessionMs(participant, sandbox.sessionMs) + sandbox.headroomMs;
145
+ const deadlineMs = participantSession(schedule, study).sandboxMs;
88
146
  if (deadlineMs <= ceilingMs)
89
147
  continue;
90
- const name = participant.id === undefined ? "A participant" : `Participant "${participant.id}"`;
91
- return `${name} works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs)}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the window, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its window.${ceilingAdvice(deadlineMs)}`;
148
+ const works = end.kind === "scheduled_end"
149
+ ? `works from +${formatDuration(schedule.startAfterMs ?? 0)} to +${formatDuration(end.endAfterMs)}`
150
+ : `may work for up to ${formatDuration(end.windowMs)}`;
151
+ const window = end.kind === "scheduled_end" ? "window" : "cap";
152
+ return `Participant "${id}" ${works}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the ${window}, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(study.sandboxMs - study.sessionMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its ${window}.${ceilingAdvice(deadlineMs)}`;
92
153
  }
93
154
  return undefined;
94
155
  }
95
156
  /**
96
- * Each participant's window for `study check`, when any participant declares an end: when it starts
97
- * and ends and when it is first told the time left, or when it starts and its session budget.
157
+ * Each participant's window for `study check`, when any participant declares an end or a cap: when
158
+ * it starts and ends and when it is first told the time left, when it starts and its cap and how
159
+ * far into its session it is first told the time left, or when it starts and the study's
160
+ * `sessionMs`.
98
161
  */
99
162
  export function describeWindows(participants, sessionMs) {
100
- if (!participants.some((participant) => participant.endAfterMs !== undefined))
163
+ if (participants.every((participant) => participant.schedule.end === undefined))
101
164
  return undefined;
102
- const windows = participants.map((participant) => {
103
- const start = `${participant.id ?? "participant"} from +${formatDuration(participant.startAfterMs ?? 0)}`;
104
- if (participant.endAfterMs === undefined)
165
+ const windows = participants.map(({ id, schedule }) => {
166
+ const startAfterMs = schedule.startAfterMs ?? 0;
167
+ const start = `${id} from +${formatDuration(startAfterMs)}`;
168
+ const { end } = schedule;
169
+ if (end === undefined)
105
170
  return `${start} for up to ${formatDuration(sessionMs)}`;
106
- const told = Math.max(participant.startAfterMs ?? 0, participant.endAfterMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
107
- return `${start} to +${formatDuration(participant.endAfterMs)}, told the time left from +${formatDuration(told)}`;
171
+ if (end.kind === "session_cap") {
172
+ const told = Math.max(0, end.windowMs - end.windDownMs);
173
+ return `${start} until it ends itself or for up to ${formatDuration(end.windowMs)}, told the time left from ${formatDuration(told)} into its session`;
174
+ }
175
+ const told = Math.max(startAfterMs, end.endAfterMs - end.windDownMs);
176
+ return `${start} to +${formatDuration(end.endAfterMs)}, told the time left from +${formatDuration(told)}`;
108
177
  });
109
178
  return `${windows.join("; ")}.`;
110
179
  }
@@ -126,34 +195,42 @@ export function rosterStartsReason(roster) {
126
195
  return undefined;
127
196
  }
128
197
  /**
129
- * The schedule a roster's declared offsets give under the queue (`undefined` is 0: the participant
130
- * starts with the run). Participants start in schedule order, by offset and then roster position,
131
- * each at its time or when a slot frees, whichever is later; this is the rule runOnSchedule
132
- * follows. `sessionsMs` gives a participant with a declared end its own session budget
133
- * (participantSessionMs); the others hold their slot for `queue.sessionMs`.
198
+ * The schedule a roster's participants give under the queue. Participants start in schedule order,
199
+ * by offset and then roster position, each at its time or when a slot frees, whichever is later;
200
+ * this is the rule runOnSchedule follows. A participant with an end or a cap holds its slot for its
201
+ * own session budget, and the others for `queue.sessionMs`. A participant whose slot would free
202
+ * only at or after its scheduled end takes none: the run skips it (pastEndReason).
134
203
  */
135
- export function planArrivals(offsets, queue, sessionsMs = []) {
136
- const due = offsets.map((offset) => offset ?? 0);
137
- const session = (index) => sessionsMs[index] ?? queue.sessionMs;
138
- const starts = queueStarts(due, queue, session);
204
+ export function planArrivals(schedules, queue) {
205
+ return simulateQueue(schedules, queue).arrivals;
206
+ }
207
+ /** planArrivals, and the roster indexes of the participants the run would skip. */
208
+ function simulateQueue(schedules, queue) {
209
+ const due = schedules.map((schedule) => schedule.startAfterMs ?? 0);
210
+ const sessionsMs = schedules.map((schedule) => schedule.end?.sessionMs ?? queue.sessionMs);
211
+ const session = (index) => sessionsMs[index];
212
+ const { starts, skipped } = queueStarts(schedules, due, queue, session);
213
+ const skips = new Set(skipped);
214
+ const started = starts.flatMap((_, index) => (skips.has(index) ? [] : [index]));
139
215
  let waiting = 0;
140
216
  let longestWaitMs = 0;
141
- for (const [index, start] of starts.entries()) {
142
- const waitMs = start - due[index];
217
+ for (const index of started) {
218
+ const waitMs = starts[index] - due[index];
143
219
  if (waitMs > 0)
144
220
  waiting += 1;
145
221
  longestWaitMs = Math.max(longestWaitMs, waitMs);
146
222
  }
147
- return {
148
- declared: offsets.some((offset) => offset !== undefined),
223
+ const arrivals = {
224
+ declared: schedules.some((schedule) => schedule.startAfterMs !== undefined),
149
225
  firstStartMs: Math.min(...due),
150
226
  lastStartMs: Math.max(...due),
151
- peak: mostAtOnce(starts, session),
227
+ peak: mostAtOnce(started.map((index) => [starts[index], session(index)])),
152
228
  waiting,
153
229
  longestWaitMs,
154
- lastEndMs: Math.max(...starts.map((start, index) => start + session(index))),
230
+ lastEndMs: Math.max(...started.map((index) => starts[index] + session(index))),
155
231
  sessionMs: queue.sessionMs,
156
232
  };
233
+ return { arrivals, skipped };
157
234
  }
158
235
  /**
159
236
  * The plan's schedule, after a "schedule:" label: the first and last start, and how many run at
@@ -169,21 +246,43 @@ export function describeArrivals(arrivals) {
169
246
  * A planner's arrival plan for its participants under the start queue, and the warnings the plan
170
247
  * records about it. A participant's desktop is created when it starts, so on computer use a late
171
248
  * start adds nothing to any sandbox's deadline; the provisioned shared world checks its app's
172
- * sandbox with servedScheduleRefusal. `lowered` is the warning that the E2B plan's limit runs the
173
- * roster in waves.
249
+ * sandbox with servedScheduleRefusal. `lowered` is participantsAtOnce's warning that the E2B plan's
250
+ * limit or HUMANISH_CUA_MAX_CONCURRENCY runs the roster in waves.
174
251
  */
175
252
  export function planStarts(participants, queue, lowered) {
176
- const arrivals = planArrivals(participants.map((participant) => participant.startAfterMs), queue, participants.map((participant) => participantSessionMs(participant, queue.sessionMs)));
177
- return { arrivals, warnings: startQueueWarnings(arrivals, queue, lowered) };
253
+ const { arrivals, skipped } = simulateQueue(participants.map((participant) => participant.schedule), queue);
254
+ const ids = skipped.map((index) => participants[index].id);
255
+ return { arrivals, warnings: startQueueWarnings(arrivals, ids, queue, lowered) };
178
256
  }
179
257
  // A roster that starts together keeps `lowered`. A declared schedule spreads its participants
180
- // itself, so it is warned only when it holds someone past their time, with `lowered` beside that
181
- // when the limit is the reason.
182
- function startQueueWarnings(arrivals, queue, lowered) {
183
- if (!arrivals.declared || arrivals.waiting === 0)
184
- return !arrivals.declared && lowered ? [lowered] : [];
185
- 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.`;
186
- return lowered === undefined ? [late] : [late, lowered];
258
+ // itself, so it is warned only when it holds someone past their time or skips someone, with
259
+ // `lowered` beside that when the limit is the reason.
260
+ function startQueueWarnings(arrivals, skipped, queue, lowered) {
261
+ const budget = `when every session uses its ${formatDuration(arrivals.sessionMs)} budget`;
262
+ const atMost = `the run starts at most ${queue.slots} at once`;
263
+ const late = `${plural(arrivals.waiting, "participant")} would start after their time ${budget}, the latest by ${formatDuration(arrivals.longestWaitMs)}: ${atMost} and holds the next one until a slot frees.`;
264
+ const one = skipped.length === 1;
265
+ const names = skipped.map((id) => `"${id}"`);
266
+ const who = one
267
+ ? `Participant ${names[0]}`
268
+ : `${skipped.length} participants (${names.join(", ")})`;
269
+ const skip = `${who} would still be waiting for a free slot at ${one ? "its" : "their"} end (participants[].endAfterMs) ${budget}, so the run would skip ${one ? "it without creating its desktop" : "them without creating their desktops"}: ${atMost}. Let more participants run at once, or give ${one ? "it" : "them"} a later end.`;
270
+ const held = arrivals.waiting > 0 || skipped.length > 0;
271
+ return [
272
+ ...(arrivals.declared && arrivals.waiting > 0 ? [late] : []),
273
+ ...(skipped.length > 0 ? [skip] : []),
274
+ ...(lowered !== undefined && (!arrivals.declared || held) ? [lowered] : []),
275
+ ];
276
+ }
277
+ /**
278
+ * Why a participant whose start comes at `nowMs` is skipped, or undefined: its scheduled end came
279
+ * while it waited, so its session would end before it began. The plan's simulation applies the
280
+ * same rule (planArrivals).
281
+ */
282
+ export function pastEndReason(end, nowMs) {
283
+ if (end?.kind !== "scheduled_end" || nowMs < end.atMs)
284
+ return undefined;
285
+ return "skipped: its scheduled end (participants[].endAfterMs) came while it waited to start, so its session would have ended before it began; no desktop was created";
187
286
  }
188
287
  /**
189
288
  * Why a sandbox that serves the app until the last participant ends, the provisioned shared
@@ -217,8 +316,9 @@ function scheduleOrder(due) {
217
316
  return due.map((_, index) => index).sort((a, b) => due[a] - due[b] || a - b);
218
317
  }
219
318
  /** Each participant's start in roster order, when each session holds its slot for `session(index)`. */
220
- function queueStarts(due, queue, session) {
319
+ function queueStarts(schedules, due, queue, session) {
221
320
  const starts = [...due];
321
+ const skipped = [];
222
322
  const queued = scheduleOrder(due).filter((index) => index !== queue.ownSlot);
223
323
  const pooled = queue.ownSlot === undefined ? queue.slots : queue.slots - 1;
224
324
  // When each slot is next free; a participant takes the one that frees first.
@@ -227,16 +327,21 @@ function queueStarts(due, queue, session) {
227
327
  const slot = free.indexOf(Math.min(...free));
228
328
  const start = Math.max(due[index], free[slot]);
229
329
  starts[index] = start;
230
- free[slot] = start + session(index);
330
+ const end = schedules[index]?.end;
331
+ if (end?.kind === "scheduled_end" && start >= end.endAfterMs)
332
+ skipped.push(index);
333
+ else
334
+ free[slot] = start + session(index);
231
335
  }
232
- return starts;
336
+ return { starts, skipped };
233
337
  }
234
- function mostAtOnce(starts, session) {
338
+ /** The most sessions running at once, each given as its start and how long it holds its slot. */
339
+ function mostAtOnce(sessions) {
235
340
  // An end sorts before a start at the same instant: that slot is free again.
236
- const changes = starts
237
- .flatMap((start, index) => [
341
+ const changes = sessions
342
+ .flatMap(([start, ms]) => [
238
343
  { at: start, by: 1 },
239
- { at: start + session(index), by: -1 },
344
+ { at: start + ms, by: -1 },
240
345
  ])
241
346
  .sort((a, b) => a.at - b.at || a.by - b.by);
242
347
  let live = 0;
@@ -1,8 +1,8 @@
1
1
  import type { DwellWindow } from "../actors/stop-conditions.js";
2
- /** One participant as the plan has it: when it starts and ends, and its dwell window. */
2
+ import type { ParticipantSchedule } from "./arrivals.js";
3
+ /** One participant as the plan has it: its schedule and its dwell window. */
3
4
  export interface EstimatedParticipant {
4
- readonly startAfterMs?: number;
5
- readonly endAfterMs?: number;
5
+ readonly schedule: ParticipantSchedule;
6
6
  readonly limits: {
7
7
  readonly dwell?: DwellWindow;
8
8
  };
@@ -11,8 +11,10 @@ export interface EstimatedParticipant {
11
11
  export interface BundleEstimate {
12
12
  /** run.json, which analysis reads whole. */
13
13
  readonly runFileBytes: number;
14
- /** The largest file: observer/observer-data.json, or the Observer page for a small run. */
15
- readonly largestFileBytes: number;
14
+ /** The largest participant trace file, which analysis also reads whole. */
15
+ readonly largestTraceBytes: number;
16
+ /** observer/index.html, which verify reads whole. */
17
+ readonly observerPageBytes: number;
16
18
  /** Every file, screenshots included. */
17
19
  readonly totalBytes: number;
18
20
  /** Files and folders in the run folder, as the public-safety scan counts them. */
@@ -33,15 +35,15 @@ export interface BundleEstimate {
33
35
  }
34
36
  /**
35
37
  * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
36
- * declares an end. A participant with an end works its whole window; one without is counted for
37
- * the measured open session above, or its budget when that is shorter. Each takes a model turn at
38
- * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
39
- * session, and the frame after it.
38
+ * declares an end or a cap. A participant with an end works its whole window; one without ends
39
+ * itself, so it is counted for the measured open session above, or its budget (its cap, else
40
+ * `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
41
+ * window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
40
42
  */
41
43
  export declare function estimateBundle(participants: readonly EstimatedParticipant[], sessionMs: number): BundleEstimate;
42
44
  /**
43
45
  * The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
44
- * estimate counted, how high it runs, and what to change.
46
+ * estimate counted, and what to change.
45
47
  */
46
48
  export declare function bundleLimitReason(estimate: BundleEstimate): string | undefined;
47
49
  /** The estimate beside the limits, for `study check`. */