@hue-run/sdk 0.11.4 → 0.12.1
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/CLI.md +11 -6
- package/EVALUATIONS.md +27 -9
- package/dist/cli/eval.js +48 -9
- package/dist/client.d.ts +3 -0
- package/dist/client.js +36 -24
- package/dist/evals/client.d.ts +25 -5
- package/dist/evals/client.js +93 -15
- package/dist/evals/environment-target.d.ts +15 -8
- package/dist/evals/environment-target.js +61 -21
- package/dist/evals/failure.d.ts +18 -0
- package/dist/evals/failure.js +150 -0
- package/dist/evals/local-worker.d.ts +39 -4
- package/dist/evals/local-worker.js +158 -34
- package/dist/evals/runner.d.ts +45 -2
- package/dist/evals/runner.js +207 -14
- package/dist/evals/simulation.d.ts +1 -1
- package/dist/evals/types.d.ts +12 -4
- package/dist/evals.d.ts +4 -4
- package/dist/evals.js +2 -2
- package/dist/transport.d.ts +7 -0
- package/dist/transport.js +76 -15
- package/dist/types.d.ts +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isTransientEnvironmentError } from "../environment/client.js";
|
|
4
|
+
import { HueApiError, isTransientApiError } from "./client.js";
|
|
5
|
+
import { HueExportError } from "../transport.js";
|
|
3
6
|
import { CheckpointStore, checkpointPath, checkpointSegment } from "./checkpoint.js";
|
|
4
7
|
import { pinRequestedAttemptV2, requestedAttemptV2, runEnvironmentTarget, } from "./environment-target.js";
|
|
5
|
-
import { runExperiment,
|
|
8
|
+
import { runExperiment, } from "./runner.js";
|
|
6
9
|
/** Capability strings a registration declares; Hue offers only matching cases. */
|
|
7
10
|
export const localAgentCapabilities = {
|
|
8
11
|
/** Cases pinned to a hosted synthetic world. */
|
|
@@ -50,6 +53,7 @@ function localAgentTargetContext(context) {
|
|
|
50
53
|
: {}),
|
|
51
54
|
files: structuredClone(context.files),
|
|
52
55
|
outputDirectory: context.outputDirectory,
|
|
56
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
/** The registration's capabilities: explicit values plus one per supplied callback. */
|
|
@@ -98,15 +102,22 @@ function wait(milliseconds, signal) {
|
|
|
98
102
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
99
103
|
});
|
|
100
104
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
/** A failure worth resuming: one that changes on its own. Hue or a world not answering or
|
|
106
|
+
* refusing transiently past the client's retries, telemetry not accepted at the flush, or a run
|
|
107
|
+
* of such failures across concurrent cases. Everything else, an outcome unsafe to resume (the
|
|
108
|
+
* target may have run without a saved outcome, an output that cannot be saved), a saved outcome
|
|
109
|
+
* whose trace was never acknowledged (a resume does not export it again), a refusal Hue decided
|
|
110
|
+
* on, an input the SDK refuses (a case file whose bytes differ from its manifest), would recur on
|
|
111
|
+
* the next attempt and is given up as attention at once. */
|
|
112
|
+
function resumable(error, seen = new Set()) {
|
|
113
|
+
if (isTransientApiError(error) ||
|
|
114
|
+
isTransientEnvironmentError(error) ||
|
|
115
|
+
error instanceof HueExportError)
|
|
105
116
|
return true;
|
|
106
|
-
if (!(error instanceof AggregateError) || seen.has(error))
|
|
117
|
+
if (!(error instanceof AggregateError) || seen.has(error) || !error.errors.length)
|
|
107
118
|
return false;
|
|
108
119
|
seen.add(error);
|
|
109
|
-
return error.errors.
|
|
120
|
+
return error.errors.every((nested) => resumable(nested, seen));
|
|
110
121
|
}
|
|
111
122
|
/**
|
|
112
123
|
* Starts an outbound-only worker for one fixed local agent entry point. Hue chooses
|
|
@@ -119,10 +130,33 @@ export async function runLocalAgent(options) {
|
|
|
119
130
|
const maxRuns = options.maxRuns ?? Number.POSITIVE_INFINITY;
|
|
120
131
|
if (!(maxRuns === Number.POSITIVE_INFINITY || (Number.isInteger(maxRuns) && maxRuns > 0)))
|
|
121
132
|
throw new RangeError("maxRuns must be a positive integer");
|
|
133
|
+
const maxRunAttempts = options.maxRunAttempts ?? 5;
|
|
134
|
+
if (!Number.isInteger(maxRunAttempts) || maxRunAttempts < 1 || maxRunAttempts > 20)
|
|
135
|
+
throw new RangeError("maxRunAttempts must be 1–20");
|
|
136
|
+
if (options.worldTtlSeconds !== undefined &&
|
|
137
|
+
(!Number.isInteger(options.worldTtlSeconds) ||
|
|
138
|
+
options.worldTtlSeconds < 1 ||
|
|
139
|
+
options.worldTtlSeconds > 86_400))
|
|
140
|
+
throw new RangeError("worldTtlSeconds must be 1–86400");
|
|
122
141
|
if (options.environmentClient.baseUrl !== options.client.baseUrl)
|
|
123
142
|
throw new Error("Environments and evaluations must use the same Hue origin");
|
|
124
143
|
const directory = resolve(options.checkpointDirectory);
|
|
125
|
-
|
|
144
|
+
// The first read is polled like the claims that follow: Hue not answering at startup is not
|
|
145
|
+
// a reason for the worker to exit; a refusal Hue decided on still is.
|
|
146
|
+
let project;
|
|
147
|
+
for (let failures = 0;;) {
|
|
148
|
+
try {
|
|
149
|
+
project = await options.client.checkConnection();
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!isTransientApiError(error) || options.signal?.aborted)
|
|
154
|
+
throw error;
|
|
155
|
+
failures++;
|
|
156
|
+
await options.onPollError?.(error);
|
|
157
|
+
await wait(Math.min(interval * 2 ** Math.min(failures, 6), 60_000), options.signal);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
126
160
|
const store = await CheckpointStore.acquire(directory, {
|
|
127
161
|
kind: "local-agent-worker",
|
|
128
162
|
projectId: project.id,
|
|
@@ -138,28 +172,87 @@ export async function runLocalAgent(options) {
|
|
|
138
172
|
workerId = randomUUID();
|
|
139
173
|
await store.write("worker-id", workerId);
|
|
140
174
|
}
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
let settled = 0;
|
|
176
|
+
/** Consecutive poll failures, for the backoff between polls; reset by a poll that answers. */
|
|
177
|
+
let pollFailures = 0;
|
|
178
|
+
/** Consecutive failed attempts per claimed run; a run is given up at `maxRunAttempts`. */
|
|
179
|
+
const attempts = new Map();
|
|
180
|
+
const bump = (claim) => {
|
|
181
|
+
const attempt = (attempts.get(claim.runId) ?? 0) + 1;
|
|
182
|
+
attempts.set(claim.runId, attempt);
|
|
183
|
+
return attempt;
|
|
184
|
+
};
|
|
185
|
+
/** The wait before the run is resumed: the poll interval doubled per attempt, at most a minute. */
|
|
186
|
+
const backoff = (attempt) => Math.min(interval * 2 ** Math.min(attempt, 6), 60_000);
|
|
187
|
+
/** Gives the run up as attention with the failure's name; a refusal to record it (the run
|
|
188
|
+
* was released or cancelled meanwhile) is reported as such and changes nothing else. */
|
|
189
|
+
const giveUp = async (claim, error) => {
|
|
190
|
+
const failureType = error instanceof Error ? error.name.slice(0, 200) : "WorkerError";
|
|
191
|
+
let outcome = "attention";
|
|
192
|
+
try {
|
|
193
|
+
await options.client.completeLocalAgentRun({
|
|
194
|
+
runId: claim.runId,
|
|
195
|
+
workerId,
|
|
196
|
+
state: "attention",
|
|
197
|
+
failureType,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (completion) {
|
|
201
|
+
if (completion instanceof HueApiError && completion.status === 409)
|
|
202
|
+
outcome = "refused";
|
|
203
|
+
// A transient failure past the client's retries leaves the run claimed; the next claim
|
|
204
|
+
// returns it and the attempt count gives it up again. A refusal Hue decided on (a
|
|
205
|
+
// revoked key, a disabled agent) is what needs fixing, and is thrown as itself.
|
|
206
|
+
else if (isTransientApiError(completion))
|
|
207
|
+
return false;
|
|
208
|
+
else
|
|
209
|
+
throw completion;
|
|
210
|
+
}
|
|
211
|
+
const attempt = attempts.get(claim.runId) ?? 0;
|
|
212
|
+
attempts.delete(claim.runId);
|
|
213
|
+
settled++;
|
|
214
|
+
await options.onRunFailed?.({ ...claim, error, attempt, outcome });
|
|
215
|
+
return true;
|
|
216
|
+
};
|
|
217
|
+
while (settled < maxRuns && !options.signal?.aborted) {
|
|
218
|
+
let claim;
|
|
219
|
+
try {
|
|
220
|
+
const agent = await options.client.registerLocalAgent({
|
|
221
|
+
...options.agent,
|
|
222
|
+
capabilities,
|
|
223
|
+
scorerDigests: options.agent.scorerDigests ??
|
|
224
|
+
options.scorers?.map((item) => item.definition.sourceDigest) ??
|
|
225
|
+
[],
|
|
226
|
+
});
|
|
227
|
+
claim = await options.client.claimLocalAgentRun({ agentId: agent.id, workerId });
|
|
228
|
+
pollFailures = 0;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// A transient failure past the client's own retries (Hue restarting, a network blip)
|
|
232
|
+
// does not end the worker: it waits, longer each time, and polls again. A refusal Hue
|
|
233
|
+
// decided on (a revoked key, a disabled agent, a changed registration) is thrown.
|
|
234
|
+
if (!isTransientApiError(error))
|
|
235
|
+
throw error;
|
|
236
|
+
pollFailures++;
|
|
237
|
+
await options.onPollError?.(error);
|
|
238
|
+
await wait(Math.min(interval * 2 ** Math.min(pollFailures, 6), 60_000), options.signal);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
151
241
|
if (!claim) {
|
|
152
242
|
await wait(interval, options.signal);
|
|
153
243
|
continue;
|
|
154
244
|
}
|
|
245
|
+
// A claim naming an experiment id the checkpoint store refuses is a protocol violation,
|
|
246
|
+
// not an operational failure: it is thrown here, before the attempt, and never retried.
|
|
247
|
+
const experimentId = checkpointSegment(claim.experimentId, "experiment id");
|
|
155
248
|
const heartbeat = setInterval(() => {
|
|
156
249
|
void options.client
|
|
157
250
|
.heartbeatLocalAgentRun({ runId: claim.runId, workerId })
|
|
158
251
|
.catch(() => undefined);
|
|
159
252
|
}, Math.min(15_000, Math.max(1_000, interval)));
|
|
160
253
|
let experimentFinished = false;
|
|
254
|
+
let queueCompleted = false;
|
|
161
255
|
try {
|
|
162
|
-
const experimentId = checkpointSegment(claim.experimentId, "experiment id");
|
|
163
256
|
const requested = requestedConfiguration
|
|
164
257
|
? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
|
|
165
258
|
: undefined;
|
|
@@ -201,6 +294,7 @@ export async function runLocalAgent(options) {
|
|
|
201
294
|
inputs,
|
|
202
295
|
context,
|
|
203
296
|
requested,
|
|
297
|
+
ttlSeconds: options.worldTtlSeconds,
|
|
204
298
|
// The registered revision is the agent revision under test.
|
|
205
299
|
agentRevision: options.agent.revision,
|
|
206
300
|
deprecationWarnings: options.deprecationWarnings,
|
|
@@ -218,24 +312,54 @@ export async function runLocalAgent(options) {
|
|
|
218
312
|
workerId,
|
|
219
313
|
state: "completed",
|
|
220
314
|
});
|
|
315
|
+
queueCompleted = true;
|
|
316
|
+
attempts.delete(claim.runId);
|
|
317
|
+
settled++;
|
|
221
318
|
await options.onCompleted?.(report);
|
|
222
|
-
completed++;
|
|
223
319
|
}
|
|
224
320
|
catch (error) {
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
321
|
+
// The run is completed in the queue: a failure after that is the caller's own
|
|
322
|
+
// (`onCompleted` threw) and is thrown as itself, not reported as a retry that cannot be.
|
|
323
|
+
if (queueCompleted)
|
|
324
|
+
throw error;
|
|
325
|
+
if (experimentFinished && error instanceof HueApiError && error.status === 409) {
|
|
326
|
+
// The experiment finished, but Hue no longer accepts this worker's completion: the
|
|
327
|
+
// run was released or cancelled meanwhile. Its outcome is recorded; nothing to resume.
|
|
328
|
+
attempts.delete(claim.runId);
|
|
329
|
+
settled++;
|
|
330
|
+
await options.onRunFailed?.({ ...claim, error, attempt: 0, outcome: "refused" });
|
|
331
|
+
}
|
|
332
|
+
else if (!experimentFinished && !resumable(error)) {
|
|
333
|
+
// A failure that would recur: unsafe to resume, refused by Hue, or an input the SDK
|
|
334
|
+
// refuses. A project member decides; the worker goes on to other runs. When Hue cannot
|
|
335
|
+
// record that for `maxRunAttempts` polls, the worker stops with the error: it can
|
|
336
|
+
// reach neither an outcome nor a record of one.
|
|
337
|
+
if (!(await giveUp(claim, error))) {
|
|
338
|
+
const attempt = bump(claim);
|
|
339
|
+
if (attempt >= maxRunAttempts)
|
|
340
|
+
throw error;
|
|
341
|
+
await wait(backoff(attempt), options.signal);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
// A transient failure: the outcome so far is checkpointed, and the claim stays with
|
|
346
|
+
// this worker, so the next claim returns the same run and the experiment resumes where
|
|
347
|
+
// it stopped. After `maxRunAttempts` such failures the run is given up as attention;
|
|
348
|
+
// when even that cannot be recorded, the worker stops with the error.
|
|
349
|
+
const attempt = bump(claim);
|
|
350
|
+
if (attempt >= maxRunAttempts) {
|
|
351
|
+
if (!(await giveUp(claim, error))) {
|
|
352
|
+
if (attempt >= 2 * maxRunAttempts)
|
|
353
|
+
throw error;
|
|
354
|
+
await wait(backoff(attempt), options.signal);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const waitMillis = backoff(attempt);
|
|
359
|
+
await options.onRunFailed?.({ ...claim, error, attempt, outcome: "retry", waitMillis });
|
|
360
|
+
await wait(waitMillis, options.signal);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
239
363
|
}
|
|
240
364
|
finally {
|
|
241
365
|
clearInterval(heartbeat);
|
package/dist/evals/runner.d.ts
CHANGED
|
@@ -29,6 +29,30 @@ export declare class OutcomeSerializationError extends Error {
|
|
|
29
29
|
export declare class TargetCancelledError extends Error {
|
|
30
30
|
constructor();
|
|
31
31
|
}
|
|
32
|
+
/** Thrown by the environment target when the target did not return before its world's deadline.
|
|
33
|
+
* The world is sealed abandoned and the attempt saved as an `error` of type `TargetTimeout`, so a
|
|
34
|
+
* hung agent ends its case instead of holding the run open. */
|
|
35
|
+
export declare class TargetTimeoutError extends Error {
|
|
36
|
+
/** The world whose deadline passed. */
|
|
37
|
+
readonly environmentRunId: string;
|
|
38
|
+
/** The world's `expiresAt`. */
|
|
39
|
+
readonly expiresAt: string;
|
|
40
|
+
constructor(
|
|
41
|
+
/** The world whose deadline passed. */
|
|
42
|
+
environmentRunId: string,
|
|
43
|
+
/** The world's `expiresAt`. */
|
|
44
|
+
expiresAt: string);
|
|
45
|
+
}
|
|
46
|
+
/** The target's outcome is saved but the telemetry it requires was never acknowledged on an
|
|
47
|
+
* earlier attempt. Restore or export the trace, or complete with omitted evidence through the
|
|
48
|
+
* client; the target is never rerun. It is this case's own state, not the run's. */
|
|
49
|
+
export declare class TraceExportUnacknowledgedError extends Error {
|
|
50
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
51
|
+
readonly executionId: string;
|
|
52
|
+
constructor(
|
|
53
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
54
|
+
executionId: string);
|
|
55
|
+
}
|
|
32
56
|
/** Thrown when the target or world may have committed but acknowledgement is unavailable. */
|
|
33
57
|
export declare class TargetOutcomeUncertainError extends Error {
|
|
34
58
|
/** Execution whose target outcome must never be replayed automatically. */
|
|
@@ -99,9 +123,13 @@ export interface RunExperimentOptions extends RunnerOptions {
|
|
|
99
123
|
* rejects. `"fail_case"` completes the execution as failed instead, with the evidence omitted
|
|
100
124
|
* under the reason `telemetry_not_accepted` and without its output or generated files, so no
|
|
101
125
|
* scorer can pass it; reports it in {@link RunnerReport.telemetryNotAccepted} and goes on with
|
|
102
|
-
* the other cases.
|
|
126
|
+
* the other cases. `"pending"` completes the execution in its true state with
|
|
127
|
+
* `traceEvidence: "pending"` and the count of spans the case ended: Hue scores the sealed world
|
|
128
|
+
* at once and attaches the trace once it holds every span, or records the evidence omitted a
|
|
129
|
+
* day later; reports it in {@link RunnerReport.tracePending} and goes on. A saved outcome whose
|
|
130
|
+
* export the receipt does not accept on resume completes the same way.
|
|
103
131
|
*/
|
|
104
|
-
traceNotAccepted?: "stop" | "fail_case";
|
|
132
|
+
traceNotAccepted?: "stop" | "fail_case" | "pending";
|
|
105
133
|
/** Called when this call completes a case as failed under `traceNotAccepted: "fail_case"`,
|
|
106
134
|
* before the run goes on, so the failure is known even if a later case stops the run. A call
|
|
107
135
|
* that resumes an already completed case lists it in the report without calling again. */
|
|
@@ -128,6 +156,21 @@ export interface RunnerReport {
|
|
|
128
156
|
/** Cases completed as failed because their telemetry was not accepted, under
|
|
129
157
|
* `traceNotAccepted: "fail_case"`; absent when there were none. */
|
|
130
158
|
telemetryNotAccepted?: TelemetryNotAccepted[];
|
|
159
|
+
/** Cases completed with their trace evidence pending, under `traceNotAccepted: "pending"`;
|
|
160
|
+
* absent when there were none. */
|
|
161
|
+
tracePending?: TracePending[];
|
|
162
|
+
}
|
|
163
|
+
/** A case completed with its trace still to arrive. */
|
|
164
|
+
export interface TracePending {
|
|
165
|
+
/** Frozen case ID. */
|
|
166
|
+
caseId: string;
|
|
167
|
+
/** The case's external key. */
|
|
168
|
+
caseKey: string;
|
|
169
|
+
/** Execution completed pending. */
|
|
170
|
+
executionId: string;
|
|
171
|
+
/** How many spans the case ended, which Hue holds the arriving trace to; absent when the
|
|
172
|
+
* checkpoint recorded no count. */
|
|
173
|
+
spanCount?: number;
|
|
131
174
|
}
|
|
132
175
|
/** Export issue counts, never content: which signal, what happened, the HTTP status when Hue
|
|
133
176
|
* answered, and how many records. */
|
package/dist/evals/runner.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
|
6
6
|
import { HueExportError } from "../transport.js";
|
|
7
7
|
import { HueApiError } from "./client.js";
|
|
8
8
|
import { loadEnvironmentEvidence } from "./environment-evidence.js";
|
|
9
|
+
import { serviceFailureType } from "./failure.js";
|
|
9
10
|
import { CheckpointStore } from "./checkpoint.js";
|
|
10
11
|
import { onForcedExit } from "./exit-cleanup.js";
|
|
11
12
|
import { assertSafeFileNames, downloadCaseFiles, downloadCaseInputs, localOutputFiles, verifyCaseFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
|
|
@@ -65,6 +66,36 @@ export class TargetCancelledError extends Error {
|
|
|
65
66
|
this.name = "TargetCancelledError";
|
|
66
67
|
}
|
|
67
68
|
}
|
|
69
|
+
/** Thrown by the environment target when the target did not return before its world's deadline.
|
|
70
|
+
* The world is sealed abandoned and the attempt saved as an `error` of type `TargetTimeout`, so a
|
|
71
|
+
* hung agent ends its case instead of holding the run open. */
|
|
72
|
+
export class TargetTimeoutError extends Error {
|
|
73
|
+
environmentRunId;
|
|
74
|
+
expiresAt;
|
|
75
|
+
constructor(
|
|
76
|
+
/** The world whose deadline passed. */
|
|
77
|
+
environmentRunId,
|
|
78
|
+
/** The world's `expiresAt`. */
|
|
79
|
+
expiresAt) {
|
|
80
|
+
super(`Target did not finish before its world ${environmentRunId} expired at ${expiresAt}`);
|
|
81
|
+
this.environmentRunId = environmentRunId;
|
|
82
|
+
this.expiresAt = expiresAt;
|
|
83
|
+
this.name = "TargetTimeoutError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The target's outcome is saved but the telemetry it requires was never acknowledged on an
|
|
87
|
+
* earlier attempt. Restore or export the trace, or complete with omitted evidence through the
|
|
88
|
+
* client; the target is never rerun. It is this case's own state, not the run's. */
|
|
89
|
+
export class TraceExportUnacknowledgedError extends Error {
|
|
90
|
+
executionId;
|
|
91
|
+
constructor(
|
|
92
|
+
/** The execution whose saved outcome waits for its trace. */
|
|
93
|
+
executionId) {
|
|
94
|
+
super("Target outcome is saved but trace export acknowledgement is unavailable. Restore/export the trace or explicitly complete with omitted evidence through the client; never rerun the target.");
|
|
95
|
+
this.executionId = executionId;
|
|
96
|
+
this.name = "TraceExportUnacknowledgedError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
68
99
|
/** Thrown when the target or world may have committed but acknowledgement is unavailable. */
|
|
69
100
|
export class TargetOutcomeUncertainError extends Error {
|
|
70
101
|
executionId;
|
|
@@ -177,25 +208,96 @@ async function allPages(page, maximum = 5000) {
|
|
|
177
208
|
} while (after !== undefined);
|
|
178
209
|
return items;
|
|
179
210
|
}
|
|
211
|
+
/** A failure the next case would meet too: Hue not answering or refusing (a transient failure
|
|
212
|
+
* past the client's retries, a lost connection, a refusal such as a finished experiment or a
|
|
213
|
+
* world that cannot be sealed), telemetry not accepted by the one transport the cases share, or
|
|
214
|
+
* a target outcome left uncertain because its world could not be sealed or read back. Starting
|
|
215
|
+
* more cases into it only piles up the same failure; the run is resumed later instead. */
|
|
216
|
+
function systemic(error) {
|
|
217
|
+
// A saved outcome still waiting for its trace (`TraceExportUnacknowledgedError`) is not
|
|
218
|
+
// systemic: the flush failed on an earlier attempt, telemetry may have recovered since, and
|
|
219
|
+
// the cases beside it can complete now; only a live export failure stops new cases.
|
|
220
|
+
return (error instanceof HueApiError ||
|
|
221
|
+
error instanceof HueExportError ||
|
|
222
|
+
error instanceof TargetOutcomeUncertainError);
|
|
223
|
+
}
|
|
224
|
+
/** Runs every case, `concurrency` at a time. One case's own failure (an attempt without a saved
|
|
225
|
+
* outcome, an output that cannot be saved, inputs the SDK refuses) does not stop the cases
|
|
226
|
+
* beside or after it: each is attempted, and the failures are thrown together at the end, so a
|
|
227
|
+
* resume has only the failed cases left to publish. A systemic failure stops new cases from
|
|
228
|
+
* starting, while the cases in flight finish; the run resumes when Hue answers again. */
|
|
180
229
|
async function pool(items, concurrency, execute) {
|
|
181
230
|
let position = 0;
|
|
182
231
|
const failures = [];
|
|
232
|
+
let stop = false;
|
|
183
233
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
184
|
-
while (position < items.length && !
|
|
234
|
+
while (position < items.length && !stop) {
|
|
185
235
|
const item = items[position++];
|
|
186
236
|
try {
|
|
187
237
|
await execute(item);
|
|
188
238
|
}
|
|
189
239
|
catch (error) {
|
|
190
240
|
failures.push(error);
|
|
241
|
+
if (systemic(error))
|
|
242
|
+
stop = true;
|
|
191
243
|
}
|
|
192
244
|
}
|
|
193
245
|
}));
|
|
194
246
|
if (failures.length === 1)
|
|
195
247
|
throw failures[0];
|
|
196
|
-
if (failures.length)
|
|
197
|
-
|
|
248
|
+
if (failures.length) {
|
|
249
|
+
const first = failures[0];
|
|
250
|
+
throw new AggregateError(failures, `${failures.length} case operations failed; resume uses saved outcomes. First: ${first instanceof Error ? first.message : String(first)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Whether Hue holds the case's telemetry, read from the trace receipt: its root span and at
|
|
254
|
+
* least as many spans as the case ended; false when they are not there by the wait's end or the
|
|
255
|
+
* receipt cannot be read, and for a checkpoint an older runner saved without the span. */
|
|
256
|
+
/** Turn a prepared completion into a pending one: the trace is declared but still to arrive,
|
|
257
|
+
* and the span count the checkpoint recorded at the flush goes with it, so Hue freezes the trace
|
|
258
|
+
* only once it holds every span the case ended, never a root span alone. */
|
|
259
|
+
function pendingCompletion(prepared) {
|
|
260
|
+
prepared.complete.traceEvidence = "pending";
|
|
261
|
+
delete prepared.complete.omissionReason;
|
|
262
|
+
if (prepared.trace?.spans !== undefined)
|
|
263
|
+
prepared.complete.traceSpanCount = prepared.trace.spans;
|
|
264
|
+
}
|
|
265
|
+
/** The reverse, once the receipt accepted the trace after all: required evidence, no count. */
|
|
266
|
+
function acceptedCompletion(prepared) {
|
|
267
|
+
prepared.complete.traceEvidence = "required";
|
|
268
|
+
delete prepared.complete.traceSpanCount;
|
|
198
269
|
}
|
|
270
|
+
async function traceLanded(hue, trace) {
|
|
271
|
+
// Without the count of spans the case ended, the receipt cannot tell a lost child span from a
|
|
272
|
+
// landed trace, and does not accept.
|
|
273
|
+
if (!trace || trace.spans === undefined)
|
|
274
|
+
return false;
|
|
275
|
+
const deadline = Date.now() + RECEIPT_WAIT_MS;
|
|
276
|
+
try {
|
|
277
|
+
for (;;) {
|
|
278
|
+
// The root span present and every span the case ended held: a lost child span is not
|
|
279
|
+
// hidden behind a root that landed. The receipt answers as soon as the root is there, so
|
|
280
|
+
// spans still arriving behind it are polled for until the wait ends.
|
|
281
|
+
const verification = await hue.verifyTrace(trace.traceId, {
|
|
282
|
+
expectedSpanIds: [trace.spanId],
|
|
283
|
+
timeoutMillis: Math.max(250, deadline - Date.now()),
|
|
284
|
+
});
|
|
285
|
+
if (verification.verified && (verification.receipt?.spanCount ?? 0) >= trace.spans)
|
|
286
|
+
return true;
|
|
287
|
+
if (!verification.verified || Date.now() + RECEIPT_POLL_MS >= deadline)
|
|
288
|
+
return false;
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, RECEIPT_POLL_MS));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** How long a receipt is polled for the root span after a flush reported a failure: ingestion
|
|
297
|
+
* of an accepted batch is not instant, and a span that is not there by then was not delivered. */
|
|
298
|
+
const RECEIPT_WAIT_MS = 5_000;
|
|
299
|
+
/** How often the receipt is read again while the root is there but spans are still arriving. */
|
|
300
|
+
const RECEIPT_POLL_MS = 500;
|
|
199
301
|
async function scoresFor(versions, context, options, executionId, hasEnvironment = true) {
|
|
200
302
|
const scores = [];
|
|
201
303
|
let environmentUnavailable = false;
|
|
@@ -329,8 +431,16 @@ export async function runExperiment(options) {
|
|
|
329
431
|
try {
|
|
330
432
|
const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
|
|
331
433
|
const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
|
|
434
|
+
// A target stopped at its world's deadline is a `TargetTimeout`, told apart from an error the
|
|
435
|
+
// target raised itself: the agent did not finish, rather than finishing wrongly. A service the
|
|
436
|
+
// agent called that refused, dropped the connection or timed out is named apart from the
|
|
437
|
+
// agent's own error (`failure.ts`), so Hue counts it as infrastructure, not as the agent.
|
|
332
438
|
const errorPayload = (error) => ({
|
|
333
|
-
type: error instanceof OutputTooLargeError
|
|
439
|
+
type: error instanceof OutputTooLargeError
|
|
440
|
+
? "OutputTooLarge"
|
|
441
|
+
: error instanceof TargetTimeoutError
|
|
442
|
+
? "TargetTimeout"
|
|
443
|
+
: (serviceFailureType(error) ?? "TargetError"),
|
|
334
444
|
...(options.persistResultContent && error instanceof Error
|
|
335
445
|
? { message: sanitize(error.message) }
|
|
336
446
|
: {}),
|
|
@@ -378,6 +488,7 @@ export async function runExperiment(options) {
|
|
|
378
488
|
complete,
|
|
379
489
|
scores,
|
|
380
490
|
exportState: "pending",
|
|
491
|
+
...(saved.trace ? { trace: saved.trace } : {}),
|
|
381
492
|
};
|
|
382
493
|
await store.write(file, prepared);
|
|
383
494
|
return prepared;
|
|
@@ -533,6 +644,7 @@ export async function runExperiment(options) {
|
|
|
533
644
|
const uploading = {
|
|
534
645
|
stage: "uploading",
|
|
535
646
|
executionId: execution.id,
|
|
647
|
+
trace: { traceId: span.traceId, spanId: span.spanId },
|
|
536
648
|
state,
|
|
537
649
|
hasOutput: output !== undefined,
|
|
538
650
|
...(options.persistResultContent && output !== undefined ? { output } : {}),
|
|
@@ -553,13 +665,50 @@ export async function runExperiment(options) {
|
|
|
553
665
|
// End root before waiting for both OTLP signals. Export failure leaves the prepared checkpoint intact.
|
|
554
666
|
let exportError;
|
|
555
667
|
try {
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
668
|
+
// The flush itself throws for any failure since the last flush; the issues it recorded
|
|
669
|
+
// are read below, where they are attributed.
|
|
670
|
+
await options.hue.flush().catch((error) => {
|
|
671
|
+
if (!(error instanceof HueExportError))
|
|
672
|
+
throw error;
|
|
673
|
+
});
|
|
674
|
+
// The transport is shared by every case in flight, so a failure the flush reports may
|
|
675
|
+
// be another case's. Each issue names the traces of the records it concerned: one that
|
|
676
|
+
// names this trace is this case's failure, final because its evidence is incomplete;
|
|
677
|
+
// one that names only others is not this case's; one that names no trace (a processor
|
|
678
|
+
// flush that failed) may be anyone's, and this case's trace receipt decides: its root
|
|
679
|
+
// span landed, or the case stays pending for the receipt to decide again on resume.
|
|
680
|
+
const decided = checkpoint;
|
|
681
|
+
const traceId = decided.trace?.traceId;
|
|
682
|
+
// Every span the case ended has reached the exporter by now; the receipt is held to
|
|
683
|
+
// that count, so a lost child span is not hidden behind a root span that landed. The
|
|
684
|
+
// count is saved before anything is decided, so a process that stops here leaves a
|
|
685
|
+
// pending checkpoint the receipt can be held to; a transport that does not count (an
|
|
686
|
+
// older or borrowed one) leaves no count, and the receipt then cannot accept.
|
|
687
|
+
if (decided.trace && traceId !== undefined) {
|
|
688
|
+
const spans = options.hue.transport.spansEnded?.(traceId);
|
|
689
|
+
if (spans !== undefined) {
|
|
690
|
+
decided.trace.spans = spans;
|
|
691
|
+
await store.write(file, decided);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (options.hue.transport.getFailureSequence() !== failureSequenceBefore) {
|
|
695
|
+
const retained = options.hue.transport.getIssues();
|
|
696
|
+
const issues = retained.filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning");
|
|
697
|
+
const named = issues.filter((issue) => issue.traceIds && (traceId === undefined || issue.traceIds.includes(traceId)));
|
|
698
|
+
// An issue naming no trace, and a failure whose issue the bounded history has already
|
|
699
|
+
// rolled past (the oldest retained issue is younger than the first this case could
|
|
700
|
+
// have caused), may be anyone's.
|
|
701
|
+
const unattributed = issues.filter((issue) => !issue.traceIds);
|
|
702
|
+
const evicted = issues.length === 0 || (retained[0]?.sequence ?? 0) > failureSequenceBefore + 1;
|
|
703
|
+
if (named.length) {
|
|
704
|
+
checkpoint.exportState = "failed";
|
|
705
|
+
await store.write(file, checkpoint);
|
|
706
|
+
throw new HueExportError([...named, ...unattributed], options.hue.transport.getReport());
|
|
707
|
+
}
|
|
708
|
+
if ((unattributed.length || evicted) &&
|
|
709
|
+
!(await traceLanded(options.hue, decided.trace)))
|
|
710
|
+
throw new HueExportError(unattributed, options.hue.transport.getReport());
|
|
711
|
+
}
|
|
563
712
|
}
|
|
564
713
|
catch (error) {
|
|
565
714
|
exportError = error;
|
|
@@ -575,7 +724,15 @@ export async function runExperiment(options) {
|
|
|
575
724
|
if (options.traceEvidence.mode !== "omit")
|
|
576
725
|
throw error;
|
|
577
726
|
}
|
|
578
|
-
if (exportError !== undefined &&
|
|
727
|
+
if (exportError !== undefined &&
|
|
728
|
+
options.traceEvidence.mode !== "omit" &&
|
|
729
|
+
options.traceNotAccepted === "pending") {
|
|
730
|
+
// The outcome stands as the target gave it; Hue attaches the trace once it holds
|
|
731
|
+
// every span the case ended, which the checkpoint's count tells it.
|
|
732
|
+
pendingCompletion(checkpoint);
|
|
733
|
+
await store.write(file, checkpoint);
|
|
734
|
+
}
|
|
735
|
+
else if (exportError !== undefined && options.traceEvidence.mode !== "omit") {
|
|
579
736
|
if (options.traceNotAccepted !== "fail_case")
|
|
580
737
|
throw exportError;
|
|
581
738
|
// Required evidence that Hue did not accept fails the case rather than leaving its
|
|
@@ -606,8 +763,35 @@ export async function runExperiment(options) {
|
|
|
606
763
|
}
|
|
607
764
|
const prepared = checkpoint;
|
|
608
765
|
await options.client.getExecution(prepared.executionId);
|
|
609
|
-
|
|
610
|
-
|
|
766
|
+
// A saved outcome no flush decided on is asked about through its trace receipt: the root
|
|
767
|
+
// span there says the case's export landed before the process stopped, or that a later
|
|
768
|
+
// flush of the same transport delivered it. An export failure that named this trace is
|
|
769
|
+
// final: the evidence is incomplete whatever the receipt holds.
|
|
770
|
+
if (prepared.exportState === "pending" &&
|
|
771
|
+
prepared.complete.traceEvidence !== "omit" &&
|
|
772
|
+
(await traceLanded(options.hue, prepared.trace))) {
|
|
773
|
+
prepared.exportState = "accepted";
|
|
774
|
+
// A completion made pending by an earlier receipt read is required again: the trace
|
|
775
|
+
// landed, so Hue freezes it at completion as for any accepted case.
|
|
776
|
+
if (prepared.complete.traceEvidence === "pending")
|
|
777
|
+
acceptedCompletion(prepared);
|
|
778
|
+
await store.write(file, prepared);
|
|
779
|
+
}
|
|
780
|
+
// Under the pending policy a saved outcome the receipt does not accept, whether no flush
|
|
781
|
+
// decided it or an export failure named its trace, completes with the trace pending rather
|
|
782
|
+
// than being refused: Hue attaches the trace when it arrives in full.
|
|
783
|
+
if (options.traceNotAccepted === "pending" &&
|
|
784
|
+
prepared.exportState !== "accepted" &&
|
|
785
|
+
prepared.exportState !== "not_accepted" &&
|
|
786
|
+
prepared.complete.traceEvidence !== "omit" &&
|
|
787
|
+
prepared.complete.traceEvidence !== "pending") {
|
|
788
|
+
pendingCompletion(prepared);
|
|
789
|
+
await store.write(file, prepared);
|
|
790
|
+
}
|
|
791
|
+
if (prepared.exportState !== "accepted" &&
|
|
792
|
+
prepared.complete.traceEvidence !== "omit" &&
|
|
793
|
+
prepared.complete.traceEvidence !== "pending")
|
|
794
|
+
throw new TraceExportUnacknowledgedError(prepared.executionId);
|
|
611
795
|
const save = () => store.write(file, prepared);
|
|
612
796
|
const notAccepted = prepared.exportState === "not_accepted"
|
|
613
797
|
? {
|
|
@@ -628,6 +812,15 @@ export async function runExperiment(options) {
|
|
|
628
812
|
}
|
|
629
813
|
if (notAccepted)
|
|
630
814
|
(report.telemetryNotAccepted ??= []).push(notAccepted);
|
|
815
|
+
if (prepared.complete.traceEvidence === "pending")
|
|
816
|
+
(report.tracePending ??= []).push({
|
|
817
|
+
caseId: item.id,
|
|
818
|
+
caseKey: item.externalKey,
|
|
819
|
+
executionId: prepared.executionId,
|
|
820
|
+
...(prepared.complete.traceSpanCount !== undefined
|
|
821
|
+
? { spanCount: prepared.complete.traceSpanCount }
|
|
822
|
+
: {}),
|
|
823
|
+
});
|
|
631
824
|
const results = await uploadScores(options, experiment.evaluation.id, prepared.scores, save, versions);
|
|
632
825
|
report.subjectIds.push(prepared.completion.subjectId);
|
|
633
826
|
report.resultIds.push(...results);
|
|
@@ -160,7 +160,7 @@ export interface RunSimulationOptions {
|
|
|
160
160
|
};
|
|
161
161
|
/** What a case does when required telemetry is not accepted; see
|
|
162
162
|
* `traceNotAccepted` of {@link runExperiment}. Defaults to `"stop"`. */
|
|
163
|
-
traceNotAccepted?: "stop" | "fail_case";
|
|
163
|
+
traceNotAccepted?: "stop" | "fail_case" | "pending";
|
|
164
164
|
/** Called as each case is failed for its telemetry; see `onTelemetryNotAccepted` of
|
|
165
165
|
* {@link runExperiment}. */
|
|
166
166
|
onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
|