humanish 0.117.0 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/analysis/admission.d.ts +23 -4
  3. package/dist/analysis/admission.js +47 -9
  4. package/dist/analysis/analysis-limits.d.ts +32 -0
  5. package/dist/analysis/analysis-limits.js +41 -3
  6. package/dist/analysis/automatic-config.d.ts +11 -1
  7. package/dist/analysis/automatic-config.js +26 -11
  8. package/dist/analysis/cohorts.d.ts +103 -0
  9. package/dist/analysis/cohorts.js +80 -0
  10. package/dist/analysis/evidence.d.ts +5 -10
  11. package/dist/analysis/evidence.js +37 -34
  12. package/dist/analysis/execute.d.ts +24 -7
  13. package/dist/analysis/execute.js +290 -307
  14. package/dist/analysis/responses.d.ts +19 -0
  15. package/dist/analysis/responses.js +201 -0
  16. package/dist/analysis/store.js +2 -1
  17. package/dist/analysis/validation.js +13 -7
  18. package/dist/cli/commands/analyze.js +10 -4
  19. package/dist/cli/commands/study-format.js +8 -11
  20. package/dist/cli/commands/study-route-shared-world.js +3 -1
  21. package/dist/cli/commands/study-run.js +1 -1
  22. package/dist/cli/doctor.js +11 -1
  23. package/dist/comms/lease-store.d.ts +1 -1
  24. package/dist/comms/lease-store.js +3 -2
  25. package/dist/comms/receiving-types.d.ts +2 -0
  26. package/dist/comms/receiving-types.js +2 -0
  27. package/dist/observer-app.html +2 -2
  28. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  29. package/dist/routes/computer-use/fanout-records.js +6 -1
  30. package/dist/routes/computer-use/participant-execution.d.ts +6 -4
  31. package/dist/routes/computer-use/participant-execution.js +42 -14
  32. package/dist/routes/computer-use/participant-model.js +18 -8
  33. package/dist/routes/computer-use/participant-runs.js +15 -9
  34. package/dist/routes/computer-use/plan.d.ts +3 -1
  35. package/dist/routes/computer-use/plan.js +38 -12
  36. package/dist/routes/computer-use/rerun-selection.js +14 -3
  37. package/dist/routes/computer-use/single-bundle.js +7 -4
  38. package/dist/routes/computer-use/subject-projection.js +2 -0
  39. package/dist/routes/computer-use/types.d.ts +13 -1
  40. package/dist/routes/scripted/plan.d.ts +4 -1
  41. package/dist/routes/scripted/plan.js +25 -2
  42. package/dist/routes/scripted/setup.js +1 -4
  43. package/dist/routes/scripted/types.d.ts +1 -1
  44. package/dist/routes/shared-world/bundle-records.js +14 -0
  45. package/dist/routes/shared-world/bundle.js +2 -1
  46. package/dist/routes/shared-world/external-public.js +15 -3
  47. package/dist/routes/shared-world/handoff.d.ts +2 -2
  48. package/dist/routes/shared-world/participant-specs.js +2 -2
  49. package/dist/routes/shared-world/plan.d.ts +10 -1
  50. package/dist/routes/shared-world/plan.js +86 -10
  51. package/dist/routes/shared-world/provisioned.js +15 -8
  52. package/dist/routes/shared-world/route.js +7 -4
  53. package/dist/routes/shared-world/types.d.ts +2 -0
  54. package/dist/run/bundle-shape.js +12 -2
  55. package/dist/run/bundle.d.ts +20 -3
  56. package/dist/run/participant-records.js +1 -0
  57. package/dist/run/projection.js +7 -2
  58. package/dist/run/run.js +4 -2
  59. package/dist/run/subject-name.d.ts +11 -0
  60. package/dist/run/subject-name.js +11 -0
  61. package/dist/study/arrivals.d.ts +114 -0
  62. package/dist/study/arrivals.js +204 -0
  63. package/dist/study/composition-rules.js +1 -1
  64. package/dist/study/config.js +6 -20
  65. package/dist/study/doctor.js +2 -1
  66. package/dist/study/keys.d.ts +1 -0
  67. package/dist/study/keys.js +2 -0
  68. package/dist/study/migrate/v2.js +3 -2
  69. package/dist/study/parse/actors.d.ts +2 -2
  70. package/dist/study/parse/actors.js +10 -4
  71. package/dist/study/parse/front.d.ts +1 -1
  72. package/dist/study/parse/front.js +36 -2
  73. package/dist/study/plan-base.d.ts +1 -1
  74. package/dist/study/plan-participants.d.ts +2 -0
  75. package/dist/study/plan-participants.js +3 -5
  76. package/dist/study/plan-types.d.ts +13 -3
  77. package/dist/study/plan.d.ts +4 -4
  78. package/dist/study/plan.js +21 -12
  79. package/dist/study/preflight.d.ts +2 -1
  80. package/dist/study/preflight.js +24 -3
  81. package/dist/study/routing.d.ts +5 -4
  82. package/dist/study/routing.js +16 -7
  83. package/dist/study/types.d.ts +14 -0
  84. package/dist/study/validation.d.ts +2 -2
  85. package/dist/study/validation.js +27 -14
  86. package/dist/study/warnings.js +11 -3
  87. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  88. package/dist/substrates/e2b/lifetime.js +134 -10
  89. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  90. package/dist/tui-app.js +112 -112
  91. package/dist/verify/artifacts.d.ts +19 -4
  92. package/dist/verify/artifacts.js +39 -8
  93. package/dist/verify/verify.js +6 -15
  94. package/docs/contracts/run-bundle.md +8 -7
  95. package/docs/contracts/schemas.md +33 -9
  96. package/docs/contracts/study-analysis.md +61 -8
  97. package/docs/principles/invariants-and-defaults.md +14 -14
  98. package/docs/product/automatic-analysis.md +10 -3
  99. package/package.json +1 -1
@@ -7,9 +7,13 @@ import { browserSurfaces } from "../../actors/scripted-browser/types.js";
7
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
8
8
  import { isNonEmpty, planBase, provisionedSubject } from "../../study/plan-base.js";
9
9
  import { cloneTargetValidationReason, desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
+ import { sandboxDeadlineRefusal } from "../../substrates/e2b/lifetime.js";
10
11
  // Default surface roster is 1 (desktop only): the defaults-table single-participant row governs;
11
12
  // `count: 2` is the declared override that adds the mobile surface.
12
13
  const DEFAULT_SURFACE_COUNT = 1;
14
+ // Journey wall-clock budget per surface: 5 minutes. A scripted surface has zero model cost and
15
+ // sandbox-seconds are pennies; a short default only truncated slow-loading subjects.
16
+ const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
13
17
  /** The app URL a run's evidence names: a provisioned subject's getHost URL is never persisted. */
14
18
  export function evidenceAppUrlOf(subject) {
15
19
  return subject.kind === "clone" ? "[provisioned-subject]" : subject.appUrl;
@@ -66,6 +70,26 @@ export function planScriptedStudy(config, input) {
66
70
  const scenarioRef = config.scenario;
67
71
  if (!scenarioRef?.trim())
68
72
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.", { actor, appUrl: evidenceAppUrlOf(subject) });
73
+ const timeoutMs = config.execution?.timeoutMs;
74
+ const sessionTimeoutMs = timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
75
+ // A clone is served from an E2B sandbox that must outlive the journey; a loopback app runs on
76
+ // this machine and needs no sandbox.
77
+ if (subject.kind === "clone") {
78
+ const ceiling = input.sandboxCeiling;
79
+ const reason = ceiling.ok
80
+ ? sandboxDeadlineRefusal({
81
+ name: "the subject sandbox",
82
+ sessionMs: sessionTimeoutMs,
83
+ sessionDeclared: timeoutMs !== undefined,
84
+ servedSubject: { seed: subject.state?.seed ?? [] },
85
+ }, ceiling.ms)
86
+ : ceiling.message;
87
+ if (reason)
88
+ return refuse("HUMANISH_SCRIPTED_SUBJECT_INVALID", reason, {
89
+ actor,
90
+ appUrl: evidenceAppUrlOf(subject),
91
+ });
92
+ }
69
93
  const requirements = [];
70
94
  if (!input.dryRun && subject.kind === "clone") {
71
95
  requirements.push({ kind: "key", name: "E2B_API_KEY" });
@@ -76,7 +100,6 @@ export function planScriptedStudy(config, input) {
76
100
  if (!input.dryRun && input.injectedBrowser !== true)
77
101
  requirements.push({ kind: "host-browser" });
78
102
  const persona = config.actor?.persona;
79
- const timeoutMs = config.execution?.timeoutMs;
80
103
  return {
81
104
  ok: true,
82
105
  plan: {
@@ -90,7 +113,7 @@ export function planScriptedStudy(config, input) {
90
113
  scenarioRef,
91
114
  surfaces: browserSurfaces.slice(0, config.surfaces?.length ?? DEFAULT_SURFACE_COUNT),
92
115
  ...(persona === undefined ? {} : { personaId: persona }),
93
- ...(timeoutMs === undefined ? {} : { sessionTimeoutMs: timeoutMs }),
116
+ sessionTimeoutMs,
94
117
  requirements,
95
118
  },
96
119
  };
@@ -12,9 +12,6 @@ import { evidenceAppUrlOf } from "./plan.js";
12
12
  import { resolveScriptedScenario } from "./scenario.js";
13
13
  import {} from "./types.js";
14
14
  import { refusedResult } from "../../run/study-result.js";
15
- // Journey wall-clock budget per surface: 5 minutes. A scripted surface has zero model cost and
16
- // sandbox-seconds are pennies; a short default only truncated slow-loading subjects.
17
- const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
18
15
  /**
19
16
  * Checks what the plan cannot: the scenario file, the E2B key and subject env of a live clone,
20
17
  * and a browser to launch. Returns the refusal, or what the run needs.
@@ -83,7 +80,7 @@ export async function prepareScriptedRun(plan, input) {
83
80
  if (refusal)
84
81
  return { ok: false, result: failed(refusal.code, refusal.message) };
85
82
  const surfaces = plan.surfaces;
86
- const timeoutMs = plan.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
83
+ const timeoutMs = plan.sessionTimeoutMs;
87
84
  const persona = {
88
85
  id: plan.personaId ?? "scripted-journey",
89
86
  traitsApplied: [],
@@ -58,7 +58,7 @@ export interface ScriptedBrowserStudyResult extends AutomaticAnalysisResult, Stu
58
58
  observer?: ObserverResult;
59
59
  warnings: string[];
60
60
  error?: {
61
- code: StudyResultErrorCode | "HUMANISH_SCRIPTED_FAILED" | "HUMANISH_SCRIPTED_ACTOR_UNSUPPORTED" | "HUMANISH_SCRIPTED_SCENARIO_INVALID" | "HUMANISH_SCRIPTED_SUBJECT_UNSAFE" | "HUMANISH_SCRIPTED_BROWSER_MISSING" | "HUMANISH_SCRIPTED_KEYS_MISSING" | "HUMANISH_SCRIPTED_SUBJECT_ENV_MISSING" | "HUMANISH_SCRIPTED_GETHOST_UNAVAILABLE";
61
+ code: StudyResultErrorCode | "HUMANISH_SCRIPTED_FAILED" | "HUMANISH_SCRIPTED_ACTOR_UNSUPPORTED" | "HUMANISH_SCRIPTED_SCENARIO_INVALID" | "HUMANISH_SCRIPTED_SUBJECT_UNSAFE" | "HUMANISH_SCRIPTED_SUBJECT_INVALID" | "HUMANISH_SCRIPTED_BROWSER_MISSING" | "HUMANISH_SCRIPTED_KEYS_MISSING" | "HUMANISH_SCRIPTED_SUBJECT_ENV_MISSING" | "HUMANISH_SCRIPTED_GETHOST_UNAVAILABLE";
62
62
  message: string;
63
63
  };
64
64
  }
@@ -7,6 +7,7 @@ import { declaredScreenForRender } from "../../substrates/e2b/desktop-geometry.j
7
7
  import { publicSafeRouteLabel } from "./provenance.js";
8
8
  import { participantCaption } from "../../run/participant-caption.js";
9
9
  import { participantSummary } from "../../run/outcomes.js";
10
+ import { arrivalRecord } from "../../study/arrivals.js";
10
11
  function participantView(ctx, spec, index) {
11
12
  const { args, external } = ctx;
12
13
  const name = participantCaption({ id: spec.planned.id, personaId: spec.persona.id });
@@ -69,8 +70,21 @@ function sharedWorldSimulation(ctx, spec, index, view) {
69
70
  summary: participantSummary(name, { name: "the shared app" }, { trace: session?.trace, sessionError: outcome?.sessionError, inProgress }),
70
71
  startedAt: args.run.createdAt,
71
72
  updatedAt: args.run.createdAt,
73
+ ...(args.plan.arrivals.declared
74
+ ? {
75
+ arrival: arrivalRecord(spec.planned.startAfterMs, actorArrival(args.actorResults[index])),
76
+ }
77
+ : {}),
72
78
  });
73
79
  }
80
+ // A follower the handoff stopped before it opened the app never started its desktop.
81
+ function actorArrival(result) {
82
+ if (result === undefined)
83
+ return undefined;
84
+ return result.outcome.skippedReason === undefined
85
+ ? { scheduledAt: result.scheduledAt, startedAt: result.startedAt }
86
+ : { scheduledAt: result.scheduledAt };
87
+ }
74
88
  function sharedWorldStream(ctx, spec, index, view) {
75
89
  const { args } = ctx;
76
90
  const { name, session, screenshots, lastScreenshot, desktopGeometry, screenshotMode } = view;
@@ -18,6 +18,7 @@ import { sharedWorldParticipantRecords } from "./bundle-records.js";
18
18
  import { CONCURRENT_ATTRIBUTION_LIMITS, EXTERNAL_PUBLIC_ATTRIBUTION_LIMITS, } from "./types.js";
19
19
  import { gapsListClause } from "../../run/outcomes.js";
20
20
  import { plural } from "../../run/text.js";
21
+ import { describeArrivals } from "../../study/arrivals.js";
21
22
  /** Max windows live at the same instant (sweep over start/end points). The simultaneity
22
23
  * count: the participant count says how many existed; this says how many ever ran at once. */
23
24
  export function maxSimultaneousWindows(windows) {
@@ -107,7 +108,7 @@ function planeEvents(args, external) {
107
108
  at: createdAt,
108
109
  level: "info",
109
110
  type: "concurrent-shared-world.run.created",
110
- message: `Created a concurrent shared-world run for ${plan.studyId} (actor ${descriptor.id}, ${plural(actorSpecs.length, "persona")} on one shared app, at most ${plan.concurrency} at once).`,
111
+ message: `Created a concurrent shared-world run for ${plan.studyId} (actor ${descriptor.id}, ${plural(actorSpecs.length, "persona")} on one shared app, at most ${plan.concurrency} at once).${plan.arrivals.declared ? ` Schedule: ${describeArrivals(plan.arrivals)}` : ""}`,
111
112
  });
112
113
  // Human-readable plane label, byte-stable for the clone route. local-tree has no repo slug: it
113
114
  // labels the packed archive instead (archiveSha256 + dirty/clean when the packed root was a git
@@ -6,7 +6,7 @@
6
6
  // deadline.
7
7
  import { liveObserverResult } from "../../observer/live.js";
8
8
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
9
- import { mapWithConcurrency } from "../../run/concurrency.js";
9
+ import { runOnSchedule } from "../../study/arrivals.js";
10
10
  import { buildConcurrentSharedWorldBundle, judgeSharedWorldRun } from "./bundle.js";
11
11
  import { drainExternalComms } from "./comms.js";
12
12
  import { LobbyHandoff, runFollower, runHost } from "./handoff.js";
@@ -95,10 +95,22 @@ export async function runExternalPublicPlane(ctx, live, inbox) {
95
95
  .filter(({ index }) => index !== hostIndex);
96
96
  const orderedResults = new Array(actorSpecs.length);
97
97
  try {
98
+ // The host starts with the run (parse refuses a later start for it); the followers start on
99
+ // the study's schedule.
98
100
  const hostPromise = hostIndex >= 0 && actorSpecs[hostIndex] !== undefined
99
- ? runHost(handoff, deps, actorSpecs[hostIndex], hostIndex)
101
+ ? runHost(handoff, deps, actorSpecs[hostIndex], hostIndex).then((result) => ({
102
+ ...result,
103
+ scheduledAt: result.startedAt,
104
+ }))
100
105
  : undefined;
101
- const followerResultsPromise = mapWithConcurrency(followerEntries, Math.max(1, concurrency - 1), ({ spec, index }) => runFollower(handoff, deps, spec, index));
106
+ const followerResultsPromise = runOnSchedule(followerEntries, {
107
+ startAfterMs: ({ spec }) => spec.planned.startAfterMs,
108
+ slots: Math.max(1, concurrency - 1),
109
+ now: ctx.now,
110
+ }, async ({ spec, index }, _position, { scheduledAt }) => ({
111
+ ...(await runFollower(handoff, deps, spec, index)),
112
+ scheduledAt,
113
+ }));
102
114
  const [hostResult, followerResults] = await Promise.all([hostPromise, followerResultsPromise]);
103
115
  if (hostResult !== undefined && hostIndex >= 0) {
104
116
  orderedResults[hostIndex] = hostResult;
@@ -59,5 +59,5 @@ export declare class LobbyHandoff {
59
59
  lobbyConvergenceDigest: string | undefined;
60
60
  };
61
61
  }
62
- export declare function runHost(handoff: LobbyHandoff, deps: HandoffParticipantDeps, spec: DesktopParticipantRun, participantIndex: number): Promise<ActorRunResult>;
63
- export declare function runFollower(handoff: LobbyHandoff, deps: HandoffParticipantDeps, spec: DesktopParticipantRun, participantIndex: number): Promise<ActorRunResult>;
62
+ export declare function runHost(handoff: LobbyHandoff, deps: HandoffParticipantDeps, spec: DesktopParticipantRun, participantIndex: number): Promise<Omit<ActorRunResult, "scheduledAt">>;
63
+ export declare function runFollower(handoff: LobbyHandoff, deps: HandoffParticipantDeps, spec: DesktopParticipantRun, participantIndex: number): Promise<Omit<ActorRunResult, "scheduledAt">>;
@@ -11,7 +11,7 @@ import { desktopParticipantDeps, } from "../computer-use/participant-deps.js";
11
11
  import { resolveCommittedPersonasForCwd } from "../../study/persona-resolve.js";
12
12
  import { participantAssignment } from "../../study/participant-assignment.js";
13
13
  import { redactText } from "../../evidence/redaction.js";
14
- import { SANDBOX_TIMEOUT_BUFFER_MS } from "../../substrates/e2b/lifetime.js";
14
+ import { sandboxHeadroomMs } from "../../substrates/e2b/lifetime.js";
15
15
  // prose-check: model prompt (the participant model reads this, not a person)
16
16
  const DEFAULT_MISSION = "You are one of MANY users hitting a shared web application at the same time. The browser is already open at the app. Accomplish your role's task, then stop.";
17
17
  /** Resolve an actor's participant URL against the harness-minted getHost base (entry is a
@@ -134,7 +134,7 @@ export function participantRunDeps(ctx, live, scrubKnownValues) {
134
134
  openaiApiKey: ctx.openaiApiKey,
135
135
  e2bApiKey: ctx.e2bApiKey,
136
136
  timeoutMs: ctx.timeoutMs,
137
- sandboxMs: ctx.timeoutMs + SANDBOX_TIMEOUT_BUFFER_MS,
137
+ sandboxMs: ctx.timeoutMs + sandboxHeadroomMs(),
138
138
  participantCount: plan.plane.participants.length,
139
139
  artifactRoot: ctx.runPaths,
140
140
  studyCwd: ctx.cwd,
@@ -1,7 +1,7 @@
1
1
  import { type CuaActorDescriptor } from "../../actors/registry.js";
2
2
  import type { RoutePlanResult, RouteRefusal, SharedWorldPlan } from "../../study/plan-types.js";
3
3
  import type { StudyConfig, StudySubjectState } from "../../study/types.js";
4
- import { type SandboxCeiling } from "../../substrates/e2b/lifetime.js";
4
+ import { type ConcurrentSandboxes, type SandboxCeiling } from "../../substrates/e2b/lifetime.js";
5
5
  import type { ConcurrentSharedWorldStudyErrorCode } from "./types.js";
6
6
  /** The error a shared-world study returns before a run starts. */
7
7
  export interface SharedWorldRefusal extends RouteRefusal<"shared-world", ConcurrentSharedWorldStudyErrorCode> {
@@ -21,6 +21,15 @@ export declare function planSharedWorldStudy(config: StudyConfig, input: {
21
21
  readonly hasRunSession?: boolean;
22
22
  /** The longest sandbox lifetime the operator's E2B plan allows (sandboxCeiling). */
23
23
  readonly sandboxCeiling: SandboxCeiling;
24
+ /** How many sandboxes the operator's E2B plan runs at once (concurrentSandboxes). */
25
+ readonly concurrentSandboxes: ConcurrentSandboxes;
24
26
  }): SharedWorldPlanResult;
27
+ /**
28
+ * The schedule lines a shared-world run prints before it creates anything, when the study declares
29
+ * `participants[].startAfterMs`: the schedule, and the worst case in sandbox-minutes, which counts
30
+ * every participant's sandbox (its session plus the teardown buffer) and on the provisioned plane
31
+ * the app's sandbox until the last participant ends.
32
+ */
33
+ export declare function sharedWorldScheduleLines(plan: SharedWorldPlan): string;
25
34
  /** The provisioned plane's declared subject state. The external-public plane declares none. */
26
35
  export declare function planeStateOf(plan: SharedWorldPlan): StudySubjectState | undefined;
@@ -12,8 +12,9 @@ import { PUBLIC_TARGET_OWNER_PATTERN } from "../../study/parse/subject.js";
12
12
  import { REPO_SLUG_PATTERN } from "../../study/parse/values.js";
13
13
  import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
14
14
  import { unpricedCapCheck } from "../../study/requirements.js";
15
- import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
16
- import { SANDBOX_TIMEOUT_BUFFER_MS, SUBJECT_PROVISION_BUDGET_MS, } from "../../substrates/e2b/lifetime.js";
15
+ import { participantsAtOnce, sandboxDeadlineRefusal, sandboxHeadroomMs, } from "../../substrates/e2b/lifetime.js";
16
+ import { boundedConcurrency } from "../computer-use/participant-runs.js";
17
+ import { describeArrivals, planStarts, servedScheduleRefusal, } from "../../study/arrivals.js";
17
18
  /** The registered descriptor for a planned actor; planSharedWorldStudy checked the registry. */
18
19
  export function sharedWorldDescriptorOf(actorType) {
19
20
  const descriptor = actorRegistry[actorType];
@@ -96,22 +97,32 @@ export function planSharedWorldStudy(config, input) {
96
97
  const plane = planeOf(config);
97
98
  if (plane === undefined)
98
99
  throw new Error("shared-world validation admitted a plane it cannot plan");
100
+ const sessionTimeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(plane, ceiling.ms);
101
+ const starts = startsOf(plane, config.execution?.concurrency, input.concurrentSandboxes, sessionTimeoutMs);
102
+ if (!starts.ok)
103
+ return refuse(invalid, starts.message, actor);
104
+ const { arrivals, warnings } = starts;
105
+ const deadlineReason = deadlineReasonOf(plane, arrivals, {
106
+ sessionDeclared: config.execution?.timeoutMs !== undefined,
107
+ ceilingMs: ceiling.ms,
108
+ });
109
+ if (deadlineReason)
110
+ return refuse(invalid, deadlineReason, actor);
99
111
  const brain = brainOf(config, false);
100
112
  if (brain.kind === "caller")
101
113
  throw new Error("a shared-world participant has no caller brain");
102
- const base = planBase(config, {
103
- dryRun: input.dryRun,
104
- analysis,
105
- });
114
+ const base = planBase(config, { dryRun: input.dryRun, analysis });
106
115
  return {
107
116
  ok: true,
108
117
  plan: {
109
118
  ...base,
119
+ ...(warnings.length === 0 ? {} : { warnings }),
110
120
  route: "shared-world",
111
121
  actor,
112
122
  plane,
113
- concurrency: config.execution?.concurrency ?? plane.participants.length,
114
- sessionTimeoutMs: config.execution?.timeoutMs ?? defaultSessionTimeoutMs(plane, ceiling.ms),
123
+ concurrency: starts.count,
124
+ arrivals,
125
+ sessionTimeoutMs,
115
126
  brain,
116
127
  caps: planCaps(config),
117
128
  requirements: base.dryRun
@@ -132,6 +143,72 @@ export function planSharedWorldStudy(config, input) {
132
143
  },
133
144
  };
134
145
  }
146
+ /**
147
+ * How many participants run at once, and when each one starts. Every participant's desktop and, on
148
+ * the provisioned plane, the app's own sandbox share the E2B plan's concurrent sandboxes, and a
149
+ * shared world needs two participants live together. On the external-public plane the host runs
150
+ * on a slot of its own and the others queue for the rest (external-public.ts).
151
+ */
152
+ function startsOf(plane, declared, limit, sessionMs) {
153
+ const n = plane.participants.length;
154
+ const atOnce = participantsAtOnce({
155
+ concurrency: boundedConcurrency(declared, n),
156
+ declared: declared !== undefined,
157
+ participants: n,
158
+ alongside: plane.kind === "provisioned" ? 1 : 0,
159
+ minimum: 2,
160
+ }, limit);
161
+ if (!atOnce.ok)
162
+ return atOnce;
163
+ const hostIndex = plane.participants.findIndex((participant) => participant.host === true);
164
+ const queue = {
165
+ slots: atOnce.count,
166
+ sessionMs,
167
+ ...(plane.kind === "external-public" && hostIndex >= 0 ? { ownSlot: hostIndex } : {}),
168
+ };
169
+ return {
170
+ ok: true,
171
+ count: atOnce.count,
172
+ ...planStarts(plane.participants, queue, atOnce.lowered),
173
+ };
174
+ }
175
+ /**
176
+ * Why the run's longest-lived sandbox cannot live as long as the plan needs. On the provisioned
177
+ * plane that is the subject sandbox, which serves the app for one session and then until the last
178
+ * participant ends; on the external-public plane, each participant's own sandbox, created at its
179
+ * start.
180
+ */
181
+ function deadlineReasonOf(plane, arrivals, options) {
182
+ const { sessionDeclared, ceilingMs } = options;
183
+ const sessionMs = arrivals.sessionMs;
184
+ if (plane.kind !== "provisioned")
185
+ return sandboxDeadlineRefusal({ name: "each participant's sandbox", sessionMs, sessionDeclared }, ceilingMs);
186
+ const seed = plane.subject.state.seed ?? [];
187
+ return (sandboxDeadlineRefusal({
188
+ name: "the subject sandbox, which serves the app until every participant ends",
189
+ sessionMs,
190
+ sessionDeclared,
191
+ servedSubject: { seed },
192
+ }, ceilingMs) ?? servedScheduleRefusal(arrivals, { sessionMs, seed }, ceilingMs));
193
+ }
194
+ /**
195
+ * The schedule lines a shared-world run prints before it creates anything, when the study declares
196
+ * `participants[].startAfterMs`: the schedule, and the worst case in sandbox-minutes, which counts
197
+ * every participant's sandbox (its session plus the teardown buffer) and on the provisioned plane
198
+ * the app's sandbox until the last participant ends.
199
+ */
200
+ export function sharedWorldScheduleLines(plan) {
201
+ const participants = plan.plane.participants.length;
202
+ const participantMs = participants * (plan.sessionTimeoutMs + sandboxHeadroomMs());
203
+ const subjectMs = plan.plane.kind === "provisioned"
204
+ ? plan.arrivals.lastEndMs + sandboxHeadroomMs({ seed: plan.plane.subject.state.seed ?? [] })
205
+ : 0;
206
+ return [
207
+ `humanish shared-world plan (${plan.studyId}): ${participants} participants, at most ${plan.concurrency} at once; worst-case ~${Math.round((participantMs + subjectMs) / 60_000)} sandbox-minutes total${plan.dryRun ? " (dry-run: $0)" : ""}.`,
208
+ ` schedule: ${describeArrivals(plan.arrivals)}`,
209
+ "",
210
+ ].join("\n");
211
+ }
135
212
  // The default per-participant session budget is derived from the plane. On a provisioned plane the
136
213
  // binding constraint is the subject sandbox (it must outlive every participant: timeoutMs +
137
214
  // provisioning + seeding + teardown buffer, under the sandbox ceiling), so the derivation hands each
@@ -145,8 +222,7 @@ const DEFAULT_APP_URL_SESSION_MS = 30 * 60_000;
145
222
  function defaultSessionTimeoutMs(plane, ceilingMs) {
146
223
  if (plane.kind !== "provisioned")
147
224
  return DEFAULT_APP_URL_SESSION_MS;
148
- const stateBudgetMs = (plane.subject.state.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0);
149
- const room = ceilingMs - SUBJECT_PROVISION_BUDGET_MS - stateBudgetMs - SANDBOX_TIMEOUT_BUFFER_MS;
225
+ const room = ceilingMs - sandboxHeadroomMs({ seed: plane.subject.state.seed ?? [] });
150
226
  return Math.max(MIN_DERIVED_SESSION_MS, Math.min(MAX_DERIVED_SESSION_MS, room));
151
227
  }
152
228
  /** The provisioned plane's declared subject state. The external-public plane declares none. */
@@ -6,7 +6,7 @@ import { buildOriginMap } from "../../comms/capture-surface.js";
6
6
  import { deployCommsCatch, refreshInboxSurface, writeInboxSurface, } from "../../comms/sandbox-catch.js";
7
7
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
8
8
  import { liveObserverResult } from "../../observer/live.js";
9
- import { mapWithConcurrency } from "../../run/concurrency.js";
9
+ import { runOnSchedule } from "../../study/arrivals.js";
10
10
  import { provisionCloneSubject } from "../../subject/clone.js";
11
11
  import { provisionLocalTreeSubject } from "../../subject/local-tree.js";
12
12
  import { defaultSharedWorldPhaseSink } from "../../subject/steps.js";
@@ -96,7 +96,7 @@ class SubjectPlane {
96
96
  this.setup.stateSnapshots.push({ timestamp, digest: snapshot.digest });
97
97
  }
98
98
  async acquire() {
99
- const { plan, deps, env, requestTimeoutMs, timeoutMs } = this.ctx;
99
+ const { plan, deps, env, requestTimeoutMs } = this.ctx;
100
100
  const { subjectEnvNames, commsEnv } = this.setup;
101
101
  const subjectModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
102
102
  // The one subject sandbox: headless service host (no GUI participant). The subject env is
@@ -107,7 +107,9 @@ class SubjectPlane {
107
107
  module: subjectModule,
108
108
  apiKey: this.ctx.e2bApiKey,
109
109
  requestTimeoutMs,
110
- sessionTimeoutMs: timeoutMs,
110
+ // The app serves until the last participant ends, which a late start or a wait for a free
111
+ // slot puts past one session.
112
+ sessionTimeoutMs: plan.arrivals.lastEndMs,
111
113
  seed: planeStateOf(plan)?.seed ?? [],
112
114
  metadata: {
113
115
  ...CONCURRENT_SHARED_WORLD_PROVIDER_METADATA,
@@ -380,15 +382,20 @@ async function publishInProgress(plane, ctx, live, setup) {
380
382
  }
381
383
  startParticipantFlush(ctx, live, inProgressBundle);
382
384
  }
383
- // Launch N actor sandboxes concurrently and independently (runCuaParticipant + mapWithConcurrency;
384
- // runCuaParticipants would add a pipeline gate and fail-fast). Each actor's window is measured on the
385
- // one orchestrator clock.
385
+ // Launch N actor sandboxes independently on the study's schedule (runCuaParticipant +
386
+ // runOnSchedule; runCuaParticipants would add a pipeline gate and fail-fast). Each actor's window is
387
+ // measured on the one orchestrator clock.
386
388
  function runParticipants(plane, ctx, live, setup) {
387
389
  const { now } = ctx;
388
390
  const participants = ctx.plan.plane.participants;
389
391
  const { commsEmail } = setup;
390
392
  const baseActorDeps = participantRunDeps(ctx, live, ctx.scrubKnownValues);
391
- return mapWithConcurrency(ctx.actorSpecs, Math.max(1, ctx.concurrency), async (spec, i) => {
393
+ const schedule = {
394
+ startAfterMs: (spec) => spec.planned.startAfterMs,
395
+ slots: Math.max(1, ctx.concurrency),
396
+ now,
397
+ };
398
+ return runOnSchedule(ctx.actorSpecs, schedule, async (spec, i, { scheduledAt }) => {
392
399
  const route = resolveActorEntryUrl(plane.getHostUrl, participants[i]?.entry);
393
400
  // Tell this persona its (getHost-reachable) inbox URL, but only when comms is live and this participant
394
401
  // has a declared recipient it can actually receive mail into (else it would stall on an empty
@@ -400,7 +407,7 @@ function runParticipants(plane, ctx, live, setup) {
400
407
  const startedAt = now();
401
408
  const outcome = await runCuaParticipant(actorSpec, { ...baseActorDeps, appUrl: route });
402
409
  const endedAt = now();
403
- return { spec, outcome, startedAt, endedAt, route };
410
+ return { spec, outcome, scheduledAt, startedAt, endedAt, route };
404
411
  });
405
412
  }
406
413
  export async function runProvisionedPlane(ctx, live, setup) {
@@ -23,7 +23,7 @@
23
23
  // per-delta→actor field, so causation under concurrency is structurally inexpressible.
24
24
  //
25
25
  // Capability and proof: the deterministic $0 gate proves the plumbing + the claims-match-mechanism
26
- // contract: the real mapWithConcurrency produces genuinely overlapping laneWindows (a rendezvous
26
+ // contract: the real start queue (runOnSchedule) produces genuinely overlapping laneWindows (a rendezvous
27
27
  // latch in the fake session forces two participant fns in-flight while the real orchestrator clock
28
28
  // measures the windows). Every generated bundle describes only its own observations; no one run
29
29
  // establishes scale, repeatability, or adopter-harness replacement.
@@ -38,7 +38,7 @@ import {} from "../../run/run.js";
38
38
  import { RunSecrets } from "../../run/secrets.js";
39
39
  import { admitRoute, completeRefusalAnalysis, } from "../../run/route-shell.js";
40
40
  import { runExternalPublicPlane } from "./external-public.js";
41
- import { sharedWorldDescriptorOf } from "./plan.js";
41
+ import { sharedWorldDescriptorOf, sharedWorldScheduleLines, } from "./plan.js";
42
42
  import { runProvisionedPlane } from "./provisioned.js";
43
43
  import { concurrentStudyFailure, finishConcurrentRun } from "./result.js";
44
44
  import { prepareConcurrentRun } from "./setup.js";
@@ -62,8 +62,8 @@ export function sharedWorldStudyRefusal(options, refusal) {
62
62
  studyId: config.id,
63
63
  actor: config.actor?.type ?? "",
64
64
  participantCount: declared.length,
65
- // The parser fills concurrency for multi-participant studies, so this fallback serves only
66
- // library callers: every declared participant runs at once unless the author declared a cap.
65
+ // A refused study never planned its concurrency, so its result reports the declared cap, else
66
+ // every declared participant.
67
67
  concurrency: config.execution?.concurrency ?? Math.max(1, declared.length),
68
68
  dryRun,
69
69
  runId: options.runId,
@@ -143,6 +143,9 @@ async function runPlanInScope(plan, input, config, scope) {
143
143
  const deps = input.deps ?? {};
144
144
  const env = input.env ?? process.env;
145
145
  const fail = sharedWorldFailure(plan, input);
146
+ // Before any sandbox or provider call, as computer use prints its participant plan.
147
+ if (plan.arrivals.declared)
148
+ process.stderr.write(sharedWorldScheduleLines(plan));
146
149
  const descriptor = sharedWorldDescriptorOf(plan.actor);
147
150
  const planeClass = plan.plane.kind === "external-public" ? "external-public" : "provisioned-getHost";
148
151
  // The provisioned plane's subject; the external-public plane has none.
@@ -138,6 +138,8 @@ export interface ConcurrentSharedWorldStudyResult extends AutomaticAnalysisResul
138
138
  export interface ActorRunResult {
139
139
  spec: DesktopParticipantRun;
140
140
  outcome: ParticipantRunOutcome;
141
+ /** When the schedule had it start, in epoch ms; the host of an external public app starts with the run. */
142
+ scheduledAt: number;
141
143
  startedAt: number;
142
144
  endedAt: number;
143
145
  route: string;
@@ -216,7 +216,8 @@ function isRunSubjectProvenance(value) {
216
216
  if (value.source !== "clone" &&
217
217
  value.source !== "app-url" &&
218
218
  value.source !== "local-tree" &&
219
- value.source !== "desktop-cli")
219
+ value.source !== "desktop-cli" &&
220
+ value.source !== "local-app")
220
221
  return false;
221
222
  if (value.repo !== undefined && typeof value.repo !== "string")
222
223
  return false;
@@ -312,7 +313,16 @@ function isRunSimulation(value) {
312
313
  Array.isArray(value.streamIds) &&
313
314
  value.streamIds.every((streamId) => typeof streamId === "string") &&
314
315
  typeof value.startedAt === "string" &&
315
- typeof value.updatedAt === "string");
316
+ typeof value.updatedAt === "string" &&
317
+ (value.arrival === undefined || isRunParticipantArrival(value.arrival)));
318
+ }
319
+ function isRunParticipantArrival(value) {
320
+ return (isRecord(value) &&
321
+ typeof value.startAfterMs === "number" &&
322
+ Number.isSafeInteger(value.startAfterMs) &&
323
+ value.startAfterMs >= 0 &&
324
+ (value.scheduledAt === undefined || typeof value.scheduledAt === "string") &&
325
+ (value.startedAt === undefined || typeof value.startedAt === "string"));
316
326
  }
317
327
  function isRunAdapterArtifact(value) {
318
328
  return (isRecord(value) &&
@@ -123,6 +123,21 @@ export interface RunSimulation {
123
123
  streamIds: string[];
124
124
  startedAt: string;
125
125
  updatedAt: string;
126
+ /**
127
+ * When a computer-use or shared-world participant was due and when it started, in a study that
128
+ * declares `participants[].startAfterMs`. `startAfterMs` is its offset (0 when its entry declares
129
+ * none), `scheduledAt` the moment the run started its participants plus that offset, and
130
+ * `startedAt` when its desktop was requested: later than `scheduledAt` when it waited for a free
131
+ * slot. A dry run records only the offset, and a participant that never started has no
132
+ * `startedAt`. The record's own `startedAt` above is the run's creation time.
133
+ */
134
+ arrival?: RunParticipantArrival;
135
+ }
136
+ /** A participant record's `arrival`. */
137
+ export interface RunParticipantArrival {
138
+ startAfterMs: number;
139
+ scheduledAt?: string;
140
+ startedAt?: string;
126
141
  }
127
142
  export interface RunEvent {
128
143
  id: string;
@@ -158,7 +173,8 @@ export interface RunSubjectStateStepRecord {
158
173
  * pre-existing bundles).
159
174
  */
160
175
  export interface RunSubjectProvenance {
161
- source: "clone" | "app-url" | "local-tree" | "desktop-cli";
176
+ /** A run saved before `desktop-cli` or `local-app` was recorded says `app-url` for either. */
177
+ source: "clone" | "app-url" | "local-tree" | "desktop-cli" | "local-app";
162
178
  /** Clone-route only. Honors policies.redactRepos exactly as the provenance event does. */
163
179
  repo?: string;
164
180
  /** Desktop-cli only: the product the study declares in `subject.product.name`. */
@@ -186,8 +202,9 @@ export interface RunSubjectProvenance {
186
202
  * an external DB is still unpinned overall).
187
203
  * declared-not-run: steps declared but not (all) executed ok: dry-run contract bundles
188
204
  * and failed live provisioning.
189
- * undeclared: no subject.state block (stateless apps, app-url and desktop-cli subjects),
190
- * the explicit "absence declared" marker that provenance requires when there is none.
205
+ * undeclared: no subject.state block (stateless apps, app-url, local-app and desktop-cli
206
+ * subjects), the explicit "absence declared" marker that provenance requires when there
207
+ * is none.
191
208
  * external-public: an operator-declared, operator-owned public deployment used
192
209
  * directly as the shared plane; humanish neither provisioned nor seeded it (no getHost, no
193
210
  * clone, no in-sandbox filesystem). It is not "seeded" (nothing was seeded) and not "unpinned" (this is
@@ -29,6 +29,7 @@ export function participantRecord(ids, place, fields) {
29
29
  streamIds: [ids.streamId],
30
30
  startedAt: fields.startedAt,
31
31
  updatedAt: fields.updatedAt,
32
+ ...(fields.arrival === undefined ? {} : { arrival: fields.arrival }),
32
33
  };
33
34
  }
34
35
  /**
@@ -7,6 +7,7 @@
7
7
  // - an unknown cost is `null` (declared absent), never 0;
8
8
  // - truncation is reported, never silent;
9
9
  // - a participant's reported thinking is quoted, never paraphrased or summarized.
10
+ import { indexedRunCost } from "./run-cost.js";
10
11
  /**
11
12
  * Group runs by study, newest-first within each study and by recency between studies. Runs with no study
12
13
  * attribution are collected under `unattributed` rather than invented into a study. That is the
@@ -74,9 +75,13 @@ export function expectationFor(entries, mode) {
74
75
  .map((entry) => entry.durationMs)
75
76
  .filter((value) => typeof value === "number" && Number.isFinite(value) && value >= 0)
76
77
  .sort((a, b) => a - b);
78
+ // Each run's cost is the one its row and card show (src/run/run-cost.ts): participants and
79
+ // desktops plus its analyses. A run whose participants and desktops have no figure stays
80
+ // unpriced below, so an analysis alone never stands in for what a run cost.
77
81
  const costs = finished
78
- .map((entry) => entry.estimatedCostUsd)
79
- .filter((value) => typeof value === "number" && Number.isFinite(value))
82
+ .filter((entry) => typeof entry.estimatedCostUsd === "number" && Number.isFinite(entry.estimatedCostUsd))
83
+ .map((entry) => indexedRunCost(entry).total?.usd)
84
+ .filter((value) => value !== undefined)
80
85
  .sort((a, b) => a - b);
81
86
  // A cost is unknown whether it was declared absent (`null`) or never recorded at all
82
87
  // (`undefined`: every fail-closed exit finalized with no outcome). Counting only the first
package/dist/run/run.js CHANGED
@@ -117,13 +117,15 @@ function observerTarget(options) {
117
117
  function runPublisher(args) {
118
118
  const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
119
119
  const observer = observerTarget(options);
120
- // The study's warnings join whatever events the route wrote, once, in every write.
120
+ // The study's warnings join whatever events the route wrote, once, in every write. A warning can
121
+ // quote the study file, which may name a sandbox URL a participant needs, so each goes through
122
+ // the redaction a run failure gets.
121
123
  const studyWarnings = (options.warnings ?? []).map((message, index) => ({
122
124
  id: `event-study-warning-${String(index + 1).padStart(3, "0")}`,
123
125
  at: createdAt,
124
126
  level: "warn",
125
127
  type: "study.warning",
126
- message,
128
+ message: redactText(message),
127
129
  }));
128
130
  const withStudyWarnings = (events) => [
129
131
  ...events.filter((event) => !studyWarnings.some((warning) => warning.id === event.id)),
@@ -0,0 +1,11 @@
1
+ /** A subject as a run records it: its source and, for a product used at a desktop, the product. */
2
+ interface NamedSubject {
3
+ readonly source: string;
4
+ readonly product?: string | undefined;
5
+ }
6
+ /**
7
+ * The URL a participant opens names the subject. A product used at a desktop has no URL, so it is
8
+ * named with its source, as in `humanish (desktop-cli)`. Empty when there is neither.
9
+ */
10
+ export declare function subjectName(url: string, subject: NamedSubject): string;
11
+ export {};
@@ -0,0 +1,11 @@
1
+ // The words a reader sees for a run's subject. The `subject:` line of `humanish run`, the run's
2
+ // summary and each participant's sentence name it the same way.
3
+ /**
4
+ * The URL a participant opens names the subject. A product used at a desktop has no URL, so it is
5
+ * named with its source, as in `humanish (desktop-cli)`. Empty when there is neither.
6
+ */
7
+ export function subjectName(url, subject) {
8
+ if (url !== "" || subject.product === undefined)
9
+ return url;
10
+ return `${subject.product} (${subject.source})`;
11
+ }