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.
- package/CHANGELOG.md +30 -0
- package/dist/actors/computer-use/actor.d.ts +3 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
- package/dist/actors/computer-use/loop/backstop.js +13 -1
- package/dist/actors/computer-use/loop/clock.d.ts +20 -0
- package/dist/actors/computer-use/loop/clock.js +64 -0
- package/dist/actors/computer-use/loop/ending.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.js +2 -1
- package/dist/actors/computer-use/loop/types.d.ts +16 -0
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +43 -8
- package/dist/actors/computer-use/wait.d.ts +3 -2
- package/dist/actors/computer-use/wait.js +5 -4
- package/dist/actors/contract.d.ts +3 -3
- package/dist/actors/contract.js +3 -0
- package/dist/actors/stop-cause.js +1 -0
- package/dist/cli/commands/study-format.js +25 -4
- package/dist/routes/computer-use/bundle.d.ts +3 -1
- package/dist/routes/computer-use/bundle.js +2 -1
- package/dist/routes/computer-use/diagnostics.d.ts +1 -1
- package/dist/routes/computer-use/diagnostics.js +4 -0
- package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
- package/dist/routes/computer-use/fanout-bundle.js +22 -14
- package/dist/routes/computer-use/live-phase.d.ts +1 -2
- package/dist/routes/computer-use/live-phase.js +2 -4
- package/dist/routes/computer-use/participant-deps.d.ts +1 -1
- package/dist/routes/computer-use/participant-deps.js +3 -1
- package/dist/routes/computer-use/participant-execution.d.ts +9 -14
- package/dist/routes/computer-use/participant-execution.js +102 -89
- package/dist/routes/computer-use/participant-model.js +17 -0
- package/dist/routes/computer-use/participant-runs.d.ts +11 -1
- package/dist/routes/computer-use/participant-runs.js +31 -4
- package/dist/routes/computer-use/plan.js +8 -1
- package/dist/routes/computer-use/result.js +57 -9
- package/dist/routes/computer-use/setup.d.ts +1 -1
- package/dist/routes/computer-use/single-bundle.d.ts +3 -0
- package/dist/routes/computer-use/single-bundle.js +3 -2
- package/dist/routes/computer-use/types.d.ts +33 -7
- package/dist/routes/shared-world/provisioned.js +2 -2
- package/dist/run/host-suspension.d.ts +56 -0
- package/dist/run/host-suspension.js +106 -0
- package/dist/run/judge.d.ts +1 -1
- package/dist/run/outcomes.js +3 -1
- package/dist/run/run.d.ts +15 -2
- package/dist/run/run.js +15 -8
- package/dist/run/status.d.ts +8 -2
- package/dist/run/status.js +11 -8
- package/dist/study/arrivals.d.ts +52 -5
- package/dist/study/arrivals.js +105 -15
- package/dist/study/keys.d.ts +6 -0
- package/dist/study/keys.js +4 -0
- package/dist/study/migrate/v2.js +8 -5
- package/dist/study/parse/actors.d.ts +1 -1
- package/dist/study/parse/actors.js +33 -4
- package/dist/study/plan-participants.d.ts +10 -1
- package/dist/study/plan-participants.js +10 -1
- package/dist/study/preflight.js +11 -1
- package/dist/study/study-deps.d.ts +6 -0
- package/dist/study/types.d.ts +26 -0
- package/dist/study/validation.d.ts +3 -2
- package/dist/study/validation.js +22 -4
- package/dist/tui-app.js +35 -35
- package/docs/contracts/run-bundle.md +20 -1
- package/docs/contracts/schemas.md +24 -3
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { adapterScoreFailureMessage, applyBrowserScorer } from "../../study/adapter-extension.js";
|
|
2
2
|
import { redactText } from "../../evidence/redaction.js";
|
|
3
|
-
import { foldScorerFailures, judgeExecution, judgeParticipantRecords, OUTCOME_POLICIES, resultOk, } from "../../run/judge.js";
|
|
3
|
+
import { foldScorerFailures, judgeExecution, judgeParticipantRecords, OUTCOME_POLICIES, participantHarnessFailed, resultOk, } from "../../run/judge.js";
|
|
4
|
+
import { readHostSuspensions, } from "../../run/host-suspension.js";
|
|
4
5
|
import { validatePreparedRunArtifactPaths } from "../../run/paths.js";
|
|
5
6
|
import { participantExecutionFailures, participantOutcomeOk, participantFactsOf, } from "./participant-facts.js";
|
|
6
7
|
import { participantCapWarning } from "./participant-model.js";
|
|
@@ -53,13 +54,39 @@ function buildParticipantSummary(outcomes, participantCount, participantPlan, dr
|
|
|
53
54
|
waves: participantPlan.waves,
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Each participant as the host-suspension reading reads it: a harness error, its session's time
|
|
59
|
+
* limit or a skip, when it started, and when its runner returned, which a setup failure with no
|
|
60
|
+
* session also records.
|
|
61
|
+
*/
|
|
62
|
+
function suspendedParticipants(outcomes) {
|
|
63
|
+
return outcomes.map((outcome) => {
|
|
64
|
+
const facts = participantFactsOf(outcome);
|
|
65
|
+
const trace = outcome.session?.trace;
|
|
66
|
+
const failure = facts.skipped
|
|
67
|
+
? "skipped"
|
|
68
|
+
: participantHarnessFailed(facts)
|
|
69
|
+
? "harness"
|
|
70
|
+
: trace?.stopCause === "time_limit"
|
|
71
|
+
? "time-limit"
|
|
72
|
+
: undefined;
|
|
73
|
+
const startedAtMs = outcome.arrival?.startedAt ?? (trace === undefined ? undefined : Date.parse(trace.startedAt));
|
|
74
|
+
const endedAtMs = outcome.endedAt ?? (trace === undefined ? undefined : Date.parse(trace.completedAt));
|
|
75
|
+
return {
|
|
76
|
+
id: outcome.spec.planned.id,
|
|
77
|
+
...(failure === undefined ? {} : { failure }),
|
|
78
|
+
...(startedAtMs === undefined ? {} : { startedAtMs }),
|
|
79
|
+
...(endedAtMs === undefined ? {} : { endedAtMs }),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
56
83
|
/**
|
|
57
84
|
* The computer-use study result for a finished run. The run passes only when the Observer rendered,
|
|
58
85
|
* every participant passed (dry-run participants pass as contracts), and no adapter score or declared scorer
|
|
59
86
|
* verdict failed; otherwise the error names the first reason.
|
|
60
87
|
*/
|
|
61
88
|
function cuaStudyResult(args) {
|
|
62
|
-
const { studyId, cwd, runId, appUrl, dryRun, participantRuns, outcomes, subjects, aggregateSubject, participantPlan, rerunLineage, bundle, observer, receivingWarnings, aggregateWarnings, adapterWarnings, } = args;
|
|
89
|
+
const { studyId, cwd, runId, appUrl, dryRun, participantRuns, outcomes, subjects, aggregateSubject, participantPlan, rerunLineage, bundle, observer, hostSuspension, receivingWarnings, aggregateWarnings, adapterWarnings, } = args;
|
|
63
90
|
const participantCount = participantRuns.length;
|
|
64
91
|
// Participant-level pass: dry-run participants are contract-ok; live ones need a passed,
|
|
65
92
|
// engaged session.
|
|
@@ -77,6 +104,10 @@ function cuaStudyResult(args) {
|
|
|
77
104
|
const participantResults = participantRuns.map((spec, index) => toParticipantResult(spec, outcomes?.[index], subjects[index], dryRun));
|
|
78
105
|
const summary = buildParticipantSummary(outcomes, participantCount, participantPlan, dryRun);
|
|
79
106
|
const firstOutcome = outcomes?.[0];
|
|
107
|
+
// A failure the host suspension likely caused names the suspension first.
|
|
108
|
+
const suspended = hostSuspension !== undefined && hostSuspension.participantIds.length > 0
|
|
109
|
+
? hostSuspension.summary
|
|
110
|
+
: undefined;
|
|
80
111
|
const errorResult = (() => {
|
|
81
112
|
if (ok)
|
|
82
113
|
return undefined;
|
|
@@ -87,24 +118,31 @@ function cuaStudyResult(args) {
|
|
|
87
118
|
const outcome = firstOutcome;
|
|
88
119
|
return {
|
|
89
120
|
code: outcome?.failureCode ?? "HUMANISH_COMPUTER_USE_FAILED",
|
|
90
|
-
message: outcome?.sessionError ??
|
|
121
|
+
message: `${suspended === undefined ? "" : `${suspended} `}${outcome?.sessionError ??
|
|
91
122
|
outcome?.providerCleanupError ??
|
|
92
123
|
outcome?.providerPolicyError ??
|
|
93
124
|
(outcome?.noEngagement || outcome?.selfReportedBlocker || observer.ok
|
|
94
125
|
? (participantResults[0]?.error?.message ??
|
|
95
126
|
judgeParticipantRecords([participantFactsOf(outcome)]).participants[0]
|
|
96
127
|
.notPassedMessage)
|
|
97
|
-
: (observer.error?.message ?? "Observer failed for the computer-use run."))
|
|
128
|
+
: (observer.error?.message ?? "Observer failed for the computer-use run."))}`,
|
|
98
129
|
};
|
|
99
130
|
}
|
|
100
131
|
const failing = (outcomes ?? []).find((outcome) => !participantOk(outcome));
|
|
132
|
+
const firstFailure = suspended !== undefined
|
|
133
|
+
? `; ${suspended.replace(/\.$/, "")}`
|
|
134
|
+
: failing?.sessionError !== undefined
|
|
135
|
+
? `; first failure: ${failing.sessionError}`
|
|
136
|
+
: failing === undefined && args.outcome.execution.failures[0] !== undefined
|
|
137
|
+
? `; ${args.outcome.execution.failures[0].message}`
|
|
138
|
+
: "";
|
|
101
139
|
// A participant whose desktop failed for a known cause gives the run that cause's code.
|
|
102
140
|
const codedFailure = (outcomes ?? []).find((outcome) => outcome.failureCode !== undefined);
|
|
103
141
|
const code = codedFailure?.failureCode ?? "HUMANISH_COMPUTER_USE_FAILED";
|
|
104
142
|
return {
|
|
105
143
|
code,
|
|
106
144
|
message: observer.ok
|
|
107
|
-
? `Fan-out run failed: ${summary.passed}/${plural(participantCount, "participant")} passed (${summary.skipped} skipped, ${plural(summary.harnessErrors, "harness error")}, ${summary.hollow} without engagement)${
|
|
145
|
+
? `Fan-out run failed: ${summary.passed}/${plural(participantCount, "participant")} passed (${summary.skipped} skipped, ${plural(summary.harnessErrors, "harness error")}, ${summary.hollow} without engagement)${firstFailure}.`
|
|
108
146
|
: (observer.error?.message ?? "Observer failed for the computer-use fan-out run."),
|
|
109
147
|
};
|
|
110
148
|
})();
|
|
@@ -149,6 +187,7 @@ function cuaStudyResult(args) {
|
|
|
149
187
|
laneSummary: summary,
|
|
150
188
|
...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
|
|
151
189
|
observer,
|
|
190
|
+
...(hostSuspension === undefined ? {} : { hostSuspension }),
|
|
152
191
|
warnings,
|
|
153
192
|
...(errorResult === undefined ? {} : { error: errorResult }),
|
|
154
193
|
};
|
|
@@ -162,7 +201,7 @@ export async function finishCuaRun(setup, finish, ran) {
|
|
|
162
201
|
const appUrl = plannedAppUrl(plan.runner.subject);
|
|
163
202
|
const { runId } = run;
|
|
164
203
|
const participantCount = participantRuns.length;
|
|
165
|
-
const { outcomes,
|
|
204
|
+
const { outcomes, receiving, receivingWarnings, externalCommsWarnings } = ran;
|
|
166
205
|
// Per-participant subject projections.
|
|
167
206
|
const subjects = projectParticipantSubjects({ ...subjectArgs, outcomes, dryRun });
|
|
168
207
|
const aggregate = aggregateCuaSubject({ subjects, outcomes, participantCount, dryRun });
|
|
@@ -175,13 +214,15 @@ export async function finishCuaRun(setup, finish, ran) {
|
|
|
175
214
|
];
|
|
176
215
|
// One judgment for the whole run: the bundle's verdict and the result's ok both read it.
|
|
177
216
|
const judgment = judgeComputerUseRun(bundleBase, { dryRun, outcomes });
|
|
217
|
+
// One reading of the host suspensions: the review, the outcome and the result all name it.
|
|
218
|
+
const hostSuspension = readHostSuspensions(run.hostSuspensions(), suspendedParticipants(outcomes ?? []), Date.parse(run.createdAt));
|
|
178
219
|
const bundle = buildCuaRunBundle(bundleBase, {
|
|
179
220
|
judgment,
|
|
180
221
|
dryRun,
|
|
181
222
|
outcomes,
|
|
182
223
|
subjects,
|
|
183
224
|
aggregateSubject,
|
|
184
|
-
...(
|
|
225
|
+
...(hostSuspension === undefined ? {} : { hostSuspension }),
|
|
185
226
|
});
|
|
186
227
|
const adapterWarnings = [];
|
|
187
228
|
const scorerResult = await applyBrowserScorer({
|
|
@@ -206,8 +247,14 @@ export async function finishCuaRun(setup, finish, ran) {
|
|
|
206
247
|
if (receiving)
|
|
207
248
|
bundle.commsReceiving = receiving.snapshot();
|
|
208
249
|
const policy = OUTCOME_POLICIES["computer-use"];
|
|
209
|
-
// FinishedRun.renderObserver adds an Observer that did not render.
|
|
210
|
-
|
|
250
|
+
// FinishedRun.renderObserver adds an Observer that did not render. A suspension that likely
|
|
251
|
+
// caused participant failures is the run's first failure, ahead of each one it explains.
|
|
252
|
+
const execution = judgeExecution([
|
|
253
|
+
...(hostSuspension !== undefined && hostSuspension.participantIds.length > 0
|
|
254
|
+
? [{ kind: "run", message: hostSuspension.summary }]
|
|
255
|
+
: []),
|
|
256
|
+
...participantExecutionFailures(outcomes, runId),
|
|
257
|
+
], policy);
|
|
211
258
|
const finished = await run.finish(bundle, {
|
|
212
259
|
ok: resultOk({ judgment, execution, scorerFailures: scorerResult.failures, policy }),
|
|
213
260
|
execution,
|
|
@@ -236,6 +283,7 @@ export async function finishCuaRun(setup, finish, ran) {
|
|
|
236
283
|
bundle,
|
|
237
284
|
outcome: finished.outcome,
|
|
238
285
|
observer,
|
|
286
|
+
hostSuspension,
|
|
239
287
|
receivingWarnings,
|
|
240
288
|
aggregateWarnings,
|
|
241
289
|
adapterWarnings,
|
|
@@ -38,7 +38,7 @@ export interface CuaRunSetup {
|
|
|
38
38
|
/** What only runStudyParticipants reads. */
|
|
39
39
|
export interface CuaParticipantsSetup {
|
|
40
40
|
env: Record<string, string | undefined>;
|
|
41
|
-
deps: Omit<CuaParticipantDeps, "
|
|
41
|
+
deps: Omit<CuaParticipantDeps, "signalReady">;
|
|
42
42
|
/** Filled by runStudyParticipants on a live run; deps.onTrace reads it. */
|
|
43
43
|
liveTrace: {
|
|
44
44
|
flush?: LiveTraceFlush["flush"];
|
|
@@ -7,6 +7,7 @@ import { type BundleRun, type RunBundle, type RunFeedbackCandidate, type RunProv
|
|
|
7
7
|
import { type RunDesktopGeometry, type RunStream } from "../../run/streams.js";
|
|
8
8
|
import { type Verdict } from "../../run/judge.js";
|
|
9
9
|
import { type DesktopUsage } from "../../run/cost-summary.js";
|
|
10
|
+
import type { HostSuspensionReading } from "../../run/host-suspension.js";
|
|
10
11
|
/**
|
|
11
12
|
* The bundle of a single-participant run: one participant and no rerun. buildCuaRunBundle
|
|
12
13
|
* (bundle.ts) maps the run's base and state into these arguments.
|
|
@@ -92,4 +93,6 @@ export declare function buildSingleParticipantBundle(args: {
|
|
|
92
93
|
* desktopDurationMs). Absent when no sandbox ran (in-process/dry-run) → no desktop cost line. */
|
|
93
94
|
desktopMinutes?: number;
|
|
94
95
|
desktopUsage?: DesktopUsage;
|
|
96
|
+
/** The run's host suspensions and the failure they likely caused. */
|
|
97
|
+
hostSuspension?: HostSuspensionReading;
|
|
95
98
|
}): RunBundle;
|
|
@@ -332,10 +332,11 @@ function singleReview(args, view, stream) {
|
|
|
332
332
|
}
|
|
333
333
|
: {}),
|
|
334
334
|
...(singleRunTasks === undefined ? {} : { tasks: singleRunTasks }),
|
|
335
|
-
|
|
335
|
+
// A host suspension comes first: it may explain everything after it.
|
|
336
|
+
summary: `${args.hostSuspension === undefined ? "" : `${args.hostSuspension.summary} `}${singleSummary(args, view, {
|
|
336
337
|
...(singleRunTasks === undefined ? {} : { tasks: singleRunTasks }),
|
|
337
338
|
...(credibilityNote === undefined ? {} : { credibilityNote }),
|
|
338
|
-
})
|
|
339
|
+
})}`,
|
|
339
340
|
gaps: args.session || args.sessionError !== undefined
|
|
340
341
|
? []
|
|
341
342
|
: args.inProgress === true
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Verdict } from "../../run/judge.js";
|
|
2
|
+
import type { HostSuspensionReading } from "../../run/host-suspension.js";
|
|
2
3
|
import type { LocalCapacity } from "../../substrates/local/capacity.js";
|
|
3
4
|
import type { RunDesktopRecording } from "../../evidence/desktop-recording-types.js";
|
|
4
5
|
import type { CuaExecutor, CuaLiveMetadata, CuaLoopResult, CuaProvider } from "../../actors/computer-use/loop.js";
|
|
@@ -111,6 +112,8 @@ export interface CuaParticipantPlanEntry {
|
|
|
111
112
|
targetDigest?: string;
|
|
112
113
|
/** The participant's declared start, in ms after the run starts its participants. */
|
|
113
114
|
startAfterMs?: number;
|
|
115
|
+
/** The participant's declared end, on the same clock. */
|
|
116
|
+
endAfterMs?: number;
|
|
114
117
|
}
|
|
115
118
|
/** The pre-flight spend and participant plan (pure; printed to stderr + recorded as a bundle event before
|
|
116
119
|
* any sandbox or provider call; identical in dry-run, marked $0). */
|
|
@@ -137,7 +140,8 @@ export interface CuaParticipantPlan {
|
|
|
137
140
|
lanes: CuaParticipantPlanEntry[];
|
|
138
141
|
}
|
|
139
142
|
/** One participant's outcome in the result projection. Always present in `result.lanes` (length
|
|
140
|
-
* 1 at N=1). A `blocked` participant is one the pipeline
|
|
143
|
+
* 1 at N=1). A `blocked` participant is one the pipeline gate or the study budget skipped before
|
|
144
|
+
* it ran. */
|
|
141
145
|
export interface CuaParticipantResult {
|
|
142
146
|
id: string;
|
|
143
147
|
actorType?: string;
|
|
@@ -148,7 +152,7 @@ export interface CuaParticipantResult {
|
|
|
148
152
|
device: string;
|
|
149
153
|
/** Requested E2B/X screen resolution. See the run stream's desktopGeometry for measurements. */
|
|
150
154
|
resolution: [number, number];
|
|
151
|
-
/** Terminal participant status; "blocked" = skipped (gate
|
|
155
|
+
/** Terminal participant status; "blocked" = skipped (gate or study budget); "contract_proof_only" = dry-run. */
|
|
152
156
|
status: ActorStatus | "blocked" | "contract_proof_only";
|
|
153
157
|
ok: boolean;
|
|
154
158
|
session?: {
|
|
@@ -182,7 +186,7 @@ export interface CuaParticipantSummary {
|
|
|
182
186
|
total: number;
|
|
183
187
|
/** Participants whose own verdict is ok (terminal, engaged, no harness error). */
|
|
184
188
|
passed: number;
|
|
185
|
-
/** Participants skipped by the pipeline gate
|
|
189
|
+
/** Participants skipped by the pipeline gate or the study budget. */
|
|
186
190
|
skipped: number;
|
|
187
191
|
/** Participants that ended in a harness error. */
|
|
188
192
|
harnessErrors: number;
|
|
@@ -233,6 +237,11 @@ export interface CuaActorStudyResult extends AutomaticAnalysisResult, StudyResul
|
|
|
233
237
|
rerun?: RunRerunLineage;
|
|
234
238
|
observer?: ObserverResult;
|
|
235
239
|
diagnostics?: CuaDiagnostics;
|
|
240
|
+
/**
|
|
241
|
+
* Present when the host was suspended during the run (a laptop that slept): when, and which
|
|
242
|
+
* participant failures it likely caused. Each participant's own result is unchanged.
|
|
243
|
+
*/
|
|
244
|
+
hostSuspension?: HostSuspensionReading;
|
|
236
245
|
warnings: string[];
|
|
237
246
|
error?: {
|
|
238
247
|
code: CuaActorStudyErrorCode;
|
|
@@ -357,6 +366,16 @@ export interface ParticipantModelDeps {
|
|
|
357
366
|
env: Record<string, string | undefined>;
|
|
358
367
|
openaiApiKey: string;
|
|
359
368
|
timeoutMs: number;
|
|
369
|
+
/**
|
|
370
|
+
* The participant's scheduled end on `now`'s clock, when it declares participants[].endAfterMs
|
|
371
|
+
* (scheduledParticipantDeps). Its session ends there with stop cause `scheduled_end`.
|
|
372
|
+
*/
|
|
373
|
+
scheduledEnd?: {
|
|
374
|
+
readonly atMs: number;
|
|
375
|
+
readonly windDownMs: number;
|
|
376
|
+
};
|
|
377
|
+
/** The run's clock. A participant with a scheduled end runs its session on it. */
|
|
378
|
+
now: () => number;
|
|
360
379
|
participantCount: number;
|
|
361
380
|
artifactRoot: PreparedOutputRoot;
|
|
362
381
|
redactScreenshots: boolean;
|
|
@@ -391,8 +410,8 @@ export type CuaParticipantDeps = E2BDesktopDeps & ParticipantModelDeps & {
|
|
|
391
410
|
/** The caller's inProcess executor with the run's config bound. Read by the in-process desktop. */
|
|
392
411
|
inProcessExecutor?: (appUrl: string) => Promise<CuaExecutor>;
|
|
393
412
|
runSession: (options: CuaActorSessionOptions) => Promise<CuaLoopResult>;
|
|
394
|
-
/**
|
|
395
|
-
|
|
413
|
+
/** Called once, when the participant's desktop and model are ready and its session starts. */
|
|
414
|
+
signalReady?: () => void;
|
|
396
415
|
};
|
|
397
416
|
/** Why a sandbox was not confirmed released. */
|
|
398
417
|
export interface SandboxReleaseFact {
|
|
@@ -435,10 +454,16 @@ export interface ParticipantRunOutcome {
|
|
|
435
454
|
* folded into bundle.events at build time. Empty on the in-process route (no provisioning). */
|
|
436
455
|
phaseRecords: SubjectPhaseEvent[];
|
|
437
456
|
warnings: string[];
|
|
438
|
-
/** Set when the
|
|
457
|
+
/** Set when the pipeline gate or the study budget skipped the participant (a pinned reason). */
|
|
439
458
|
skippedReason?: string;
|
|
440
459
|
/** When a fan-out participant was due and when its desktop was requested. */
|
|
441
460
|
arrival?: ParticipantArrival;
|
|
461
|
+
/**
|
|
462
|
+
* When the participant's runner returned this outcome, on the deps clock (epoch ms): after its
|
|
463
|
+
* session, or after a setup failure that left it none. Absent on a skipped participant, which
|
|
464
|
+
* never ran, and on outcomes built outside the runner.
|
|
465
|
+
*/
|
|
466
|
+
endedAt?: number;
|
|
442
467
|
noEngagement: boolean;
|
|
443
468
|
selfReportedBlocker: boolean;
|
|
444
469
|
/** The inclusive friction read: blocker-shaped narration incl. self-resolved arcs.
|
|
@@ -469,7 +494,8 @@ export interface CuaFanoutBundleArgs {
|
|
|
469
494
|
source: RunBundle["source"];
|
|
470
495
|
participantPlan: CuaParticipantPlan;
|
|
471
496
|
rerun?: RunRerunLineage;
|
|
472
|
-
|
|
497
|
+
/** The run's host suspensions and the participant failures they likely caused. */
|
|
498
|
+
hostSuspension?: HostSuspensionReading;
|
|
473
499
|
publicRepo?: string;
|
|
474
500
|
inProgress?: boolean;
|
|
475
501
|
}
|
|
@@ -383,8 +383,8 @@ async function publishInProgress(plane, ctx, live, setup) {
|
|
|
383
383
|
startParticipantFlush(ctx, live, inProgressBundle);
|
|
384
384
|
}
|
|
385
385
|
// Launch N actor sandboxes independently on the study's schedule (runCuaParticipant +
|
|
386
|
-
// runOnSchedule; runCuaParticipants would add a pipeline gate
|
|
387
|
-
//
|
|
386
|
+
// runOnSchedule; runCuaParticipants would add a pipeline gate). Each actor's window is measured on
|
|
387
|
+
// the one orchestrator clock.
|
|
388
388
|
function runParticipants(plane, ctx, live, setup) {
|
|
389
389
|
const { now } = ctx;
|
|
390
390
|
const participants = ctx.plan.plane.participants;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RunEvent } from "./bundle.js";
|
|
2
|
+
/** The run's clock and heartbeat timer. Tests pass one whose wall clock they move. */
|
|
3
|
+
export interface HostClock {
|
|
4
|
+
/** Wall-clock time in epoch milliseconds. */
|
|
5
|
+
now(): number;
|
|
6
|
+
/** Call `tick` every `intervalMs` until the returned function runs. Never keeps the process alive. */
|
|
7
|
+
every(intervalMs: number, tick: () => void): () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare const systemHostClock: HostClock;
|
|
10
|
+
/** A time the run's heartbeat did not run: from its last tick before the gap to its first after. */
|
|
11
|
+
export interface HostSuspension {
|
|
12
|
+
startedAt: string;
|
|
13
|
+
endedAt: string;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Heartbeat {
|
|
17
|
+
/** The suspensions so far. A read also checks the time since the last tick. */
|
|
18
|
+
suspensions(): readonly HostSuspension[];
|
|
19
|
+
/** Stop the timer. Later reads return the suspensions recorded until then. */
|
|
20
|
+
stop(): void;
|
|
21
|
+
}
|
|
22
|
+
/** Run `onTick` every `intervalMs` on `clock`, and record each gap of 30 s or more. */
|
|
23
|
+
export declare function startHeartbeat(clock: HostClock, intervalMs: number, onTick: () => void): Heartbeat;
|
|
24
|
+
/** One warn event per suspension, at its start, naming its start, end, length and offset. */
|
|
25
|
+
export declare function hostSuspensionEvents(suspensions: readonly HostSuspension[], runStartMs: number): RunEvent[];
|
|
26
|
+
/** One participant as the attribution reads it. */
|
|
27
|
+
export interface SuspendedParticipant {
|
|
28
|
+
id: string;
|
|
29
|
+
/**
|
|
30
|
+
* How it did not pass, when a suspension can explain that: an error in the harness, its
|
|
31
|
+
* session's time limit, or a skip after another participant's harness error. Absent when it
|
|
32
|
+
* passed or its session ended on its own report.
|
|
33
|
+
*/
|
|
34
|
+
failure?: "harness" | "time-limit" | "skipped";
|
|
35
|
+
/** When it started; absent when no record says. */
|
|
36
|
+
startedAtMs?: number;
|
|
37
|
+
/** When it stopped, after its session or after a setup failure; absent when no record says. */
|
|
38
|
+
endedAtMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** What a run says about its host suspensions. */
|
|
41
|
+
export interface HostSuspensionReading {
|
|
42
|
+
/** One sentence for the run outcome, the CLI and the review. */
|
|
43
|
+
summary: string;
|
|
44
|
+
/** The participants whose failure is likely a suspension's effect, in the order given. */
|
|
45
|
+
participantIds: string[];
|
|
46
|
+
suspensions: HostSuspension[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Which failures the run's suspensions likely caused, and the sentence that says so; undefined
|
|
50
|
+
* without a suspension. A frozen loop's failures surface at the wake (macOS fires every overdue
|
|
51
|
+
* timer at once) or up to a turn timeout later (Linux timers do not count suspended time), so the
|
|
52
|
+
* rule reads who was in flight across the suspension. A skip follows another participant's
|
|
53
|
+
* harness error (the pipeline gate), so skips count only when the run had harness errors and
|
|
54
|
+
* every one was in flight across a suspension.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readHostSuspensions(suspensions: readonly HostSuspension[], participants: readonly SuspendedParticipant[], runStartMs: number): HostSuspensionReading | undefined;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Host suspensions: when the machine running a study sleeps, every participant loop on it stops,
|
|
2
|
+
// and on wake its timers and connections fail together. This module notices the gap and says
|
|
3
|
+
// which participant failures it likely caused, so the run names the suspension as their cause.
|
|
4
|
+
//
|
|
5
|
+
// Detection reads the wall clock across a heartbeat. Date.now counts suspended time on macOS and
|
|
6
|
+
// Linux. performance.now and Node's timers run on libuv's clock, which counts sleep on macOS
|
|
7
|
+
// (mach_continuous_time), so the tick after a sleep fires at wake, and stops during a Linux
|
|
8
|
+
// suspend (CLOCK_MONOTONIC), so the tick fires within one interval of resume. Either way the wall
|
|
9
|
+
// clock between two ticks shows the gap.
|
|
10
|
+
import { formatDuration } from "./projection.js";
|
|
11
|
+
import { plural } from "./text.js";
|
|
12
|
+
export const systemHostClock = {
|
|
13
|
+
now: () => Date.now(),
|
|
14
|
+
every(intervalMs, tick) {
|
|
15
|
+
const timer = setInterval(tick, intervalMs);
|
|
16
|
+
timer.unref?.();
|
|
17
|
+
return () => clearInterval(timer);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A wall-clock gap between two heartbeats this long or longer is a suspension. A shorter one is
|
|
22
|
+
* most likely a busy event loop.
|
|
23
|
+
*/
|
|
24
|
+
const HOST_SUSPENSION_MIN_MS = 30_000;
|
|
25
|
+
/** Run `onTick` every `intervalMs` on `clock`, and record each gap of 30 s or more. */
|
|
26
|
+
export function startHeartbeat(clock, intervalMs, onTick) {
|
|
27
|
+
const found = [];
|
|
28
|
+
let lastMs = clock.now();
|
|
29
|
+
let stopped = false;
|
|
30
|
+
const observe = () => {
|
|
31
|
+
if (stopped)
|
|
32
|
+
return;
|
|
33
|
+
const nowMs = clock.now();
|
|
34
|
+
if (nowMs - lastMs >= HOST_SUSPENSION_MIN_MS)
|
|
35
|
+
found.push({
|
|
36
|
+
startedAt: new Date(lastMs).toISOString(),
|
|
37
|
+
endedAt: new Date(nowMs).toISOString(),
|
|
38
|
+
durationMs: nowMs - lastMs,
|
|
39
|
+
});
|
|
40
|
+
lastMs = nowMs;
|
|
41
|
+
};
|
|
42
|
+
const stopTimer = clock.every(intervalMs, () => {
|
|
43
|
+
observe();
|
|
44
|
+
onTick();
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
suspensions() {
|
|
48
|
+
observe();
|
|
49
|
+
return [...found];
|
|
50
|
+
},
|
|
51
|
+
stop() {
|
|
52
|
+
stopped = true;
|
|
53
|
+
stopTimer();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** `6m 5s at +1m`: how long, and how far into the run it started. */
|
|
58
|
+
function span(suspension, runStartMs) {
|
|
59
|
+
const offset = Date.parse(suspension.startedAt) - runStartMs;
|
|
60
|
+
return `${formatDuration(suspension.durationMs)} at +${formatDuration(offset)}`;
|
|
61
|
+
}
|
|
62
|
+
/** One warn event per suspension, at its start, naming its start, end, length and offset. */
|
|
63
|
+
export function hostSuspensionEvents(suspensions, runStartMs) {
|
|
64
|
+
return suspensions.map((suspension, index) => ({
|
|
65
|
+
id: `event-host-suspended-${String(index + 1).padStart(3, "0")}`,
|
|
66
|
+
at: suspension.startedAt,
|
|
67
|
+
level: "warn",
|
|
68
|
+
type: "host.suspended",
|
|
69
|
+
message: `The host was suspended for ${formatDuration(suspension.durationMs)}, from ${suspension.startedAt} to ${suspension.endedAt} (+${formatDuration(Date.parse(suspension.startedAt) - runStartMs)} into the run): the run's heartbeat did not run in that time. A process that was stopped, or a clock set forward, reads the same way.`,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** The participant was running at some moment of the suspension. */
|
|
73
|
+
function inFlight(participant, suspension) {
|
|
74
|
+
return ((participant.startedAtMs ?? -Infinity) <= Date.parse(suspension.endedAt) &&
|
|
75
|
+
(participant.endedAtMs ?? Infinity) >= Date.parse(suspension.startedAt));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Which failures the run's suspensions likely caused, and the sentence that says so; undefined
|
|
79
|
+
* without a suspension. A frozen loop's failures surface at the wake (macOS fires every overdue
|
|
80
|
+
* timer at once) or up to a turn timeout later (Linux timers do not count suspended time), so the
|
|
81
|
+
* rule reads who was in flight across the suspension. A skip follows another participant's
|
|
82
|
+
* harness error (the pipeline gate), so skips count only when the run had harness errors and
|
|
83
|
+
* every one was in flight across a suspension.
|
|
84
|
+
*/
|
|
85
|
+
export function readHostSuspensions(suspensions, participants, runStartMs) {
|
|
86
|
+
if (suspensions.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const explained = new Set(participants
|
|
89
|
+
.filter((participant) => (participant.failure === "harness" || participant.failure === "time-limit") &&
|
|
90
|
+
suspensions.some((suspension) => inFlight(participant, suspension)))
|
|
91
|
+
.map((participant) => participant.id));
|
|
92
|
+
const harness = participants.filter((participant) => participant.failure === "harness");
|
|
93
|
+
const skipsFollow = harness.length > 0 && harness.every((participant) => explained.has(participant.id));
|
|
94
|
+
const participantIds = participants
|
|
95
|
+
.filter((participant) => explained.has(participant.id) || (participant.failure === "skipped" && skipsFollow))
|
|
96
|
+
.map((participant) => participant.id);
|
|
97
|
+
const one = suspensions.length === 1;
|
|
98
|
+
const what = one
|
|
99
|
+
? `The host was suspended for ${span(suspensions[0], runStartMs)}`
|
|
100
|
+
: `The host was suspended ${suspensions.length} times (${suspensions.map((suspension) => span(suspension, runStartMs)).join(", ")})`;
|
|
101
|
+
const count = participantIds.length;
|
|
102
|
+
const effect = count === 0
|
|
103
|
+
? "no participant failure followed"
|
|
104
|
+
: `the ${plural(count, "participant failure")} after ${one ? "it" : "them"} ${count === 1 ? "is" : "are"} likely ${one ? "its" : "their"} effect`;
|
|
105
|
+
return { summary: `${what}; ${effect}.`, participantIds, suspensions: [...suspensions] };
|
|
106
|
+
}
|
package/dist/run/judge.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface ParticipantFacts {
|
|
|
34
34
|
completionReason?: ActorCompletionReason;
|
|
35
35
|
/** The harness failed before the session reached a terminal status. */
|
|
36
36
|
sessionError?: string;
|
|
37
|
-
/** The pipeline gate or
|
|
37
|
+
/** The pipeline gate or the study budget skipped the participant. */
|
|
38
38
|
skipped: boolean;
|
|
39
39
|
/** hollowCompletion held for its session. */
|
|
40
40
|
noEngagement: boolean;
|
package/dist/run/outcomes.js
CHANGED
|
@@ -193,7 +193,9 @@ export function formatParticipantOutcomes(outcomes, terminalCauses = []) {
|
|
|
193
193
|
const description = entry.label === undefined ? fallback : `interrupted (${entry.label})`;
|
|
194
194
|
counts.set(description, (counts.get(description) ?? 0) + 1);
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
// Participants finish in any order, so the causes are sorted: most first, then by name.
|
|
197
|
+
const sorted = [...counts].sort(([a, m], [b, n]) => n - m || a.localeCompare(b));
|
|
198
|
+
for (const [description, n] of sorted)
|
|
197
199
|
parts.push(`${n} ${description}`);
|
|
198
200
|
};
|
|
199
201
|
append(outcomes.abandoned, ["abandoned"], "gave up");
|
package/dist/run/run.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type RunBundle } from "./bundle.js";
|
|
|
3
3
|
import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
|
|
4
4
|
import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
|
|
5
5
|
import { RunSecrets } from "./secrets.js";
|
|
6
|
+
import { type HostClock, type HostSuspension } from "./host-suspension.js";
|
|
6
7
|
import type { RunStudyProvenance } from "./study-provenance.js";
|
|
7
8
|
interface StartRunOptions {
|
|
8
9
|
/** Project directory, in the form the route resolved it. */
|
|
@@ -21,8 +22,10 @@ interface StartRunOptions {
|
|
|
21
22
|
open: boolean;
|
|
22
23
|
render?: typeof renderObserver | undefined;
|
|
23
24
|
};
|
|
24
|
-
/** Clock for `createdAt` and the latest pointer. */
|
|
25
|
+
/** Clock for `createdAt` and the latest pointer. Defaults to the clock's `now`. */
|
|
25
26
|
now?: (() => number) | undefined;
|
|
27
|
+
/** The run's wall clock and heartbeat timer. Defaults to systemHostClock. */
|
|
28
|
+
clock?: HostClock | undefined;
|
|
26
29
|
/** Warnings about the study's own fields. Every bundle write records each as a warn event. */
|
|
27
30
|
warnings?: readonly string[] | undefined;
|
|
28
31
|
/** The route's known values, created before the run so a refusal before it is scrubbed too. */
|
|
@@ -42,9 +45,13 @@ interface StartRunInput {
|
|
|
42
45
|
readonly runId?: string | undefined;
|
|
43
46
|
/** Open the Observer page once it renders. */
|
|
44
47
|
readonly open?: boolean | undefined;
|
|
45
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* `renderObserver` is the seam FinishedRun.renderObserver calls; `hostClock` is the run's
|
|
50
|
+
* clock and heartbeat timer.
|
|
51
|
+
*/
|
|
46
52
|
readonly deps?: {
|
|
47
53
|
readonly renderObserver?: typeof renderObserver | undefined;
|
|
54
|
+
readonly hostClock?: HostClock | undefined;
|
|
48
55
|
} | undefined;
|
|
49
56
|
}
|
|
50
57
|
/** What a recorded route passes to startRun besides its plan and input. */
|
|
@@ -100,6 +107,12 @@ interface Run {
|
|
|
100
107
|
* and a run that failed before any session (a refused E2B key) does not.
|
|
101
108
|
*/
|
|
102
109
|
participantStarted(): void;
|
|
110
|
+
/**
|
|
111
|
+
* The times this process did not run while the run was alive, most likely because the host
|
|
112
|
+
* slept, as the run's heartbeat saw them up to this call. Every bundle write records each as a
|
|
113
|
+
* `host.suspended` event; a route reads them to say which participant failures they caused.
|
|
114
|
+
*/
|
|
115
|
+
hostSuspensions(): readonly HostSuspension[];
|
|
103
116
|
/**
|
|
104
117
|
* The one final publication: run.json with `outcome` set from `outcome`, then the status
|
|
105
118
|
* outcome copied from it, then review.json, review.md, events.ndjson,
|
package/dist/run/run.js
CHANGED
|
@@ -16,6 +16,7 @@ import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
|
|
|
16
16
|
import { RunSecrets } from "./secrets.js";
|
|
17
17
|
import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
|
|
18
18
|
import { readRunJsonIfExists, runJsonValue } from "./locate.js";
|
|
19
|
+
import { hostSuspensionEvents, systemHostClock, } from "./host-suspension.js";
|
|
19
20
|
/** An entry point called in a state that does not admit it. */
|
|
20
21
|
class RunLifecycleError extends Error {
|
|
21
22
|
name = "RunLifecycleError";
|
|
@@ -127,10 +128,14 @@ function runPublisher(args) {
|
|
|
127
128
|
type: "study.warning",
|
|
128
129
|
message: redactText(message),
|
|
129
130
|
}));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// So do the host suspensions the heartbeat has seen by the time of the write.
|
|
132
|
+
const withRunEvents = (events) => {
|
|
133
|
+
const owned = [
|
|
134
|
+
...studyWarnings,
|
|
135
|
+
...hostSuspensionEvents(runStatus.suspensions(), Date.parse(createdAt)),
|
|
136
|
+
];
|
|
137
|
+
return [...events.filter((event) => !owned.some((mine) => mine.id === event.id)), ...owned];
|
|
138
|
+
};
|
|
134
139
|
let pointerWritten = false;
|
|
135
140
|
// The bundle of the last write asked for, and the outcome every later write carries once a
|
|
136
141
|
// signal stopped the run, so no write after the interrupt can drop it.
|
|
@@ -162,7 +167,7 @@ function runPublisher(args) {
|
|
|
162
167
|
const recorded = interrupted ?? outcome;
|
|
163
168
|
const publicBundle = await withPublicSandboxIds(paths, {
|
|
164
169
|
...evidence,
|
|
165
|
-
events:
|
|
170
|
+
events: withRunEvents(evidence.events),
|
|
166
171
|
cwd: PUBLIC_TARGET_CWD,
|
|
167
172
|
...(recorded === undefined ? {} : { outcome: recorded }),
|
|
168
173
|
});
|
|
@@ -259,8 +264,8 @@ export async function runScope(fn) {
|
|
|
259
264
|
const created = await createRunArtifactPaths(options.cwd, runId);
|
|
260
265
|
if (!created.ok)
|
|
261
266
|
return created;
|
|
262
|
-
// beginRunStatus reads only the mode, the study provenance
|
|
263
|
-
// the run's options.
|
|
267
|
+
// beginRunStatus reads only the mode, the study provenance, the sandboxes field and the
|
|
268
|
+
// clock from the run's options.
|
|
264
269
|
const runStatus = beginRunStatus(created.paths, { ...options, runId });
|
|
265
270
|
status = runStatus;
|
|
266
271
|
let interruptBundle;
|
|
@@ -290,7 +295,7 @@ export async function runScope(fn) {
|
|
|
290
295
|
return args.length === 1 ? startRunWith(args[0]) : startRecordedRun(startRunWith, ...args);
|
|
291
296
|
}
|
|
292
297
|
const openRun = (options, runId, paths, runStatus) => {
|
|
293
|
-
const now = options.now ??
|
|
298
|
+
const now = options.now ?? (options.clock ?? systemHostClock).now;
|
|
294
299
|
const createdAt = new Date(now()).toISOString();
|
|
295
300
|
let participantsRan = false;
|
|
296
301
|
const publisher = runPublisher({
|
|
@@ -317,6 +322,7 @@ export async function runScope(fn) {
|
|
|
317
322
|
participantStarted() {
|
|
318
323
|
participantsRan = true;
|
|
319
324
|
},
|
|
325
|
+
hostSuspensions: () => runStatus.suspensions(),
|
|
320
326
|
writeSnapshot(bundle) {
|
|
321
327
|
if (closed)
|
|
322
328
|
return refuse("The run scope has closed.");
|
|
@@ -374,6 +380,7 @@ async function startRecordedRun(start, plan, input, route) {
|
|
|
374
380
|
renderReview: route.renderReview,
|
|
375
381
|
observer: { open: input.open === true, render: input.deps?.renderObserver },
|
|
376
382
|
now: route.now,
|
|
383
|
+
clock: input.deps?.hostClock,
|
|
377
384
|
secrets: route.secrets,
|
|
378
385
|
});
|
|
379
386
|
if (!started.ok)
|