@centerforagenticai/pi-multi-account 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +17 -3
  2. package/package.json +5 -5
  3. package/packages/pi-anthropic-oauth/package.json +2 -2
  4. package/packages/pi-anthropic-oauth/src/stream.ts +26 -8
  5. package/packages/pi-anthropic-oauth/src/transport-activity.ts +59 -0
  6. package/packages/pi-antigravity/package.json +2 -2
  7. package/packages/pi-antigravity/src/models/discovery.ts +2 -1
  8. package/packages/pi-antigravity/src/models/grouping.ts +12 -10
  9. package/packages/pi-antigravity/src/models/models.ts +11 -4
  10. package/src/account-group-failure.ts +149 -0
  11. package/src/account-group-members.ts +139 -0
  12. package/src/anthropic-adaptive-stream.ts +18 -9
  13. package/src/anthropic-alias-stream.ts +13 -68
  14. package/src/codex-adapter.ts +78 -50
  15. package/src/commands.ts +3 -3
  16. package/src/config.ts +197 -2
  17. package/src/diagnostic-store.ts +17 -15
  18. package/src/diagnostics.ts +97 -35
  19. package/src/host-final-stop-message.ts +20 -118
  20. package/src/index.ts +452 -87
  21. package/src/logical-dispatch.ts +12 -13
  22. package/src/logical-provider.ts +1282 -355
  23. package/src/model-fallback-policy.ts +384 -0
  24. package/src/models-declaration.ts +15 -8
  25. package/src/public-assistant-projection.ts +163 -0
  26. package/src/recovery-engine.ts +648 -110
  27. package/src/recovery-plan.ts +7 -1
  28. package/src/recovery-send-evidence.ts +29 -0
  29. package/src/refusal-advice.ts +139 -0
  30. package/src/routing.ts +4 -8
  31. package/src/runtime-state.ts +7 -0
  32. package/src/shared-usage.ts +21 -4
  33. package/src/upstream-anthropic.ts +12 -4
  34. package/src/upstream-antigravity.ts +2 -38
  35. package/src/usage-fetch.ts +42 -59
@@ -10,7 +10,9 @@ import {
10
10
  createAcceptedOutputStream,
11
11
  type AcceptedOutputErrorCode,
12
12
  } from "./recovery-output.js";
13
- import type { RecoveryCandidate } from "./recovery-plan.js";
13
+ import type { RecoveryActionKind, RecoveryCandidate } from "./recovery-plan.js";
14
+ import { classifyCodexRecoverySendEvidence } from "./recovery-send-evidence.js";
15
+ import { isCanonicalManagedProviderId } from "./runtime-state.js";
14
16
 
15
17
  /** A request-owned timer. Cancelling it must prevent its callback from running. */
16
18
  export interface RecoveryTimer {
@@ -33,8 +35,22 @@ export type RecoveryPreDispatchDecision =
33
35
 
34
36
  export type RecoveryRetrySafety =
35
37
  | {
36
- readonly status: "retryable";
37
- readonly reason: "generation-only-incomplete" | "definitively-rejected";
38
+ readonly status: "recoverable";
39
+ readonly action: "account";
40
+ readonly reason:
41
+ | "account-local-quota"
42
+ | "account-local-auth"
43
+ | "account-local-rate-limit"
44
+ /**
45
+ * A network or transport failure, or a stall, before the attempt
46
+ * produced any output. Only valid while nothing has been observed;
47
+ * the engine converts it to `uncertain-external-effects` otherwise.
48
+ */
49
+ | "pre-start-transient";
50
+ }
51
+ | {
52
+ readonly status: "model-policy";
53
+ readonly reason: "structured-provider-error";
38
54
  }
39
55
  | {
40
56
  readonly status: "unsafe";
@@ -42,38 +58,40 @@ export type RecoveryRetrySafety =
42
58
  | "uncertain-external-effects"
43
59
  | "completed-tool"
44
60
  | "completed-call"
45
- | "agent-run";
61
+ | "agent-run"
62
+ | "invalid-request"
63
+ | "refusal"
64
+ | "unknown";
46
65
  };
47
66
 
67
+ export type RecoveryModelDecision =
68
+ | {
69
+ readonly status: "recoverable";
70
+ readonly action: "model";
71
+ readonly reason: "unsupported-model" | "unsupported-capability";
72
+ }
73
+ | { readonly status: "none" };
74
+
48
75
  /** Request options after recovery has replaced the provider's inner retry allowance. */
49
76
  export type RecoveryBoundStreamOptions = SimpleStreamOptions & {
50
77
  readonly maxRetries: number;
51
78
  };
52
79
 
53
80
  /**
54
- * A pre-send reservation is an upper bound, not an observed charge count.
55
- *
56
- * `deliberate` is part of the type so future accounting cannot "tighten" the
57
- * reservation to an optimistic count. Adaptive Anthropic may use three SDK
58
- * sends under the provenance-locked adapter. A Codex WebSocket-capable request
59
- * may send three socket frames and then one SSE request. Recovery reserves all
60
- * sends that could be charged even though fewer (often one) normally occur.
81
+ * Every supported bounded provider invocation reserves one possibly charged
82
+ * send. Pinned Codex non-SSE transport can reconnect or fall back internally,
83
+ * and the vendored Google Antigravity stream ignores `maxRetries` and loops over
84
+ * empty-response retries, runtime-model candidates and endpoint fallbacks, so
85
+ * both counts are explicitly unknown and can never authorize another send.
61
86
  */
62
87
  export type RecoverySendReservation =
63
88
  | {
64
89
  readonly maximumPossiblyChargedSends: 1;
65
- readonly overReservation: "none";
66
- readonly basis: "bounded-http";
90
+ readonly basis: "bounded-provider-invocation";
67
91
  }
68
92
  | {
69
- readonly maximumPossiblyChargedSends: 3;
70
- readonly overReservation: "deliberate";
71
- readonly basis: "adaptive-anthropic-provenance";
72
- }
73
- | {
74
- readonly maximumPossiblyChargedSends: 4;
75
- readonly overReservation: "deliberate";
76
- readonly basis: "codex-websocket-uncertain";
93
+ readonly maximumPossiblyChargedSends: "unknown";
94
+ readonly basis: "codex-non-sse-unknown" | "antigravity-inner-unknown";
77
95
  };
78
96
 
79
97
  export interface RecoverySendReservationRequest {
@@ -90,18 +108,20 @@ export interface RecoverySendReservationRequest {
90
108
  export type RecoveryChargedSendExposure =
91
109
  | {
92
110
  readonly status: "bounded";
93
- readonly maximumPossiblyChargedSends: 1 | 3;
111
+ readonly maximumPossiblyChargedSends: 1;
94
112
  }
95
113
  | {
96
114
  readonly status: "unknown";
97
- readonly reason: "codex-websocket-send";
115
+ readonly reason: "codex-non-sse-send-count" | "antigravity-inner-sends";
98
116
  };
99
117
 
100
118
  /**
101
119
  * One supported provider invocation. `retrySafety` must classify the final
102
120
  * outbound payload after caller payload replacement and every provider callback.
103
- * Recovery independently overrides that classification for WebSocket-capable
104
- * Codex attempts because a completed socket send has uncertain external effects.
121
+ * Recovery independently overrides that classification for non-SSE Codex
122
+ * attempts unless `classifyCodexRecoverySendEvidence` proves a single
123
+ * pre-execution rejection, because a socket send may have reconnected or fallen
124
+ * back to SSE with uncertain external effects.
105
125
  */
106
126
  export interface RecoveryPhysicalAttempt {
107
127
  readonly output: AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>;
@@ -113,6 +133,12 @@ export interface RecoveryDispatchRequest {
113
133
  readonly context: unknown;
114
134
  readonly options: RecoveryBoundStreamOptions;
115
135
  readonly signal: AbortSignal;
136
+ /**
137
+ * Report bytes arriving on this attempt's connection, keep-alive pings
138
+ * included. It resets only the request's idle limit: transport activity is
139
+ * not output and never makes an attempt ineligible for recovery.
140
+ */
141
+ readonly onTransportActivity: () => void;
116
142
  }
117
143
 
118
144
  export type RecoveryUsageCoverage =
@@ -133,7 +159,13 @@ export type RecoveryCostCoverage =
133
159
  export interface RecoveryAttemptAccounting {
134
160
  readonly ordinal: number;
135
161
  readonly candidate: RecoveryCandidate;
136
- readonly disposition: "failed" | "accepted";
162
+ /**
163
+ * `committed` means the attempt started streaming under
164
+ * `stream-after-first-content` publication and now belongs to the caller; its
165
+ * usage and cost are unknown here and the caller retains them at the
166
+ * physical terminal.
167
+ */
168
+ readonly disposition: "failed" | "accepted" | "committed";
137
169
  readonly failureCode?: AcceptedOutputErrorCode | "dispatch-failure";
138
170
  readonly reservation: RecoverySendReservation;
139
171
  readonly chargedSendExposure: RecoveryChargedSendExposure;
@@ -141,8 +173,37 @@ export interface RecoveryAttemptAccounting {
141
173
  readonly cost: RecoveryCostCoverage;
142
174
  }
143
175
 
176
+ /**
177
+ * Content-free structured projection of a failed terminal for model policy.
178
+ *
179
+ * The engine copies only these named fields; assistant content, thinking, tool
180
+ * calls, error text, raw stop reasons, response identifiers, usage and
181
+ * diagnostic messages/details never reach the hook. A policy must decide from
182
+ * these structured facts alone and must never scan provider or assistant text.
183
+ */
184
+ export interface RecoveryModelFailure {
185
+ readonly stopReason: AssistantMessage["stopReason"];
186
+ readonly api: string;
187
+ readonly provider: string;
188
+ readonly model: string;
189
+ /** Only whether the terminal carried an error message, never its text. */
190
+ readonly hasErrorMessage: boolean;
191
+ /** Diagnostic `type` identifiers only, in terminal order. */
192
+ readonly diagnosticTypes: readonly string[];
193
+ /**
194
+ * Structured provider stop code, copied only from the two-value allowlist.
195
+ * Absent for any other value; never derived from error or assistant text.
196
+ */
197
+ readonly code?: "refusal" | "unknown_stop";
198
+ }
199
+
144
200
  export interface RecoveryEngineDependencies {
145
201
  readonly clock: RecoveryClock;
202
+ /** #129 owns this structured policy hook. Omission means no model recovery. */
203
+ readonly classifyModelRecovery?: (
204
+ failure: RecoveryModelFailure,
205
+ signal: AbortSignal,
206
+ ) => RecoveryModelDecision | Promise<RecoveryModelDecision>;
146
207
  /** Re-read eligibility, live config authorization and credentials here. */
147
208
  readonly recheck: (
148
209
  candidate: RecoveryCandidate,
@@ -168,8 +229,26 @@ export type RecoveryTimingConfig = Pick<
168
229
  "recoveryIdleTimeoutMs" | "recoveryAbsoluteTimeoutMs"
169
230
  >;
170
231
 
232
+ /**
233
+ * How an attempt's output is published.
234
+ *
235
+ * `buffered` (the default) holds every event until a successful terminal, so a
236
+ * failure at any point may still recover. `stream-after-first-content` holds
237
+ * the attempt's leading `start` events, which a provider pushes as soon as the
238
+ * response headers arrive and before any content: they are neither published
239
+ * nor counted as progress. The first content event (text, thinking or a tool
240
+ * call) commits the attempt and streams one held `start`, that event and the
241
+ * rest of the attempt live to the caller with no further recovery. A terminal,
242
+ * malformed or missing event, or a thrown failure, before any content falls
243
+ * back to the buffered path, so it may still recover and a failed attempt's
244
+ * held `start` is never published. A committed attempt is never retried, so
245
+ * output already shown to a consumer is never followed by a second send.
246
+ */
247
+ export type RecoveryPublication = "buffered" | "stream-after-first-content";
248
+
171
249
  export interface RecoveryRequest {
172
250
  readonly candidates: readonly RecoveryCandidate[];
251
+ readonly publication?: RecoveryPublication;
173
252
  readonly context: unknown;
174
253
  readonly options?: SimpleStreamOptions;
175
254
  readonly timing: RecoveryTimingConfig;
@@ -192,7 +271,10 @@ export type RecoveryTerminationReason =
192
271
  | "uncertain-external-effects"
193
272
  | "completed-tool"
194
273
  | "completed-call"
195
- | "agent-run";
274
+ | "agent-run"
275
+ | "invalid-request"
276
+ | "refusal"
277
+ | "unknown";
196
278
 
197
279
  export type RecoveryResult =
198
280
  | {
@@ -202,11 +284,29 @@ export type RecoveryResult =
202
284
  readonly terminal: AssistantMessage;
203
285
  readonly output: AssistantMessageEventStream;
204
286
  }
205
- | { readonly status: "exhausted"; readonly attempts: number }
287
+ | {
288
+ /**
289
+ * The attempt produced content under `stream-after-first-content`
290
+ * publication. `output` yields its one held `start` (when it sent
291
+ * one), the first content event and then the rest of the physical
292
+ * stream unbuffered; the caller owns its terminal, timers and abort
293
+ * from here on.
294
+ */
295
+ readonly status: "committed";
296
+ readonly candidate: RecoveryCandidate;
297
+ readonly attempts: number;
298
+ readonly output: AsyncIterable<unknown>;
299
+ }
300
+ | {
301
+ readonly status: "exhausted";
302
+ readonly attempts: number;
303
+ readonly errorMessage: string;
304
+ }
206
305
  | {
207
306
  readonly status: "terminated";
208
307
  readonly reason: RecoveryTerminationReason;
209
308
  readonly attempts: number;
309
+ readonly errorMessage: string;
210
310
  };
211
311
 
212
312
  export interface RecoveryEngine {
@@ -235,15 +335,54 @@ interface ActiveRequest {
235
335
  const GAP_USAGE: RecoveryUsageCoverage = Object.freeze({ coverage: "gap" });
236
336
  const GAP_COST: RecoveryCostCoverage = Object.freeze({ coverage: "gap" });
237
337
 
338
+ /**
339
+ * One unified logical call owns at most two provider invocations: the initial
340
+ * invocation plus one recovery invocation. Raising this value allows a third
341
+ * invocation and must fail the named `RECOVERY-TWO-SEND-CAP` regression.
342
+ *
343
+ * For the bounded HTTP/SSE families (Anthropic, OpenAI, Codex SSE) inner
344
+ * provider retries are forced to zero, so each invocation is one possibly
345
+ * charged send and the call makes at most two sends. Both Anthropic
346
+ * paths honour that zero: the adaptive adapter
347
+ * (`src/anthropic-adaptive-stream.ts`) and the vendored pinned stream
348
+ * (`packages/pi-anthropic-oauth/src/stream.ts`, local patch 0004) forward a
349
+ * finite non-negative integer `maxRetries` into `client.messages.create`.
350
+ *
351
+ * A Codex non-SSE (auto/WebSocket) invocation has an unknown internal send
352
+ * count: pinned pi-ai 0.84.4 may reconnect or fall back to SSE inside one
353
+ * invocation. The engine therefore never uses such an invocation for the
354
+ * recovery send, but it cannot control the internal count of a non-SSE initial
355
+ * invocation. Consumers, including the production cutover, must not claim the
356
+ * at-most-two-sends guarantee for Codex auto/WebSocket calls; only the
357
+ * two-invocation bound holds there. Routed Codex calls are currently pinned to
358
+ * SSE by `forceCodexSseOptions` (`src/codex-adapter.ts`), so a caller that
359
+ * passes the forced options here receives the bounded-HTTP reservation.
360
+ *
361
+ * A Google Antigravity invocation also has an unknown internal send count: the
362
+ * vendored stream (`packages/pi-antigravity/src/stream/stream.ts`) ignores
363
+ * `maxRetries` and can send once per empty-response retry (up to three), runtime
364
+ * model candidate, and `ENDPOINT_FALLBACKS` entry
365
+ * (`packages/pi-antigravity/src/client/client.ts`, three endpoints). The engine
366
+ * reserves it as unknown, never uses it for the recovery send, and terminates
367
+ * with `uncertain-external-effects` after a failed initial Antigravity
368
+ * invocation. Consumers must not claim the at-most-two-sends guarantee for
369
+ * Antigravity calls; only the two-invocation bound holds there.
370
+ */
371
+ export const RECOVERY_MAX_PROVIDER_SENDS_PER_CALL = 2 as const;
372
+
238
373
  /**
239
374
  * Supported request-local retry controls. Keep every managed family's entry
240
375
  * separate: each provider path has its own compile-valid mutation control and
241
- * regression test. The provenance-locked adaptive Anthropic adapter ignores
242
- * this option, so its possible three SDK sends are over-reserved instead of
243
- * assumed away. Google Antigravity has no supported inner-retry behavior yet,
244
- * so it gets the same conservative zero the other non-Anthropic families use.
376
+ * regression test. Anthropic's zero bounds both Anthropic paths: the adaptive
377
+ * adapter (`ANTHROPIC-MAX-RETRIES-ZERO`) and the vendored pinned stream
378
+ * (`ANTHROPIC-PINNED-MAX-RETRIES`) each forward this request-local `maxRetries`
379
+ * into `client.messages.create`, so the SDK's two default retries never run.
380
+ * Google Antigravity receives the same fail-closed zero, but its vendored stream
381
+ * ignores it; its unknown inner send count is handled by the
382
+ * `antigravity-inner-unknown` reservation instead (see
383
+ * {@link RECOVERY_MAX_PROVIDER_SENDS_PER_CALL}).
245
384
  */
246
- const RECOVERY_INNER_RETRY_LIMITS = Object.freeze({
385
+ export const RECOVERY_INNER_RETRY_LIMITS = Object.freeze({
247
386
  anthropic: 0,
248
387
  openai: 0,
249
388
  "openai-codex": 0,
@@ -264,40 +403,63 @@ function sendReservationFor(
264
403
  candidate: RecoveryCandidate,
265
404
  options: RecoveryBoundStreamOptions,
266
405
  ): RecoverySendReservation {
267
- if (candidate.family === "anthropic") {
268
- // Recovery cannot see the model compat selector. Reserve the provenance-locked
269
- // adaptive SDK's initial send plus two retries for every Anthropic candidate.
406
+ if (candidate.family === "openai-codex" && options.transport !== "sse") {
407
+ // Pinned pi-ai 0.84.4 can resend `response.create` once for
408
+ // previous_response_not_found, once for a connection-limit error, then fall
409
+ // back to one SSE send (`dist/api/openai-codex-responses.js:214-245`), so one
410
+ // invocation may make up to four sends. The supported terminal carries no
411
+ // trustworthy complete count, so this invocation can never authorize a send.
412
+ // Recovery cannot bound the internal sends of an initial non-SSE invocation
413
+ // and never forces SSE; that measured limit remains open for the cutover.
270
414
  return Object.freeze({
271
- maximumPossiblyChargedSends: 3,
272
- overReservation: "deliberate",
273
- basis: "adaptive-anthropic-provenance",
415
+ maximumPossiblyChargedSends: "unknown",
416
+ basis: "codex-non-sse-unknown",
274
417
  });
275
418
  }
276
- if (candidate.family === "openai-codex" && options.transport !== "sse") {
277
- // Pinned Codex can reconnect twice after socket.send, then fall back to one
278
- // bounded SSE send. The supported API exposes no narrower send observation.
419
+ if (candidate.family === "google-antigravity") {
420
+ // The vendored stream ignores `maxRetries` and loops over empty-response
421
+ // retries, runtime-model candidates and endpoint fallbacks inside one
422
+ // invocation, so it can make several sends with no trustworthy count.
279
423
  return Object.freeze({
280
- maximumPossiblyChargedSends: 4,
281
- overReservation: "deliberate",
282
- basis: "codex-websocket-uncertain",
424
+ maximumPossiblyChargedSends: "unknown",
425
+ basis: "antigravity-inner-unknown",
283
426
  });
284
427
  }
285
428
  return Object.freeze({
286
429
  maximumPossiblyChargedSends: 1,
287
- overReservation: "none",
288
- basis: "bounded-http",
430
+ basis: "bounded-provider-invocation",
289
431
  });
290
432
  }
291
433
 
292
434
  function chargedSendExposureFor(
293
435
  reservation: RecoverySendReservation,
294
436
  ): RecoveryChargedSendExposure {
295
- return reservation.basis === "codex-websocket-uncertain"
296
- ? Object.freeze({ status: "unknown", reason: "codex-websocket-send" })
297
- : Object.freeze({
298
- status: "bounded",
299
- maximumPossiblyChargedSends: reservation.maximumPossiblyChargedSends,
300
- });
437
+ switch (reservation.basis) {
438
+ case "codex-non-sse-unknown":
439
+ return Object.freeze({ status: "unknown", reason: "codex-non-sse-send-count" });
440
+ case "antigravity-inner-unknown":
441
+ return Object.freeze({ status: "unknown", reason: "antigravity-inner-sends" });
442
+ case "bounded-provider-invocation":
443
+ return Object.freeze({ status: "bounded", maximumPossiblyChargedSends: 1 });
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Content-free terminal error text for an exhausted or terminated recovery.
449
+ *
450
+ * Host retries count toward the same two-send cap. Pinned pi-coding-agent 0.84.4
451
+ * `AgentSession._isRetryableError` (`dist/core/agent-session.js:2241-2246`)
452
+ * restarts the turn when `isRetryableAssistantError` from
453
+ * `@earendil-works/pi-ai/compat` matches the final `errorMessage`
454
+ * (`pi-ai/dist/utils/retry.js:166-173`, patterns at `:4-77`), and its overflow
455
+ * path compact-and-retries when `isContextOverflow` matches
456
+ * (`agent-session.js:1652-1690`, `pi-ai/dist/utils/overflow.js:130-156`). A
457
+ * production caller must surface this message, never a provider's own text, so
458
+ * neither host path can add a third send. The named `HOST-RETRY-BOUNDARY`
459
+ * regression checks both classifiers directly.
460
+ */
461
+ export function buildBoundedRecoveryFinalErrorMessage(): string {
462
+ return "Unified recovery stopped after its bounded provider attempt.";
301
463
  }
302
464
 
303
465
  function finiteNonNegative(value: unknown): number | undefined {
@@ -352,8 +514,26 @@ function terminalFromEvent(event: unknown): unknown {
352
514
  return undefined;
353
515
  }
354
516
 
517
+ function assistantTerminal(value: unknown): AssistantMessage | undefined {
518
+ if (typeof value !== "object" || value === null) return undefined;
519
+ const terminal = value as Partial<AssistantMessage>;
520
+ return terminal.role === "assistant" &&
521
+ Array.isArray(terminal.content) &&
522
+ typeof terminal.api === "string" &&
523
+ typeof terminal.provider === "string" &&
524
+ typeof terminal.model === "string" &&
525
+ typeof terminal.stopReason === "string" &&
526
+ typeof terminal.timestamp === "number"
527
+ ? (terminal as AssistantMessage)
528
+ : undefined;
529
+ }
530
+
531
+ /**
532
+ * Assistant content a consumer would see. A provider's `start` event is not
533
+ * here: it only reports that the response headers arrived, so it is held, never
534
+ * counted as progress or output, and a failure after it is still pre-output.
535
+ */
355
536
  const PROGRESS_EVENT_TYPES = new Set([
356
- "start",
357
537
  "text_start",
358
538
  "text_delta",
359
539
  "text_end",
@@ -373,9 +553,27 @@ function isProgressEvent(event: unknown): boolean {
373
553
  );
374
554
  }
375
555
 
556
+ /**
557
+ * Whether an event is assistant content (text, thinking or a tool call). Only
558
+ * content counts as output for in-call recovery; a `start` never does.
559
+ */
560
+ export function isRecoveryContentEvent(event: unknown): boolean {
561
+ return isProgressEvent(event);
562
+ }
563
+
564
+ /** Whether an event is the provider's `start`, held until the first content. */
565
+ export function isRecoveryStartEvent(event: unknown): boolean {
566
+ return (
567
+ typeof event === "object" &&
568
+ event !== null &&
569
+ (event as { type?: unknown }).type === "start"
570
+ );
571
+ }
572
+
376
573
  function observedOutput(
377
574
  upstream: AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>,
378
575
  onProgress: () => void,
576
+ onTerminal: (terminal: AssistantMessage) => void,
379
577
  onFacts: (facts: AttemptFacts) => void,
380
578
  ): AsyncIterable<unknown> {
381
579
  return {
@@ -383,8 +581,12 @@ function observedOutput(
383
581
  const source = await upstream;
384
582
  for await (const event of source) {
385
583
  if (isProgressEvent(event)) onProgress();
386
- const facts = projectAttemptFacts(terminalFromEvent(event));
387
- if (facts !== undefined) onFacts(facts);
584
+ const terminal = assistantTerminal(terminalFromEvent(event));
585
+ if (terminal !== undefined) {
586
+ onTerminal(terminal);
587
+ const facts = projectAttemptFacts(terminal);
588
+ if (facts !== undefined) onFacts(facts);
589
+ }
388
590
  yield event;
389
591
  }
390
592
  },
@@ -450,11 +652,25 @@ function validCandidate(candidate: RecoveryCandidate): boolean {
450
652
  // future family addition cannot silently fall out of recovery consideration
451
653
  // the way the prior hand-listed three-family check did for Google Antigravity.
452
654
  isManagedFamily(candidate.family) &&
655
+ // A caller-built candidate must name a canonical account of its own family,
656
+ // so a mislabelled id can never borrow another family's send reservation.
657
+ isCanonicalManagedProviderId(candidate.providerId, candidate.family) &&
453
658
  typeof candidate.modelId === "string" &&
454
- candidate.modelId.length > 0
659
+ candidate.modelId.length > 0 &&
660
+ (candidate.recoveryAction === "account" || candidate.recoveryAction === "model")
455
661
  );
456
662
  }
457
663
 
664
+ function changesOnlyAuthorizedDimension(
665
+ initial: RecoveryCandidate,
666
+ candidate: RecoveryCandidate,
667
+ action: RecoveryActionKind,
668
+ ): boolean {
669
+ return action === "account"
670
+ ? candidate.providerId !== initial.providerId && candidate.modelId === initial.modelId
671
+ : candidate.modelId !== initial.modelId;
672
+ }
673
+
458
674
  function observeLatePhysicalAttempt(
459
675
  pending: RecoveryPhysicalAttempt | Promise<RecoveryPhysicalAttempt>,
460
676
  signal: AbortSignal,
@@ -478,6 +694,163 @@ function observeLatePhysicalAttempt(
478
694
  );
479
695
  }
480
696
 
697
+ /**
698
+ * An attempt read up to its first event that is not a `start`. At most one
699
+ * `start` is kept: a provider sends one, and a repeat carries nothing a
700
+ * consumer needs.
701
+ */
702
+ interface HeldPrefix {
703
+ readonly iterator: AsyncIterator<unknown>;
704
+ readonly held: readonly unknown[];
705
+ /** The step after the held events; absent when reading it failed. */
706
+ readonly next?: IteratorResult<unknown>;
707
+ readonly failure?: { readonly error: unknown };
708
+ }
709
+
710
+ type PeekResult =
711
+ | { readonly status: "aborted" }
712
+ | { readonly status: "content"; readonly prefix: HeldPrefix }
713
+ | { readonly status: "replay"; readonly prefix: Promise<HeldPrefix> };
714
+
715
+ /**
716
+ * Read an attempt past its leading `start` events. A held `start` is not
717
+ * progress, so it never resets the request's idle limit. A rejection while
718
+ * opening the stream or before any `start` rejects; a rejection after a held
719
+ * `start` is kept so the replay can rethrow it at the same point.
720
+ */
721
+ function readHeldPrefix(
722
+ output: AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>,
723
+ ): Promise<HeldPrefix> {
724
+ const pending = (async (): Promise<HeldPrefix> => {
725
+ const source = await output;
726
+ const iterator = source[Symbol.asyncIterator]();
727
+ const held: unknown[] = [];
728
+ let sawStart = false;
729
+ for (;;) {
730
+ let step: IteratorResult<unknown>;
731
+ try {
732
+ step = await iterator.next();
733
+ } catch (error) {
734
+ if (!sawStart) throw error;
735
+ return { iterator, held, failure: { error } };
736
+ }
737
+ if (step.done !== true && isRecoveryStartEvent(step.value)) {
738
+ if (!sawStart) held.push(step.value);
739
+ sawStart = true;
740
+ continue;
741
+ }
742
+ return { iterator, held, next: step };
743
+ }
744
+ })();
745
+ void pending.catch(() => {});
746
+ return pending;
747
+ }
748
+
749
+ function closeIterator(iterator: AsyncIterator<unknown>): void {
750
+ try {
751
+ void Promise.resolve(iterator.return?.()).catch(() => {});
752
+ } catch {
753
+ // A throwing `return` is contained like any other callback failure.
754
+ }
755
+ }
756
+
757
+ /**
758
+ * The attempt's output, resumed after the engine read its held prefix. The
759
+ * held `start` replays first, then the step that ended the prefix; a failure
760
+ * the read observed rethrows at the same point, so the buffered path sees
761
+ * exactly what an unread stream would have produced.
762
+ */
763
+ function resumedOutput(prefix: Promise<HeldPrefix>): AsyncIterable<unknown> {
764
+ return {
765
+ async *[Symbol.asyncIterator]() {
766
+ const { iterator, held, next, failure } = await prefix;
767
+ let finished = false;
768
+ try {
769
+ for (const event of held) yield event;
770
+ if (failure !== undefined) {
771
+ finished = true;
772
+ throw failure.error;
773
+ }
774
+ if (next === undefined || next.done === true) {
775
+ finished = true;
776
+ return;
777
+ }
778
+ yield next.value;
779
+ for (;;) {
780
+ const step = await iterator.next();
781
+ if (step.done === true) {
782
+ finished = true;
783
+ return;
784
+ }
785
+ yield step.value;
786
+ }
787
+ } finally {
788
+ if (!finished) closeIterator(iterator);
789
+ }
790
+ },
791
+ };
792
+ }
793
+
794
+ async function peekFirstContent(
795
+ output: AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>,
796
+ request: ActiveRequest,
797
+ ): Promise<PeekResult> {
798
+ const prefix = readHeldPrefix(output);
799
+ const settled = await awaitRequest(prefix, request);
800
+ if (settled.status === "aborted") {
801
+ void prefix.then(({ iterator }) => closeIterator(iterator), () => {});
802
+ return { status: "aborted" };
803
+ }
804
+ if (
805
+ settled.status === "value" &&
806
+ settled.value.next !== undefined &&
807
+ settled.value.next.done !== true &&
808
+ isRecoveryContentEvent(settled.value.next.value)
809
+ ) {
810
+ return { status: "content", prefix: settled.value };
811
+ }
812
+ return { status: "replay", prefix };
813
+ }
814
+
815
+ /**
816
+ * The first send must use the call's selected model on an account candidate.
817
+ * A model change happens only through `classifyModelRecovery` after a
818
+ * structured failure, never because recheck skipped every exact-model account.
819
+ */
820
+ function eligibleInitialCandidate(candidate: RecoveryCandidate): boolean {
821
+ return (
822
+ candidate.recoveryAction === "account" &&
823
+ candidate.modelId === candidate.selectedModelId
824
+ );
825
+ }
826
+
827
+ function projectModelFailure(terminal: AssistantMessage): RecoveryModelFailure {
828
+ const diagnostics: readonly unknown[] = Array.isArray(terminal.diagnostics)
829
+ ? terminal.diagnostics
830
+ : [];
831
+ const diagnosticTypes: string[] = [];
832
+ for (const diagnostic of diagnostics) {
833
+ const type =
834
+ typeof diagnostic === "object" && diagnostic !== null
835
+ ? (diagnostic as { type?: unknown }).type
836
+ : undefined;
837
+ if (typeof type === "string") diagnosticTypes.push(type);
838
+ }
839
+ const rawCode = (terminal as { code?: unknown }).code;
840
+ const code =
841
+ rawCode === "refusal" || rawCode === "unknown_stop" ? rawCode : undefined;
842
+ return Object.freeze({
843
+ stopReason: terminal.stopReason,
844
+ api: terminal.api,
845
+ provider: terminal.provider,
846
+ model: terminal.model,
847
+ hasErrorMessage:
848
+ typeof terminal.errorMessage === "string" && terminal.errorMessage.length > 0,
849
+ diagnosticTypes: Object.freeze(diagnosticTypes),
850
+ ...(code === undefined ? {} : { code }),
851
+ });
852
+ }
853
+
481
854
  function validPreDispatchDecision(value: unknown): value is RecoveryPreDispatchDecision {
482
855
  if (typeof value !== "object" || value === null) return false;
483
856
  const decision = value as Record<string, unknown>;
@@ -492,18 +865,39 @@ function validPreDispatchDecision(value: unknown): value is RecoveryPreDispatchD
492
865
  function validRetrySafety(value: unknown): value is RecoveryRetrySafety {
493
866
  if (typeof value !== "object" || value === null) return false;
494
867
  const safety = value as Record<string, unknown>;
495
- if (safety.status === "retryable") {
868
+ if (safety.status === "recoverable") {
496
869
  return (
497
- safety.reason === "generation-only-incomplete" ||
498
- safety.reason === "definitively-rejected"
870
+ safety.action === "account" &&
871
+ (safety.reason === "account-local-quota" ||
872
+ safety.reason === "account-local-auth" ||
873
+ safety.reason === "account-local-rate-limit" ||
874
+ safety.reason === "pre-start-transient")
499
875
  );
500
876
  }
877
+ if (safety.status === "model-policy") {
878
+ return safety.reason === "structured-provider-error";
879
+ }
501
880
  return (
502
881
  safety.status === "unsafe" &&
503
882
  (safety.reason === "uncertain-external-effects" ||
504
883
  safety.reason === "completed-tool" ||
505
884
  safety.reason === "completed-call" ||
506
- safety.reason === "agent-run")
885
+ safety.reason === "agent-run" ||
886
+ safety.reason === "invalid-request" ||
887
+ safety.reason === "refusal" ||
888
+ safety.reason === "unknown")
889
+ );
890
+ }
891
+
892
+ function validModelDecision(value: unknown): value is RecoveryModelDecision {
893
+ if (typeof value !== "object" || value === null) return false;
894
+ const decision = value as Record<string, unknown>;
895
+ return (
896
+ decision.status === "none" ||
897
+ (decision.status === "recoverable" &&
898
+ decision.action === "model" &&
899
+ (decision.reason === "unsupported-model" ||
900
+ decision.reason === "unsupported-capability"))
507
901
  );
508
902
  }
509
903
 
@@ -558,16 +952,34 @@ async function runRecovery(
558
952
  onProgress: () => void,
559
953
  ): Promise<RecoveryResult> {
560
954
  let attempts = 0;
955
+ let initialCandidate: RecoveryCandidate | undefined;
956
+ let recoveryAction: RecoveryActionKind | undefined;
561
957
  const terminated = (): RecoveryResult => ({
562
958
  status: "terminated",
563
959
  reason: request.reason ?? "callback-failure",
564
960
  attempts,
961
+ errorMessage: buildBoundedRecoveryFinalErrorMessage(),
962
+ });
963
+ const exhausted = (): RecoveryResult => ({
964
+ status: "exhausted",
965
+ attempts,
966
+ errorMessage: buildBoundedRecoveryFinalErrorMessage(),
565
967
  });
566
968
  const consideredPairs = new Set<string>();
567
969
 
568
970
  for (const candidate of [...input.candidates]) {
569
971
  if (request.controller.signal.aborted) return terminated();
972
+ if (attempts >= RECOVERY_MAX_PROVIDER_SENDS_PER_CALL) return exhausted();
570
973
  if (!validCandidate(candidate)) continue;
974
+ if (initialCandidate === undefined && !eligibleInitialCandidate(candidate)) continue;
975
+ if (
976
+ recoveryAction !== undefined &&
977
+ (initialCandidate === undefined ||
978
+ candidate.recoveryAction !== recoveryAction ||
979
+ !changesOnlyAuthorizedDimension(initialCandidate, candidate, recoveryAction))
980
+ ) {
981
+ continue;
982
+ }
571
983
  const pair = `${candidate.providerId}\u0000${candidate.modelId}`;
572
984
  if (consideredPairs.has(pair)) continue;
573
985
  consideredPairs.add(pair);
@@ -608,8 +1020,16 @@ async function runRecovery(
608
1020
  abortRequest(request, "callback-failure");
609
1021
  return terminated();
610
1022
  }
1023
+ if (attempts > 0 && reservation.maximumPossiblyChargedSends === "unknown") {
1024
+ // The recovery send is the call's last send. A pinned Codex non-SSE or a
1025
+ // vendored Antigravity invocation can make several internal sends, so it
1026
+ // cannot fit a one-send remainder.
1027
+ continue;
1028
+ }
1029
+ // A reservation keeps its ordinal even when the request aborts before dispatch.
1030
+ const ordinal = attempts + 1;
611
1031
  const reserved = await reserveAttempt(deps, request, {
612
- ordinal: attempts + 1,
1032
+ ordinal,
613
1033
  candidate,
614
1034
  reservation,
615
1035
  });
@@ -625,7 +1045,10 @@ async function runRecovery(
625
1045
  | { readonly terminal: AssistantMessage; readonly output: AssistantMessageEventStream }
626
1046
  | undefined;
627
1047
  let safety: RecoveryRetrySafety | undefined;
1048
+ let terminal: AssistantMessage | undefined;
628
1049
  let callbackFailed = false;
1050
+ let sawProgress = false;
1051
+ let committed: { readonly output: AsyncIterable<unknown> } | undefined;
629
1052
  const attemptController = new AbortController();
630
1053
  const abortAttempt = (): void => attemptController.abort();
631
1054
  request.controller.signal.addEventListener("abort", abortAttempt, { once: true });
@@ -635,12 +1058,17 @@ async function runRecovery(
635
1058
  if (!request.controller.signal.aborted) {
636
1059
  try {
637
1060
  // A physical attempt begins exactly when this callback is invoked.
1061
+ initialCandidate ??= candidate;
638
1062
  attempts += 1;
639
1063
  physicalValue = deps.dispatch({
640
1064
  candidate,
641
1065
  context: input.context,
642
1066
  options: streamOptions,
643
1067
  signal: attemptController.signal,
1068
+ onTransportActivity: () => {
1069
+ // Liveness, not output: only the idle limit is reset.
1070
+ if (!attemptController.signal.aborted) onProgress();
1071
+ },
644
1072
  });
645
1073
  } catch {
646
1074
  callbackFailed = true;
@@ -661,51 +1089,82 @@ async function runRecovery(
661
1089
  }
662
1090
  const safetyPromise = Promise.resolve(physical.retrySafety);
663
1091
  void safetyPromise.catch(() => {});
664
- const output = createAcceptedOutputStream(
665
- observedOutput(
666
- physical.output,
667
- onProgress,
668
- (observed) => {
669
- facts = observed;
670
- },
671
- ),
672
- { signal: request.controller.signal },
673
- );
674
- const outputResult = await awaitRequest(output.result(), request);
675
- if (outputResult.status === "value") {
676
- accepted = { terminal: outputResult.value, output };
677
- facts = projectAttemptFacts(outputResult.value) ?? facts;
678
- } else if (outputResult.status === "rejected") {
679
- try {
680
- await output.result();
681
- } catch (error) {
682
- failureCode =
683
- error instanceof AcceptedOutputError
684
- ? error.code
685
- : "dispatch-failure";
1092
+ let upstream: AsyncIterable<unknown> | Promise<AsyncIterable<unknown>> =
1093
+ physical.output;
1094
+ let peekAborted = false;
1095
+ if (input.publication === "stream-after-first-content") {
1096
+ const peek = await peekFirstContent(physical.output, request);
1097
+ if (peek.status === "aborted") {
1098
+ peekAborted = true;
1099
+ } else if (peek.status === "content") {
1100
+ onProgress();
1101
+ sawProgress = true;
1102
+ committed = { output: resumedOutput(Promise.resolve(peek.prefix)) };
1103
+ } else {
1104
+ upstream = resumedOutput(peek.prefix);
686
1105
  }
687
1106
  }
688
- if (accepted === undefined) {
689
- if (reservation.basis === "codex-websocket-uncertain") {
690
- // No supported hook distinguishes an unsent connection failure from
691
- // a failure after socket.send. The latter may already be executing,
692
- // so recovery must never dispatch another candidate.
693
- safety = {
694
- status: "unsafe",
695
- reason: "uncertain-external-effects",
696
- };
697
- } else {
698
- const safetyResult = await awaitRequest(safetyPromise, request);
1107
+ if (committed === undefined && !peekAborted) {
1108
+ const output = createAcceptedOutputStream(
1109
+ observedOutput(
1110
+ upstream,
1111
+ () => {
1112
+ sawProgress = true;
1113
+ onProgress();
1114
+ },
1115
+ (observed) => {
1116
+ terminal = observed;
1117
+ },
1118
+ (observed) => {
1119
+ facts = observed;
1120
+ },
1121
+ ),
1122
+ { signal: request.controller.signal },
1123
+ );
1124
+ const outputResult = await awaitRequest(output.result(), request);
1125
+ if (outputResult.status === "value") {
1126
+ accepted = { terminal: outputResult.value, output };
1127
+ facts = projectAttemptFacts(outputResult.value) ?? facts;
1128
+ } else if (outputResult.status === "rejected") {
1129
+ try {
1130
+ await output.result();
1131
+ } catch (error) {
1132
+ failureCode =
1133
+ error instanceof AcceptedOutputError
1134
+ ? error.code
1135
+ : "dispatch-failure";
1136
+ }
1137
+ }
1138
+ if (accepted === undefined) {
699
1139
  if (
700
- safetyResult.status === "value" &&
701
- validRetrySafety(safetyResult.value)
1140
+ reservation.basis === "antigravity-inner-unknown" ||
1141
+ (reservation.basis === "codex-non-sse-unknown" &&
1142
+ (terminal === undefined ||
1143
+ classifyCodexRecoverySendEvidence(terminal) !==
1144
+ "pre-execution-rejected"))
702
1145
  ) {
703
- safety = safetyResult.value;
704
- } else if (
705
- safetyResult.status === "rejected" ||
706
- safetyResult.status === "value"
707
- ) {
708
- callbackFailed = true;
1146
+ // A non-SSE Codex invocation may have reconnected or fallen back
1147
+ // to SSE after socket.send; only structured proof of a single
1148
+ // pre-execution rejection may consult the caller's classifier.
1149
+ // An Antigravity invocation may already have sent to several
1150
+ // endpoints or runtime models; no supported evidence bounds it.
1151
+ safety = {
1152
+ status: "unsafe",
1153
+ reason: "uncertain-external-effects",
1154
+ };
1155
+ } else {
1156
+ const safetyResult = await awaitRequest(safetyPromise, request);
1157
+ if (
1158
+ safetyResult.status === "value" &&
1159
+ validRetrySafety(safetyResult.value)
1160
+ ) {
1161
+ safety = safetyResult.value;
1162
+ } else if (
1163
+ safetyResult.status === "rejected" ||
1164
+ safetyResult.status === "value"
1165
+ ) {
1166
+ callbackFailed = true;
1167
+ }
709
1168
  }
710
1169
  }
711
1170
  }
@@ -716,22 +1175,46 @@ async function runRecovery(
716
1175
  }
717
1176
 
718
1177
  const accounting = await accountAttempt(deps, request, {
719
- ordinal: attempts,
1178
+ ordinal,
720
1179
  candidate,
721
- disposition: accepted === undefined ? "failed" : "accepted",
722
- ...(accepted === undefined ? { failureCode } : {}),
1180
+ disposition:
1181
+ committed !== undefined ? "committed" : accepted === undefined ? "failed" : "accepted",
1182
+ ...(accepted === undefined && committed === undefined ? { failureCode } : {}),
723
1183
  reservation,
724
1184
  chargedSendExposure: chargedSendExposureFor(reservation),
725
1185
  usage: facts.usage,
726
1186
  cost: facts.cost,
727
1187
  });
728
1188
  request.controller.signal.removeEventListener("abort", abortAttempt);
1189
+ if (
1190
+ committed !== undefined &&
1191
+ accounting === "recorded" &&
1192
+ !request.controller.signal.aborted
1193
+ ) {
1194
+ // The live attempt now belongs to the caller: its signal stays open and
1195
+ // no later send exists for this call.
1196
+ return { status: "committed", candidate, attempts, output: committed.output };
1197
+ }
729
1198
  if (!attemptController.signal.aborted) abortAttempt();
1199
+ if (committed !== undefined) {
1200
+ // Close the started stream; a committed attempt is never retried.
1201
+ void (async () => {
1202
+ try {
1203
+ for await (const _event of committed.output) break;
1204
+ } catch {
1205
+ // The attempt was already aborted.
1206
+ }
1207
+ })();
1208
+ }
730
1209
  if (request.controller.signal.aborted) return terminated();
731
1210
  if (accounting === "rejected") {
732
1211
  abortRequest(request, "accounting-rejected");
733
1212
  return terminated();
734
1213
  }
1214
+ if (committed !== undefined) {
1215
+ abortRequest(request, "callback-failure");
1216
+ return terminated();
1217
+ }
735
1218
  if (accepted !== undefined) {
736
1219
  return {
737
1220
  status: "accepted",
@@ -745,12 +1228,52 @@ async function runRecovery(
745
1228
  abortRequest(request, "callback-failure");
746
1229
  return terminated();
747
1230
  }
1231
+ if (safety.status === "recoverable" && safety.reason === "pre-start-transient" && sawProgress) {
1232
+ // A transient failure is retried only while nothing was produced.
1233
+ abortRequest(request, "uncertain-external-effects");
1234
+ return terminated();
1235
+ }
1236
+ if (safety.status === "recoverable") {
1237
+ recoveryAction = safety.action;
1238
+ continue;
1239
+ }
748
1240
  if (safety.status === "unsafe") {
749
1241
  abortRequest(request, safety.reason);
750
1242
  return terminated();
751
1243
  }
1244
+ // The recovery send was the last send; do not consult model policy for a
1245
+ // third send that the call can never make.
1246
+ if (attempts >= RECOVERY_MAX_PROVIDER_SENDS_PER_CALL) return exhausted();
1247
+ if (terminal === undefined || deps.classifyModelRecovery === undefined) {
1248
+ abortRequest(request, "unknown");
1249
+ return terminated();
1250
+ }
1251
+ let modelValue: ReturnType<NonNullable<RecoveryEngineDependencies["classifyModelRecovery"]>>;
1252
+ try {
1253
+ modelValue = deps.classifyModelRecovery(
1254
+ projectModelFailure(terminal),
1255
+ request.controller.signal,
1256
+ );
1257
+ } catch {
1258
+ abortRequest(request, "callback-failure");
1259
+ return terminated();
1260
+ }
1261
+ const modelDecision = await awaitRequest(modelValue, request);
1262
+ if (modelDecision.status === "aborted") return terminated();
1263
+ if (
1264
+ modelDecision.status === "rejected" ||
1265
+ !validModelDecision(modelDecision.value)
1266
+ ) {
1267
+ abortRequest(request, "callback-failure");
1268
+ return terminated();
1269
+ }
1270
+ if (modelDecision.value.status === "none") {
1271
+ abortRequest(request, "unknown");
1272
+ return terminated();
1273
+ }
1274
+ recoveryAction = modelDecision.value.action;
752
1275
  }
753
- return { status: "exhausted", attempts };
1276
+ return exhausted();
754
1277
  }
755
1278
 
756
1279
  export function createRecoveryEngine(
@@ -767,14 +1290,24 @@ export function createRecoveryEngine(
767
1290
  return {
768
1291
  async recover(input): Promise<RecoveryResult> {
769
1292
  if (shutdown) {
770
- return { status: "terminated", reason: "shutdown", attempts: 0 };
1293
+ return {
1294
+ status: "terminated",
1295
+ reason: "shutdown",
1296
+ attempts: 0,
1297
+ errorMessage: buildBoundedRecoveryFinalErrorMessage(),
1298
+ };
771
1299
  }
772
1300
  if (
773
1301
  !validTiming(input.timing) ||
774
1302
  (input.deadlineMs !== undefined &&
775
1303
  (!Number.isFinite(input.deadlineMs) || input.deadlineMs < 0))
776
1304
  ) {
777
- return { status: "terminated", reason: "malformed-config", attempts: 0 };
1305
+ return {
1306
+ status: "terminated",
1307
+ reason: "malformed-config",
1308
+ attempts: 0,
1309
+ errorMessage: buildBoundedRecoveryFinalErrorMessage(),
1310
+ };
778
1311
  }
779
1312
 
780
1313
  const activeRequest: ActiveRequest = { controller: new AbortController() };
@@ -784,8 +1317,11 @@ export function createRecoveryEngine(
784
1317
  let absoluteTimer: RecoveryTimer | undefined;
785
1318
  let callerAbort: (() => void) | undefined;
786
1319
  let idleGeneration = 0;
1320
+ let settled = false;
787
1321
  const resetIdle = (): void => {
788
- if (activeRequest.controller.signal.aborted) return;
1322
+ // A committed attempt's connection outlives the invocation; its late
1323
+ // transport activity must not arm a timer for a settled request.
1324
+ if (settled || activeRequest.controller.signal.aborted) return;
789
1325
  const generation = ++idleGeneration;
790
1326
  cancelTimer(idleTimer);
791
1327
  try {
@@ -833,10 +1369,12 @@ export function createRecoveryEngine(
833
1369
  status: "terminated",
834
1370
  reason: activeRequest.reason ?? "callback-failure",
835
1371
  attempts: 0,
1372
+ errorMessage: buildBoundedRecoveryFinalErrorMessage(),
836
1373
  };
837
1374
  }
838
1375
  return await runRecovery(deps, input, activeRequest, resetIdle);
839
1376
  } finally {
1377
+ settled = true;
840
1378
  idleGeneration += 1;
841
1379
  cancelTimer(deadlineTimer);
842
1380
  cancelTimer(idleTimer);