@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.
- package/README.md +17 -3
- package/package.json +5 -5
- package/packages/pi-anthropic-oauth/package.json +2 -2
- package/packages/pi-anthropic-oauth/src/stream.ts +26 -8
- package/packages/pi-anthropic-oauth/src/transport-activity.ts +59 -0
- package/packages/pi-antigravity/package.json +2 -2
- package/packages/pi-antigravity/src/models/discovery.ts +2 -1
- package/packages/pi-antigravity/src/models/grouping.ts +12 -10
- package/packages/pi-antigravity/src/models/models.ts +11 -4
- package/src/account-group-failure.ts +149 -0
- package/src/account-group-members.ts +139 -0
- package/src/anthropic-adaptive-stream.ts +18 -9
- package/src/anthropic-alias-stream.ts +13 -68
- package/src/codex-adapter.ts +78 -50
- package/src/commands.ts +3 -3
- package/src/config.ts +197 -2
- package/src/diagnostic-store.ts +17 -15
- package/src/diagnostics.ts +97 -35
- package/src/host-final-stop-message.ts +20 -118
- package/src/index.ts +452 -87
- package/src/logical-dispatch.ts +12 -13
- package/src/logical-provider.ts +1282 -355
- package/src/model-fallback-policy.ts +384 -0
- package/src/models-declaration.ts +15 -8
- package/src/public-assistant-projection.ts +163 -0
- package/src/recovery-engine.ts +648 -110
- package/src/recovery-plan.ts +7 -1
- package/src/recovery-send-evidence.ts +29 -0
- package/src/refusal-advice.ts +139 -0
- package/src/routing.ts +4 -8
- package/src/runtime-state.ts +7 -0
- package/src/shared-usage.ts +21 -4
- package/src/upstream-anthropic.ts +12 -4
- package/src/upstream-antigravity.ts +2 -38
- package/src/usage-fetch.ts +42 -59
package/src/recovery-engine.ts
CHANGED
|
@@ -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: "
|
|
37
|
-
readonly
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
66
|
-
readonly basis: "bounded-http";
|
|
90
|
+
readonly basis: "bounded-provider-invocation";
|
|
67
91
|
}
|
|
68
92
|
| {
|
|
69
|
-
readonly maximumPossiblyChargedSends:
|
|
70
|
-
readonly
|
|
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
|
|
111
|
+
readonly maximumPossiblyChargedSends: 1;
|
|
94
112
|
}
|
|
95
113
|
| {
|
|
96
114
|
readonly status: "unknown";
|
|
97
|
-
readonly reason: "codex-
|
|
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
|
|
104
|
-
*
|
|
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
|
-
|
|
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
|
-
| {
|
|
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.
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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 === "
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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:
|
|
272
|
-
|
|
273
|
-
basis: "adaptive-anthropic-provenance",
|
|
415
|
+
maximumPossiblyChargedSends: "unknown",
|
|
416
|
+
basis: "codex-non-sse-unknown",
|
|
274
417
|
});
|
|
275
418
|
}
|
|
276
|
-
if (candidate.family === "
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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:
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
387
|
-
if (
|
|
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 === "
|
|
868
|
+
if (safety.status === "recoverable") {
|
|
496
869
|
return (
|
|
497
|
-
safety.
|
|
498
|
-
safety.reason === "
|
|
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
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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 (
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
1178
|
+
ordinal,
|
|
720
1179
|
candidate,
|
|
721
|
-
disposition:
|
|
722
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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);
|