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