@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.
@@ -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, OutcomeSerializationError, TargetOutcomeUncertainError, UncertainExecutionError, } from "./runner.js";
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
- function needsAttention(error, seen = new Set()) {
102
- if (error instanceof TargetOutcomeUncertainError ||
103
- error instanceof UncertainExecutionError ||
104
- error instanceof OutcomeSerializationError)
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.some((nested) => needsAttention(nested, seen));
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
- const project = await options.client.checkConnection();
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 completed = 0;
142
- while (completed < maxRuns && !options.signal?.aborted) {
143
- const agent = await options.client.registerLocalAgent({
144
- ...options.agent,
145
- capabilities,
146
- scorerDigests: options.agent.scorerDigests ??
147
- options.scorers?.map((item) => item.definition.sourceDigest) ??
148
- [],
149
- });
150
- const claim = await options.client.claimLocalAgentRun({ agentId: agent.id, workerId });
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
- // A durable outcome can still need completion/result uploads. Leave operational
226
- // failures claimed so the same worker can resume them through its checkpoints.
227
- // Attention is terminal in the queue and is reserved for explicit unsafe-to-resume
228
- // outcomes that require operator intervention.
229
- if (!experimentFinished && needsAttention(error))
230
- await options.client
231
- .completeLocalAgentRun({
232
- runId: claim.runId,
233
- workerId,
234
- state: "attention",
235
- failureType: error instanceof Error ? error.name.slice(0, 200) : "WorkerError",
236
- })
237
- .catch(() => undefined);
238
- throw error;
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);
@@ -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. */
@@ -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 && !failures.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
- throw new AggregateError(failures, "Multiple case operations failed; resume uses saved outcomes");
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 ? "OutputTooLarge" : "TargetError",
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
- await options.hue.flush();
557
- // Another concurrent flush may already have surfaced this failure. OTLP
558
- // partial acknowledgements do not identify individual rejected records.
559
- if (options.hue.transport.getFailureSequence() !== failureSequenceBefore)
560
- throw new HueExportError(options.hue.transport
561
- .getIssues()
562
- .filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning"), options.hue.transport.getReport());
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 && options.traceEvidence.mode !== "omit") {
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
- if (prepared.exportState !== "accepted" && prepared.complete.traceEvidence !== "omit")
610
- throw new Error("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.");
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>;