humanish 0.120.1 → 0.122.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 (67) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/actors/computer-use/actor.d.ts +3 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
  5. package/dist/actors/computer-use/loop/backstop.js +13 -1
  6. package/dist/actors/computer-use/loop/clock.d.ts +20 -0
  7. package/dist/actors/computer-use/loop/clock.js +64 -0
  8. package/dist/actors/computer-use/loop/ending.d.ts +1 -1
  9. package/dist/actors/computer-use/loop/session.d.ts +1 -1
  10. package/dist/actors/computer-use/loop/session.js +2 -1
  11. package/dist/actors/computer-use/loop/types.d.ts +16 -0
  12. package/dist/actors/computer-use/loop.d.ts +1 -1
  13. package/dist/actors/computer-use/loop.js +43 -8
  14. package/dist/actors/computer-use/wait.d.ts +3 -2
  15. package/dist/actors/computer-use/wait.js +5 -4
  16. package/dist/actors/contract.d.ts +3 -3
  17. package/dist/actors/contract.js +3 -0
  18. package/dist/actors/stop-cause.js +1 -0
  19. package/dist/cli/commands/study-format.js +25 -4
  20. package/dist/routes/computer-use/bundle.d.ts +3 -1
  21. package/dist/routes/computer-use/bundle.js +2 -1
  22. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  23. package/dist/routes/computer-use/diagnostics.js +4 -0
  24. package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
  25. package/dist/routes/computer-use/fanout-bundle.js +22 -14
  26. package/dist/routes/computer-use/live-phase.d.ts +1 -2
  27. package/dist/routes/computer-use/live-phase.js +2 -4
  28. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  29. package/dist/routes/computer-use/participant-deps.js +3 -1
  30. package/dist/routes/computer-use/participant-execution.d.ts +9 -14
  31. package/dist/routes/computer-use/participant-execution.js +102 -89
  32. package/dist/routes/computer-use/participant-model.js +17 -0
  33. package/dist/routes/computer-use/participant-runs.d.ts +11 -1
  34. package/dist/routes/computer-use/participant-runs.js +31 -4
  35. package/dist/routes/computer-use/plan.js +8 -1
  36. package/dist/routes/computer-use/result.js +57 -9
  37. package/dist/routes/computer-use/setup.d.ts +1 -1
  38. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  39. package/dist/routes/computer-use/single-bundle.js +3 -2
  40. package/dist/routes/computer-use/types.d.ts +33 -7
  41. package/dist/routes/shared-world/provisioned.js +2 -2
  42. package/dist/run/host-suspension.d.ts +56 -0
  43. package/dist/run/host-suspension.js +106 -0
  44. package/dist/run/judge.d.ts +1 -1
  45. package/dist/run/outcomes.js +3 -1
  46. package/dist/run/run.d.ts +15 -2
  47. package/dist/run/run.js +15 -8
  48. package/dist/run/status.d.ts +8 -2
  49. package/dist/run/status.js +11 -8
  50. package/dist/study/arrivals.d.ts +52 -5
  51. package/dist/study/arrivals.js +105 -15
  52. package/dist/study/keys.d.ts +6 -0
  53. package/dist/study/keys.js +4 -0
  54. package/dist/study/migrate/v2.js +8 -5
  55. package/dist/study/parse/actors.d.ts +1 -1
  56. package/dist/study/parse/actors.js +33 -4
  57. package/dist/study/plan-participants.d.ts +10 -1
  58. package/dist/study/plan-participants.js +10 -1
  59. package/dist/study/preflight.js +11 -1
  60. package/dist/study/study-deps.d.ts +6 -0
  61. package/dist/study/types.d.ts +26 -0
  62. package/dist/study/validation.d.ts +3 -2
  63. package/dist/study/validation.js +22 -4
  64. package/dist/tui-app.js +35 -35
  65. package/docs/contracts/run-bundle.md +20 -1
  66. package/docs/contracts/schemas.md +24 -3
  67. package/package.json +1 -1
@@ -49,7 +49,25 @@ function fanoutSummary(args, facts) {
49
49
  return `${rerun}Dry run: ${people} would each use their own copy of ${subjectName(publicSafeAppUrlLabel(args.appUrl), args.aggregateSubject)}. No desktops were launched and $0 was spent.`;
50
50
  }
51
51
  const tasks = facts.tasks === undefined ? "" : ` Tasks: ${facts.tasks}.`;
52
- return `${rerun}${people} took part, each in their own copy of the app: ${facts.outcomes}${gapsListClause(count - facts.passed, count)}.${tasks}`;
52
+ // A host suspension comes first: it may explain everything after it.
53
+ const host = args.hostSuspension === undefined ? "" : `${args.hostSuspension.summary} `;
54
+ return `${host}${rerun}${people} took part, each in their own copy of the app: ${facts.outcomes}${gapsListClause(count - facts.passed, count)}.${tasks}`;
55
+ }
56
+ /**
57
+ * The review's gaps: one line for the participants a host suspension likely failed, then a line
58
+ * for each other participant that did not pass.
59
+ */
60
+ function fanoutGaps(args, judgment) {
61
+ const suspended = new Set(args.hostSuspension?.participantIds ?? []);
62
+ const others = judgment.participants.flatMap((participant, index) => participant.gapLine === undefined || suspended.has(args.specs[index].planned.id)
63
+ ? []
64
+ : [participant.gapLine]);
65
+ if (suspended.size === 0)
66
+ return others;
67
+ return [
68
+ `${[...suspended].join(", ")}: failed after the host was suspended, likely its effect. Each participant's record keeps its own error.`,
69
+ ...others,
70
+ ];
53
71
  }
54
72
  function fanoutReview(args, streams, judgment) {
55
73
  const { outcomes } = args;
@@ -91,7 +109,7 @@ function fanoutReview(args, streams, judgment) {
91
109
  ? ["Live fan-out session is still running."]
92
110
  : args.dryRun
93
111
  ? ["Live fan-out session not yet run (dry run only)."]
94
- : judgment.participants.flatMap((participant) => participant.gapLine === undefined ? [] : [participant.gapLine]),
112
+ : fanoutGaps(args, judgment),
95
113
  }, streams);
96
114
  return review;
97
115
  }
@@ -163,9 +181,8 @@ function fanoutFeedbackCandidates(args) {
163
181
  /**
164
182
  * Project N>1 fan-out participants into a humanish.run-bundle.v1 (the evidence schema is unchanged; this
165
183
  * is a new producer for the multi-stream shape). One sim + one stream per participant; per-participant
166
- * provenance/session events; a recorded `cua-lab.fanout.plan` event (and a `cua-lab.fanout.fail-fast`
167
- * event when a harness error skipped queued participants). N-ary verify/Observer already handle multiple
168
- * streams. A single-participant run without a rerun never reaches here: buildCuaRunBundle
184
+ * provenance/session events; a recorded `cua-lab.fanout.plan` event. N-ary verify/Observer already
185
+ * handle multiple streams. A single-participant run without a rerun never reaches here: buildCuaRunBundle
169
186
  * (bundle.ts) sends it to buildSingleParticipantBundle (single-bundle.ts).
170
187
  */
171
188
  export function buildCuaFanoutBundle(args) {
@@ -197,15 +214,6 @@ export function buildCuaFanoutBundle(args) {
197
214
  streams.push(records.stream);
198
215
  events.push(...records.events);
199
216
  });
200
- if (args.failFastReason) {
201
- events.push({
202
- id: nextEventId("fanout-fail-fast"),
203
- at: args.run.createdAt,
204
- level: "warn",
205
- type: "cua-lab.fanout.fail-fast",
206
- message: `Fan-out fail-fast: ${args.failFastReason}. In-flight participants finished; queued participants were skipped (blocked); completed evidence is retained.`,
207
- });
208
- }
209
217
  const review = fanoutReview(args, streams, judgment);
210
218
  const anyRaw = (outcomes ?? []).some((outcome) => outcome.session?.trace.redaction.screenshots === "raw");
211
219
  const ranLive = (outcomes ?? []).some((outcome) => outcome.session !== undefined || outcome.sessionError !== undefined);
@@ -38,6 +38,7 @@ export declare function runStudyParticipants(setup: CuaRunSetup, participants: C
38
38
  rerun?: import("../../run/bundle.js").RunRerunLineage;
39
39
  observer?: import("../../index.js").ObserverResult;
40
40
  diagnostics?: import("./diagnostics.js").CuaDiagnostics;
41
+ hostSuspension?: import("../../run/host-suspension.js").HostSuspensionReading;
41
42
  warnings: string[];
42
43
  error?: {
43
44
  code: import("./types.js").CuaActorStudyErrorCode;
@@ -46,7 +47,6 @@ export declare function runStudyParticipants(setup: CuaRunSetup, participants: C
46
47
  runId: string;
47
48
  };
48
49
  outcomes?: never;
49
- failFastReason?: never;
50
50
  receiving?: never;
51
51
  receivingWarnings?: never;
52
52
  externalCommsWarnings?: never;
@@ -54,7 +54,6 @@ export declare function runStudyParticipants(setup: CuaRunSetup, participants: C
54
54
  result?: never;
55
55
  ok: true;
56
56
  outcomes: ParticipantRunOutcome[] | undefined;
57
- failFastReason: string | undefined;
58
57
  receiving: CommsReceivingRun | undefined;
59
58
  receivingWarnings: string[];
60
59
  externalCommsWarnings: string[];
@@ -94,17 +94,16 @@ export async function runStudyParticipants(setup, participants) {
94
94
  }
95
95
  // Run the participants (dry-run runs none). In-process is always one.
96
96
  let outcomes;
97
- let failFastReason;
98
97
  try {
99
98
  if (!dryRun)
100
- ({ outcomes, failFastReason } = await runAllCuaParticipants(participantRuns, {
99
+ outcomes = await runAllCuaParticipants(participantRuns, {
101
100
  ...deps,
102
101
  // A session that throws returns no trace; the run still records that it started.
103
102
  runSession: (options) => {
104
103
  run.participantStarted();
105
104
  return deps.runSession(options);
106
105
  },
107
- }, participantPlan, inProcess));
106
+ }, participantPlan, inProcess);
108
107
  }
109
108
  finally {
110
109
  try {
@@ -130,7 +129,6 @@ export async function runStudyParticipants(setup, participants) {
130
129
  return {
131
130
  ok: true,
132
131
  outcomes,
133
- failFastReason,
134
132
  receiving,
135
133
  receivingWarnings,
136
134
  externalCommsWarnings,
@@ -3,7 +3,7 @@ import type { StudyDeps } from "../../study/study-deps.js";
3
3
  import type { LiveTraceFlush } from "./live-flush.js";
4
4
  import type { CuaParticipantDeps } from "./types.js";
5
5
  /** The deps a desktop route builds once per run. Each participant adds the app URL it opens. */
6
- export type DesktopParticipantDeps = Omit<CuaParticipantDeps, "signalProvisioned" | "appUrl" | "onObservedUrl">;
6
+ export type DesktopParticipantDeps = Omit<CuaParticipantDeps, "signalReady" | "appUrl" | "onObservedUrl">;
7
7
  /** What every participant of a desktop run reads. Each route supplies all of it. */
8
8
  interface DesktopRunFacts {
9
9
  plan: Pick<ComputerUsePlan | SharedWorldPlan, "studyId" | "residual" | "caps" | "dryRun">;
@@ -37,7 +37,9 @@ export function desktopParticipantDeps(run, overrides) {
37
37
  redactScreenshots: run.redactScreenshots,
38
38
  scrubKnownValues: run.scrubKnownValues,
39
39
  runSession: run.runSession,
40
- now: seams.now ?? Date.now,
40
+ // The run's own clock unless a test sets another, so a participant's times and the run's host
41
+ // suspensions read one clock.
42
+ now: seams.now ?? seams.hostClock?.now ?? Date.now,
41
43
  onStream: run.onStream,
42
44
  reportSubjectPhase: run.reportSubjectPhase,
43
45
  };
@@ -12,27 +12,22 @@ export declare function makeParticipantWriteScreenshot(artifactRoot: PreparedOut
12
12
  export declare function runCuaParticipant(spec: DesktopParticipantRun, deps: CuaParticipantDeps): Promise<ParticipantRunOutcome>;
13
13
  /**
14
14
  * Run N>1 E2B participants on the study's schedule (runOnSchedule: each at its start offset, at
15
- * most `concurrency` at once), behind a pipeline gate (the first to start provisions before the
16
- * rest start) and session fail-fast on harness errors only (queued participants become `blocked`
17
- * with a pinned reason + a fail-fast event; mission verdicts never trip it). A gate failure or a
18
- * fail-fast ends every wait for a later start at once. Each participant tears down its own sandbox
19
- * by id; nothing here ever enumerates.
15
+ * most `concurrency` at once), behind the pipeline gate when the subject is set up in each sandbox.
16
+ * A harness error ends only its own participant: one desktop's stall or one provider failure does
17
+ * not predict the next participant's, so the schedule goes on. Only a closed gate ends every wait
18
+ * for a later start at once, and the participants it skips are `blocked` with its reason. Each
19
+ * participant tears down its own sandbox by id; nothing here ever enumerates.
20
20
  *
21
21
  * Exported for the total-runner tests: the injectable runner lets a test make one participant
22
22
  * throw (the exact class the guard exists for) without a live sandbox. Production always uses
23
23
  * the default.
24
24
  */
25
- export declare function runCuaParticipants(runs: DesktopParticipantRun[], deps: Omit<CuaParticipantDeps, "signalProvisioned">, concurrency: number, runParticipant?: typeof runCuaParticipant): Promise<{
26
- outcomes: ParticipantRunOutcome[];
27
- failFastReason?: string;
28
- }>;
25
+ export declare function runCuaParticipants(runs: DesktopParticipantRun[], deps: Omit<CuaParticipantDeps, "signalReady">, concurrency: number, runParticipant?: typeof runCuaParticipant): Promise<ParticipantRunOutcome[]>;
29
26
  /** Project one participant outcome (or a dry-run contract spec) into the public CuaParticipantResult. */
30
27
  export declare function toParticipantResult(spec: DesktopParticipantRun, outcome: ParticipantRunOutcome | undefined, subject: RunSubjectProvenance, dryRun: boolean): CuaParticipantResult;
31
28
  /**
32
29
  * Run every participant of a live run. The in-process route drives its single participant in this
33
- * process; one hosted participant runs alone; a fan-out runs at the plan's concurrency and may stop early.
30
+ * process; one hosted participant runs alone; a fan-out runs at the plan's concurrency, and stops
31
+ * early only when the pipeline gate closes.
34
32
  */
35
- export declare function runAllCuaParticipants(runs: readonly DesktopParticipantRun[], deps: Omit<CuaParticipantDeps, "signalProvisioned">, participantPlan: CuaParticipantPlan, inProcess: boolean): Promise<{
36
- outcomes: ParticipantRunOutcome[];
37
- failFastReason: string | undefined;
38
- }>;
33
+ export declare function runAllCuaParticipants(runs: readonly DesktopParticipantRun[], deps: Omit<CuaParticipantDeps, "signalReady">, participantPlan: CuaParticipantPlan, inProcess: boolean): Promise<ParticipantRunOutcome[]>;
@@ -1,3 +1,4 @@
1
+ import { scheduledParticipantDeps } from "./participant-runs.js";
1
2
  import { judgeParticipantRecords } from "../../run/judge.js";
2
3
  import { createE2BParticipantDesktop } from "./e2b-desktop/desktop.js";
3
4
  import { createInProcessDesktop } from "./in-process-desktop.js";
@@ -31,7 +32,7 @@ export function makeParticipantWriteScreenshot(artifactRoot, spec, screenshots)
31
32
  return rel;
32
33
  };
33
34
  }
34
- /** A blocked participant outcome (pipeline gate / fail-fast skipped it before it ran). */
35
+ /** A blocked participant outcome: the pipeline gate or the study budget skipped it. */
35
36
  function skippedOutcome(spec, reason, arrival) {
36
37
  return {
37
38
  spec,
@@ -56,7 +57,6 @@ export async function runCuaParticipant(spec, deps) {
56
57
  // model turn only to stop at its first one, so it is skipped before its desktop exists.
57
58
  const crossed = deps.runBudget?.crossed();
58
59
  if (crossed !== undefined) {
59
- deps.signalProvisioned?.(false);
60
60
  return skippedOutcome(spec, `skipped: study budget reached before this participant started: ${crossed}; no desktop was created`);
61
61
  }
62
62
  let model = {};
@@ -67,23 +67,13 @@ export async function runCuaParticipant(spec, deps) {
67
67
  let sessionError;
68
68
  let providerCleanupError;
69
69
  let providerPolicyError;
70
- let provisioned = false;
71
- let signaled = false;
72
- const signal = (ok) => {
73
- if (!signaled && deps.signalProvisioned) {
74
- signaled = true;
75
- deps.signalProvisioned(ok);
76
- }
77
- };
78
70
  const desktop = deps.createDesktop?.(spec, warnings, deps.artifactRoot) ??
79
71
  createE2BParticipantDesktop(spec, deps, warnings);
80
72
  try {
81
73
  await desktop.prepare();
82
74
  const ready = await desktop.openSession();
83
75
  model = await startParticipantModel(spec, deps, ready.executor);
84
- // World is ready: release the pipeline gate so the remaining participants may start.
85
- provisioned = true;
86
- signal(true);
76
+ deps.signalReady?.();
87
77
  session = await deps.runSession(participantSessionOptions(spec, deps, ready, model.provider, writeScreenshot));
88
78
  }
89
79
  catch (error) {
@@ -102,18 +92,12 @@ export async function runCuaParticipant(spec, deps) {
102
92
  : closed.refusal.startsWith("HUMANISH_CLAUDE_PARTICIPANT_")
103
93
  ? `Claude Code's stream showed a call the participant may not make after its last turn (${closed.refusal}); the participant was stopped.`
104
94
  : `Codex output after the participant's last request could not be checked against the item policy (${closed.refusal}).`));
105
- try {
106
- if (!provisioned)
107
- signal(false);
108
- }
109
- finally {
110
- await desktop.finalize({
111
- failed: sessionError !== undefined ||
112
- providerCleanupError !== undefined ||
113
- providerPolicyError !== undefined ||
114
- session === undefined,
115
- });
116
- }
95
+ await desktop.finalize({
96
+ failed: sessionError !== undefined ||
97
+ providerCleanupError !== undefined ||
98
+ providerPolicyError !== undefined ||
99
+ session === undefined,
100
+ });
117
101
  }
118
102
  const { released, desktopFailure, ...desktopEvidence } = desktop.snapshot();
119
103
  // A desktop that died under the participant explains the failure better than the transport
@@ -127,8 +111,8 @@ export async function runCuaParticipant(spec, deps) {
127
111
  if (session)
128
112
  await recordParticipantTrace(spec, deps, session, warnings);
129
113
  const { noEngagement, selfReportedBlocker, reportedFriction } = judgeParticipantSession(session, deps, warnings);
130
- // A provider-cleanup or provider-policy failure still trips fail-fast and counts in the
131
- // participant summary's harness errors.
114
+ // A provider-cleanup or provider-policy failure counts in the participant summary's harness
115
+ // errors.
132
116
  const harnessError = sessionError !== undefined ||
133
117
  providerCleanupError !== undefined ||
134
118
  providerPolicyError !== undefined ||
@@ -147,57 +131,94 @@ export async function runCuaParticipant(spec, deps) {
147
131
  selfReportedBlocker,
148
132
  reportedFriction,
149
133
  harnessError,
134
+ endedAt: deps.now(),
135
+ };
136
+ }
137
+ /**
138
+ * Holds a fan-out's participants back until one of them is ready, when each sets up the subject in
139
+ * its own sandbox the same way (a clone or local tree built and served, a desktop CLI installed),
140
+ * so a subject that fails to set up fails once. The first participant to enter holds the gate and
141
+ * the others wait. Any participant that becomes ready opens it. A holder that never got a desktop
142
+ * learned nothing about the subject, so the next waiting participant holds it instead. A holder
143
+ * whose desktop came up and then failed before its session closes it, and `onClose` runs.
144
+ */
145
+ function pipelineGate(open, onClose) {
146
+ let state = open ? "open" : "vacant";
147
+ let holder;
148
+ const waiting = [];
149
+ const settle = (turn) => {
150
+ for (const participant of waiting.splice(0))
151
+ participant.turn(turn);
152
+ };
153
+ return {
154
+ /** The participant that holds the gate, or held it when it closed. */
155
+ holder: () => holder,
156
+ enter(id) {
157
+ if (state === "open")
158
+ return Promise.resolve("start");
159
+ if (state === "closed")
160
+ return Promise.resolve("closed");
161
+ if (state === "vacant") {
162
+ state = "held";
163
+ holder = id;
164
+ return Promise.resolve("hold");
165
+ }
166
+ return new Promise((turn) => waiting.push({ id, turn }));
167
+ },
168
+ ready() {
169
+ if (state !== "held")
170
+ return;
171
+ state = "open";
172
+ settle("start");
173
+ },
174
+ failed(id, { handOver }) {
175
+ if (state !== "held" || holder !== id)
176
+ return;
177
+ if (handOver) {
178
+ const next = waiting.shift();
179
+ if (next === undefined)
180
+ state = "vacant";
181
+ else {
182
+ holder = next.id;
183
+ next.turn("hold");
184
+ }
185
+ return;
186
+ }
187
+ state = "closed";
188
+ settle("closed");
189
+ onClose();
190
+ },
150
191
  };
151
192
  }
152
193
  /**
153
194
  * Run N>1 E2B participants on the study's schedule (runOnSchedule: each at its start offset, at
154
- * most `concurrency` at once), behind a pipeline gate (the first to start provisions before the
155
- * rest start) and session fail-fast on harness errors only (queued participants become `blocked`
156
- * with a pinned reason + a fail-fast event; mission verdicts never trip it). A gate failure or a
157
- * fail-fast ends every wait for a later start at once. Each participant tears down its own sandbox
158
- * by id; nothing here ever enumerates.
195
+ * most `concurrency` at once), behind the pipeline gate when the subject is set up in each sandbox.
196
+ * A harness error ends only its own participant: one desktop's stall or one provider failure does
197
+ * not predict the next participant's, so the schedule goes on. Only a closed gate ends every wait
198
+ * for a later start at once, and the participants it skips are `blocked` with its reason. Each
199
+ * participant tears down its own sandbox by id; nothing here ever enumerates.
159
200
  *
160
201
  * Exported for the total-runner tests: the injectable runner lets a test make one participant
161
202
  * throw (the exact class the guard exists for) without a live sandbox. Production always uses
162
203
  * the default.
163
204
  */
164
205
  export async function runCuaParticipants(runs, deps, concurrency, runParticipant = runCuaParticipant) {
165
- const failFast = { tripped: false, reason: "" };
166
206
  const stopping = new AbortController();
167
- let gateHolder = runs[0]?.planned.id ?? "lane-01";
168
- let resolveGate;
169
- let rejectGate;
170
- const gate = new Promise((resolve, reject) => {
171
- resolveGate = resolve;
172
- rejectGate = () => {
173
- reject(new Error("gate"));
174
- stopping.abort();
175
- };
176
- });
177
- // The gate is rejected on the first participant's provisioning failure; swallow the unhandled
178
- // rejection if no later participant ever awaits it (concurrency could let the first finish
179
- // alone).
180
- gate.catch(() => undefined);
181
- const outcomes = await runOnSchedule(runs, {
207
+ // An app-url subject is only opened, so one participant's start says nothing about another's.
208
+ const gate = pipelineGate(deps.subject.kind === "app-url", () => stopping.abort());
209
+ return runOnSchedule(runs, {
182
210
  startAfterMs: (spec) => spec.planned.startAfterMs,
183
211
  slots: concurrency,
184
212
  now: deps.now,
185
213
  signal: stopping.signal,
186
- }, async (spec, _index, { order, scheduledAt }) => {
187
- if (order === 0)
188
- gateHolder = spec.planned.id;
189
- if (order > 0) {
190
- try {
191
- await gate;
192
- }
193
- catch {
194
- return skippedOutcome(spec, `skipped: participant ${gateHolder} failed to provision its world (pipeline gate)`, { scheduledAt });
195
- }
196
- }
197
- if (failFast.tripped) {
198
- return skippedOutcome(spec, `skipped: ${failFast.reason}`, { scheduledAt });
214
+ }, async (spec, _index, { scheduledAt }) => {
215
+ const id = spec.planned.id;
216
+ if ((await gate.enter(id)) === "closed") {
217
+ return skippedOutcome(spec, `skipped: participant ${gate.holder()} failed to provision its world (pipeline gate)`, { scheduledAt });
199
218
  }
200
219
  const arrival = { scheduledAt, startedAt: deps.now() };
220
+ let ready = false;
221
+ let threw = false;
201
222
  // The participant runner is total: every exit path returns a recorded outcome. Without this
202
223
  // guard, one participant's late throw (e.g. its trace write hitting ENOSPC after its own sandbox was
203
224
  // already torn down) rejected the whole map while sibling workers kept launching sandboxes
@@ -206,25 +227,15 @@ export async function runCuaParticipants(runs, deps, concurrency, runParticipant
206
227
  try {
207
228
  outcome = await runParticipant(spec, {
208
229
  ...deps,
209
- ...(order === 0
210
- ? {
211
- signalProvisioned: (ok) => {
212
- if (ok) {
213
- resolveGate?.();
214
- }
215
- else {
216
- rejectGate?.();
217
- }
218
- },
219
- }
220
- : {}),
230
+ ...scheduledParticipantDeps(spec.planned, deps, scheduledAt),
231
+ signalReady: () => {
232
+ ready = true;
233
+ gate.ready();
234
+ },
221
235
  });
222
236
  }
223
237
  catch (error) {
224
- // The first participant may have thrown before signaling the provisioning gate; release the followers as
225
- // blocked rather than leaving them awaiting a gate that will never settle.
226
- if (order === 0)
227
- rejectGate?.();
238
+ threw = true;
228
239
  const detail = redactText(toErrorMessage(error));
229
240
  outcome = {
230
241
  spec,
@@ -239,20 +250,20 @@ export async function runCuaParticipants(runs, deps, concurrency, runParticipant
239
250
  reportedFriction: false,
240
251
  harnessError: true,
241
252
  sessionError: `participant runner threw outside the session guard: ${detail}`,
253
+ endedAt: deps.now(),
242
254
  };
243
255
  }
244
- if (outcome.harnessError && !failFast.tripped) {
245
- failFast.tripped = true;
246
- failFast.reason = `a prior participant (${outcome.spec.planned.id}) ended in a harness error (fail-fast)`;
247
- stopping.abort();
248
- }
256
+ // A participant that stopped before its session without a desktop says nothing about the
257
+ // subject, so the next participant starts first. A runner that threw is a harness defect: it
258
+ // closes the gate.
259
+ if (!ready)
260
+ gate.failed(id, { handOver: !threw && outcome.sandboxId === undefined });
249
261
  // A participant the study budget skipped never requested its desktop.
250
262
  return {
251
263
  ...outcome,
252
264
  arrival: outcome.skippedReason === undefined ? arrival : { scheduledAt },
253
265
  };
254
266
  });
255
- return { outcomes, ...(failFast.tripped ? { failFastReason: failFast.reason } : {}) };
256
267
  }
257
268
  /** Project one participant outcome (or a dry-run contract spec) into the public CuaParticipantResult. */
258
269
  export function toParticipantResult(spec, outcome, subject, dryRun) {
@@ -347,19 +358,21 @@ export function toParticipantResult(spec, outcome, subject, dryRun) {
347
358
  }
348
359
  /**
349
360
  * Run every participant of a live run. The in-process route drives its single participant in this
350
- * process; one hosted participant runs alone; a fan-out runs at the plan's concurrency and may stop early.
361
+ * process; one hosted participant runs alone; a fan-out runs at the plan's concurrency, and stops
362
+ * early only when the pipeline gate closes.
351
363
  */
352
364
  export async function runAllCuaParticipants(runs, deps, participantPlan, inProcess) {
365
+ // A participant that runs alone starts with the run.
366
+ const alone = { ...deps, ...scheduledParticipantDeps(runs[0].planned, deps, deps.now()) };
353
367
  if (inProcess) {
354
368
  // The caller's executor stands in for a desktop, and the shared runner drives the participant.
355
369
  const outcome = await runCuaParticipant(runs[0], {
356
- ...deps,
370
+ ...alone,
357
371
  createDesktop: () => createInProcessDesktop(deps),
358
372
  });
359
- return { outcomes: [outcome], failFastReason: undefined };
373
+ return [outcome];
360
374
  }
361
375
  if (runs.length === 1)
362
- return { outcomes: [await runCuaParticipant(runs[0], deps)], failFastReason: undefined };
363
- const ran = await runCuaParticipants([...runs], deps, participantPlan.concurrency);
364
- return { outcomes: ran.outcomes, failFastReason: ran.failFastReason };
376
+ return [await runCuaParticipant(runs[0], alone)];
377
+ return runCuaParticipants([...runs], deps, participantPlan.concurrency);
365
378
  }
@@ -99,6 +99,7 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
99
99
  : spec.instructions,
100
100
  persona: spec.persona,
101
101
  timeoutMs: deps.timeoutMs,
102
+ ...participantClock(spec.planned.limits.timeZone, deps),
102
103
  // The brain is either a keyed API client or a CLI the operator is already signed in to.
103
104
  // Everything below this line (loop, executor, trace, affordances) is identical either
104
105
  // way, which is what makes a local-agent run comparable to an API one.
@@ -305,3 +306,19 @@ export function makeCuaRunBudget(maxTotalUsd) {
305
306
  },
306
307
  };
307
308
  }
309
+ /**
310
+ * The participant's clock: its time zone, and its scheduled end on the run's clock. A participant
311
+ * with an end runs its session on the run's clock, so the end and the schedule read one clock.
312
+ */
313
+ function participantClock(timeZone, deps) {
314
+ const { scheduledEnd } = deps;
315
+ if (timeZone === undefined && scheduledEnd === undefined)
316
+ return {};
317
+ return {
318
+ clock: {
319
+ ...(timeZone === undefined ? {} : { timeZone }),
320
+ ...(scheduledEnd === undefined ? {} : { end: scheduledEnd }),
321
+ },
322
+ ...(scheduledEnd === undefined ? {} : { now: deps.now }),
323
+ };
324
+ }
@@ -3,7 +3,8 @@ import { type StudyConfig } from "../../study/types.js";
3
3
  import { type ResolvedPersona } from "../../study/persona.js";
4
4
  import { type RunRerunLineage } from "../../run/bundle.js";
5
5
  import type { PreparedSelectedOutputDirectory } from "../../run/contained-output.js";
6
- import { type CuaActorStudyErrorCode, type CuaParticipantPlan, type CuaParticipantPlanEntry, type DesktopParticipantRun, type ParticipantRunsAndPlan } from "./types.js";
6
+ import { type CuaActorStudyErrorCode, type CuaParticipantDeps, type CuaParticipantPlan, type CuaParticipantPlanEntry, type DesktopParticipantRun, type ParticipantRunsAndPlan } from "./types.js";
7
+ import { type ParticipantWindow } from "../../study/arrivals.js";
7
8
  /**
8
9
  * The session budget of a study that declares no execution.timeoutMs. On a provisioned route it is
9
10
  * what the `ceilingMs` sandbox ceiling leaves after provisioning, seeding and the teardown buffer.
@@ -16,6 +17,15 @@ export declare function defaultSessionTimeoutMs(config: StudyConfig, ceilingMs:
16
17
  * git clone for an upload+extract, but the shared install/build/state/start/probe pipeline
17
18
  * costs the same wall-clock room either way. */
18
19
  export declare function resolveParticipantSandboxMs(config: StudyConfig, ceilingMs: number): number;
20
+ /**
21
+ * The deps one participant runs with once its start comes: its own session budget and sandbox
22
+ * deadline, and with a declared end, that end on the run's clock (`scheduledAt` is when it was
23
+ * due, from runOnSchedule).
24
+ */
25
+ export declare function scheduledParticipantDeps(participant: ParticipantWindow, deps: {
26
+ readonly timeoutMs: number;
27
+ readonly sandboxMs: number;
28
+ }, scheduledAt: number): Pick<CuaParticipantDeps, "timeoutMs" | "sandboxMs" | "scheduledEnd">;
19
29
  /**
20
30
  * The in-flight participant bound a study declares. Defaults to the participant count: every
21
31
  * declared participant runs at once, because a throttle nobody asked for silently turns "N actors
@@ -18,7 +18,7 @@ import { composeParticipantInstructions, DEFAULT_MISSION } from "./participant-p
18
18
  import { resolveCuaRerunSelection } from "./rerun-selection.js";
19
19
  import { plural } from "../../run/text.js";
20
20
  import { formatDuration } from "../../run/projection.js";
21
- import { describeArrivals, planArrivals } from "../../study/arrivals.js";
21
+ import { describeArrivals, participantSessionMs, planArrivals, scheduledEndAt, } from "../../study/arrivals.js";
22
22
  /**
23
23
  * The session budget of a study that declares no execution.timeoutMs. On a provisioned route it is
24
24
  * what the `ceilingMs` sandbox ceiling leaves after provisioning, seeding and the teardown buffer.
@@ -45,6 +45,30 @@ export function resolveParticipantSandboxMs(config, ceilingMs) {
45
45
  timeoutMs +
46
46
  sandboxHeadroomMs(provisionedRoute ? { seed: config.subject.state?.seed ?? [] } : undefined));
47
47
  }
48
+ /**
49
+ * A participant's own session budget and sandbox deadline: the study's, or with a declared end its
50
+ * window plus room for the closing report (participantSessionMs), with the same setup and
51
+ * teardown headroom the study's sandbox has over its session.
52
+ */
53
+ function participantWindow(participant, study) {
54
+ const timeoutMs = participantSessionMs(participant, study.sessionBudgetMs);
55
+ return { timeoutMs, sandboxMs: study.sandboxMs - study.sessionBudgetMs + timeoutMs };
56
+ }
57
+ /**
58
+ * The deps one participant runs with once its start comes: its own session budget and sandbox
59
+ * deadline, and with a declared end, that end on the run's clock (`scheduledAt` is when it was
60
+ * due, from runOnSchedule).
61
+ */
62
+ export function scheduledParticipantDeps(participant, deps, scheduledAt) {
63
+ const end = scheduledEndAt(participant, scheduledAt);
64
+ return {
65
+ ...participantWindow(participant, {
66
+ sessionBudgetMs: deps.timeoutMs,
67
+ sandboxMs: deps.sandboxMs,
68
+ }),
69
+ ...(end === undefined ? {} : { scheduledEnd: end }),
70
+ };
71
+ }
48
72
  /**
49
73
  * The in-flight participant bound a study declares. Defaults to the participant count: every
50
74
  * declared participant runs at once, because a throttle nobody asked for silently turns "N actors
@@ -114,10 +138,11 @@ export function participantRunsAndPlan(plan, opts = {}) {
114
138
  });
115
139
  const resolved = envLoweredConcurrency(plan.concurrency, participantCount, env);
116
140
  const concurrency = resolved.bound;
117
- const { sessionBudgetMs, sandboxMs } = plan;
141
+ const { sessionBudgetMs } = plan;
118
142
  const schedule = resolved.envLoweredFrom === undefined
119
143
  ? plan.arrivals
120
- : planArrivals(participants.map((participant) => participant.startAfterMs), { slots: concurrency, sessionMs: sessionBudgetMs });
144
+ : planArrivals(participants.map((participant) => participant.startAfterMs), { slots: concurrency, sessionMs: sessionBudgetMs }, participants.map((participant) => participantSessionMs(participant, sessionBudgetMs)));
145
+ const sandboxesMs = participants.reduce((sum, participant) => sum + participantWindow(participant, plan).sandboxMs, 0);
121
146
  const participantPlan = {
122
147
  strategy: CUA_FANOUT_STRATEGY,
123
148
  laneCount: participantCount,
@@ -127,7 +152,7 @@ export function participantRunsAndPlan(plan, opts = {}) {
127
152
  : { envLoweredConcurrencyFrom: resolved.envLoweredFrom }),
128
153
  waves: Math.ceil(participantCount / concurrency),
129
154
  perLaneSessionBudgetMs: sessionBudgetMs,
130
- worstCaseSandboxMinutes: Math.round((participantCount * sandboxMs) / 60_000),
155
+ worstCaseSandboxMinutes: Math.round(sandboxesMs / 60_000),
131
156
  ...(schedule.declared ? { schedule } : {}),
132
157
  dryRun: plan.dryRun,
133
158
  lanes: runs.map((spec) => ({
@@ -158,6 +183,7 @@ export function participantRunsAndPlan(plan, opts = {}) {
158
183
  ...(spec.planned.startAfterMs === undefined
159
184
  ? {}
160
185
  : { startAfterMs: spec.planned.startAfterMs }),
186
+ ...(spec.planned.endAfterMs === undefined ? {} : { endAfterMs: spec.planned.endAfterMs }),
161
187
  })),
162
188
  };
163
189
  return { runs, participantPlan };
@@ -182,6 +208,7 @@ export function formatParticipantPlanEntry(entry) {
182
208
  entry.caseGroup ? `case=${entry.caseGroup}` : undefined,
183
209
  entry.reasoningEffort ? `effort=${entry.reasoningEffort}` : undefined,
184
210
  entry.startAfterMs === undefined ? undefined : `start=+${formatDuration(entry.startAfterMs)}`,
211
+ entry.endAfterMs === undefined ? undefined : `end=+${formatDuration(entry.endAfterMs)}`,
185
212
  ].filter((part) => part !== undefined);
186
213
  return `${entry.id}: persona=${entry.persona}${taxonomy.length > 0 ? ` ${taxonomy.join(" ")}` : ""} device=${entry.device} ${entry.resolution[0]}x${entry.resolution[1]} prompt#${entry.instructionDigest}${entry.targetDigest ? ` target#${entry.targetDigest}` : ""}`;
187
214
  }
@@ -9,7 +9,7 @@ import { studyUrlCredentialReason } from "../../study/url-credentials.js";
9
9
  import { subjectStateInvalidReason } from "../../study/parse/subject-state.js";
10
10
  import { brainOf, callerBrainOf, planCaps, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
11
11
  import { computerUseParticipants, declaredTargets } from "../../study/plan-participants.js";
12
- import { planStarts } from "../../study/arrivals.js";
12
+ import { planStarts, windowDeadlineRefusal } from "../../study/arrivals.js";
13
13
  import { computerUseParticipantCount, participantCountReason } from "../../study/routing.js";
14
14
  import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
15
15
  import { desktopCliProductReason } from "../../study/composition-rules.js";
@@ -209,6 +209,13 @@ function startsOf(config, runner, limit, ceilingMs) {
209
209
  if (!atOnce.ok)
210
210
  return atOnce;
211
211
  const sessionMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config, ceilingMs);
212
+ // A participant with a declared end gets a sandbox sized from its own window.
213
+ if (runner.desktop === "e2b-desktop") {
214
+ const headroomMs = resolveParticipantSandboxMs(config, ceilingMs) - sessionMs;
215
+ const window = windowDeadlineRefusal(runner.participants, { sessionMs, headroomMs }, ceilingMs);
216
+ if (window !== undefined)
217
+ return { ok: false, message: window };
218
+ }
212
219
  const slots = { slots: atOnce.count, sessionMs };
213
220
  return {
214
221
  ok: true,