@bitkyc08/opencodex 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/gui/dist/assets/index-Co12XTT-.js +76 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/cursor/discovery.ts +4 -1
  5. package/src/adapters/cursor/effort-map.ts +5 -1
  6. package/src/adapters/cursor/request-builder.ts +3 -3
  7. package/src/adapters/google.ts +25 -5
  8. package/src/adapters/openai-chat.ts +182 -6
  9. package/src/adapters/openai-responses.ts +17 -7
  10. package/src/codex/catalog/bundled.ts +16 -0
  11. package/src/codex/catalog/metadata.ts +180 -5
  12. package/src/codex/catalog/parsing.ts +7 -6
  13. package/src/codex/catalog/sync.ts +73 -6
  14. package/src/codex/catalog.ts +1 -1
  15. package/src/codex/convergence.ts +20 -0
  16. package/src/codex/prompt-journal.ts +50 -13
  17. package/src/codex/prompt-layers.ts +1 -1
  18. package/src/config.ts +57 -0
  19. package/src/generated/compatibility-version.json +64 -48
  20. package/src/generated/model-metadata.ts +3 -3
  21. package/src/lib/local-provider-reload-contract.ts +100 -0
  22. package/src/oauth/login-cli.ts +52 -26
  23. package/src/providers/derive.ts +2 -0
  24. package/src/providers/openai-sidecar.ts +9 -2
  25. package/src/providers/quota.ts +57 -0
  26. package/src/providers/registry.ts +53 -25
  27. package/src/responses/state.ts +22 -0
  28. package/src/router.ts +1 -0
  29. package/src/server/claude-messages.ts +57 -11
  30. package/src/server/direct-local-http.ts +7 -3
  31. package/src/server/images.ts +6 -0
  32. package/src/server/index.ts +51 -11
  33. package/src/server/live.ts +117 -13
  34. package/src/server/local-provider-reload-client.ts +137 -0
  35. package/src/server/management/config-routes.ts +20 -3
  36. package/src/server/management/logs-usage-routes.ts +28 -0
  37. package/src/server/management/model-routes.ts +11 -3
  38. package/src/server/management/model-rows.ts +18 -3
  39. package/src/server/management/provider-routes.ts +107 -3
  40. package/src/server/management-auth.ts +65 -1
  41. package/src/server/proxy-liveness.ts +1 -0
  42. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  43. package/src/server/responses/agent-task-recovery.ts +460 -0
  44. package/src/server/responses/compact.ts +4 -2
  45. package/src/server/responses/core.ts +142 -6
  46. package/src/server/responses/encrypted-payload.ts +4 -1
  47. package/src/server/search.ts +4 -0
  48. package/src/types.ts +27 -0
  49. package/src/usage/expected-prices.ts +11 -0
  50. package/src/vision/describe.ts +4 -0
  51. package/src/web-search/anthropic-executor.ts +5 -1
  52. package/src/web-search/executor.ts +9 -1
  53. package/src/web-search/index.ts +5 -0
  54. package/src/web-search/loop.ts +42 -5
  55. package/gui/dist/assets/index-BHldBl6_.js +0 -76
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
+ import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch";
5
6
  import {
6
7
  DEFAULT_SUBAGENT_MODELS,
8
+ adoptPersistedProviderIntoLiveConfig,
7
9
  codexAutoStartEnabled,
8
10
  hasOwnProvider,
9
11
  isValidProviderName,
@@ -12,6 +14,7 @@ import {
12
14
  normalizeNonBlankStringArray,
13
15
  providerBaseUrlConfigError,
14
16
  providerHeadersConfigError,
17
+ readConfigAdmissionSnapshot,
15
18
  saveConfigPreservingClaudeCode,
16
19
  withConfigMutationLockSync,
17
20
  } from "../../config";
@@ -39,12 +42,13 @@ import {
39
42
  resolveProviderModelDiscovery,
40
43
  } from "../../providers/model-discovery";
41
44
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
42
- import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
45
+ import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
46
+ import { clearKeyCooldowns } from "../../providers/key-failover";
43
47
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
44
48
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
45
49
  import { clearThreadAccountMap } from "../../codex/routing";
46
50
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
47
- import { getProviderDiscoveryStatus } from "../../codex/model-cache";
51
+ import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
48
52
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
49
53
  import { resolveCodexHomeDir } from "../../codex/home";
50
54
  import { readUsageEntries } from "../../usage/log";
@@ -68,6 +72,11 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
68
72
  import type { PersistedUsageAttempt } from "../../usage/log";
69
73
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
70
74
  import { applySystemEnvToggle } from "../system-env";
75
+ import {
76
+ LOCAL_PROVIDER_RELOAD_NAME_HEADER,
77
+ LOCAL_PROVIDER_RELOAD_PATH,
78
+ } from "../../lib/local-provider-reload-contract";
79
+ import { refreshUserCostOverlays } from "../../usage/user-cost-overlays";
71
80
 
72
81
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
73
82
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
@@ -288,7 +297,7 @@ function applyProviderPatchFields(
288
297
  }
289
298
 
290
299
  export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
291
- const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
300
+ const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
292
301
 
293
302
  if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
294
303
  const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
@@ -315,6 +324,78 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
315
324
  })));
316
325
  }
317
326
 
327
+ if (url.pathname === LOCAL_PROVIDER_RELOAD_PATH && req.method === "POST") {
328
+ if (principal !== "local-provider-reload-capability") {
329
+ return jsonResponse({ error: "provider reload capability required" }, 403);
330
+ }
331
+ const name = req.headers.get(LOCAL_PROVIDER_RELOAD_NAME_HEADER) ?? "";
332
+ if (!isValidProviderName(name)) return jsonResponse({ error: "invalid provider reload target" }, 400);
333
+
334
+ const admitted = readConfigAdmissionSnapshot();
335
+ if (
336
+ admitted.kind !== "read"
337
+ || admitted.diagnostics.source !== "file"
338
+ || admitted.diagnostics.error !== null
339
+ ) {
340
+ return jsonResponse({ error: "provider reload source unavailable" }, 409);
341
+ }
342
+ const diskConfig = admitted.diagnostics.config;
343
+ if (!hasOwnProvider(diskConfig.providers, name)) {
344
+ return jsonResponse({ error: "provider reload target unavailable" }, 404);
345
+ }
346
+ const provider = diskConfig.providers[name]!;
347
+ const providerError = providerManagementConfigError(name, provider);
348
+ if (providerError) return jsonResponse({ error: "provider reload target invalid" }, 409);
349
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(
350
+ diskConfig.codexAccountNamespaces,
351
+ name,
352
+ );
353
+ if (namespaceCollision) return jsonResponse({ error: "provider reload target conflicts with routing" }, 409);
354
+ const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(provider);
355
+ const resolvedError = await providerDestinationResolvedError(name, provider, { allowBenchmarkAddresses });
356
+ if (resolvedError) return jsonResponse({ error: "provider reload target rejected" }, 409);
357
+
358
+ // Destination validation awaits DNS. A cooperating writer holds the same SQLite
359
+ // mutation lock, so the final exact-byte check and live adoption happen as one
360
+ // synchronous authority decision. The route does not save or reserialize disk.
361
+ let currentDiskConfig: OcxConfig | null = null;
362
+ let sourceChanged = false;
363
+ withConfigMutationLockSync(() => {
364
+ const current = readConfigAdmissionSnapshot();
365
+ if (
366
+ current.kind !== "read"
367
+ || current.diagnostics.source !== "file"
368
+ || current.diagnostics.error !== null
369
+ || current.contentSha256 !== admitted.contentSha256
370
+ ) {
371
+ sourceChanged = true;
372
+ return;
373
+ }
374
+ currentDiskConfig = current.diagnostics.config;
375
+ adoptPersistedProviderIntoLiveConfig(
376
+ config,
377
+ name,
378
+ current.diagnostics.config.providers[name]!,
379
+ current.diagnostics.config,
380
+ );
381
+ });
382
+ if (sourceChanged || currentDiskConfig === null) {
383
+ return jsonResponse({ error: "provider reload source changed" }, 409);
384
+ }
385
+ reconcileLiveStateStores();
386
+ // The complete disk snapshot owns display overlays, including providers that this
387
+ // live routing instance deliberately does not adopt.
388
+ refreshUserCostOverlays(currentDiskConfig);
389
+ clearGatherRoutedModelsInflight();
390
+ (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
391
+ clearAccountQuotaCache(name);
392
+ clearKeyCooldowns(name);
393
+ clearModelCache(name);
394
+ if (name === "openai") (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
395
+ const catalogRefresh = await convergeCodexCatalog();
396
+ return jsonResponse({ success: true, name, catalogRefresh });
397
+ }
398
+
318
399
  // Add (or overwrite) a single provider. Merges into the live in-memory config and
319
400
  // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
320
401
  // which would re-save the masked keys from GET). Live routing picks it up immediately.
@@ -350,6 +431,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
350
431
  }
351
432
  // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
352
433
  // doesn't send — merge it in so the sidecars are gated correctly.
434
+ // Sample request ownership BEFORE enrichment. Enrichment fills absent fields from the
435
+ // registry seed, after which "the client omitted this" and "the registry supplied it" are
436
+ // indistinguishable — so a carry-over guard written as `prov.x === undefined` after this
437
+ // call can never fire.
438
+ const submittedContextWindow = Object.hasOwn(prov, "contextWindow");
439
+ const submittedModelContextWindows = Object.hasOwn(prov, "modelContextWindows");
353
440
  enrichProviderFromCatalog(name, prov);
354
441
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
355
442
  // Overwriting an existing provider must not drop its multi-key pool: carry it over, then
@@ -361,6 +448,23 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
361
448
  // erase hand-edited per-model prices from Logs/Usage estimates.
362
449
  const existingCosts = config.providers[name]?.modelCosts;
363
450
  if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
451
+ // ...and to hand-edited context windows. `ProviderPayload` (gui/src/provider-payload.ts)
452
+ // has no member for either field, so the add/edit form structurally cannot send them:
453
+ // absence in the request means "not carried", never "the user deleted it". Deletion goes
454
+ // through PATCH with an explicit null (#1409).
455
+ const existing = config.providers[name];
456
+ if (!submittedContextWindow && existing?.contextWindow !== undefined) {
457
+ prov.contextWindow = existing.contextWindow;
458
+ }
459
+ if (existing?.modelContextWindows) {
460
+ // When the client did send a map, its keys win and the user's other keys survive. When
461
+ // it did not, the stored value is the user's map alone: merging the registry seed in
462
+ // would persist seed keys into user config as a side effect of an unrelated save, and
463
+ // router.ts already fills registry values beneath user entries at resolve time.
464
+ prov.modelContextWindows = submittedModelContextWindows
465
+ ? { ...existing.modelContextWindows, ...(prov.modelContextWindows ?? {}) }
466
+ : { ...existing.modelContextWindows };
467
+ }
364
468
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
365
469
  if (body.setDefault === true) config.defaultProvider = name;
366
470
  save(config);
@@ -29,6 +29,16 @@ import {
29
29
  parseExpectedSystemRestartPid,
30
30
  verifySystemRestartCapability,
31
31
  } from "../lib/system-restart-contract";
32
+ import {
33
+ LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER,
34
+ LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER,
35
+ LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER,
36
+ LOCAL_PROVIDER_RELOAD_NAME_HEADER,
37
+ LOCAL_PROVIDER_RELOAD_NONCE_HEADER,
38
+ LOCAL_PROVIDER_RELOAD_PATH,
39
+ parseExpectedLocalProviderReloadPid,
40
+ verifyLocalProviderReloadCapability,
41
+ } from "../lib/local-provider-reload-contract";
32
42
  import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
33
43
  import type { OcxConfig } from "../types";
34
44
  import {
@@ -45,6 +55,9 @@ const GUI_SESSION_LIMIT = 128;
45
55
  const LOCAL_READ_REPLAY_LIMIT = 256;
46
56
  const consumedLocalReadCapabilities = new Map<string, number>();
47
57
  const admittedLocalReadRequests = new WeakSet<Request>();
58
+ const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256;
59
+ const consumedLocalProviderReloadCapabilities = new Map<string, number>();
60
+ const admittedLocalProviderReloadRequests = new WeakSet<Request>();
48
61
 
49
62
  interface GuiSessionRecord {
50
63
  csrfToken: string;
@@ -266,12 +279,13 @@ export function issueGuiSession(
266
279
  * rather than off request headers, which the token holder can forge freely.
267
280
  * The capability principals are process-scoped HMACs bound to the current process
268
281
  * PID and listening port. Local reads are accepted only for two exact GET paths;
269
- * restart remains a separate wire contract for its exact POST.
282
+ * restart and provider reload remain separate wire contracts for their exact POSTs.
270
283
  */
271
284
  export type ManagementPrincipal =
272
285
  | "admin-token"
273
286
  | "gui-session"
274
287
  | "local-read-capability"
288
+ | "local-provider-reload-capability"
275
289
  | "system-restart-capability";
276
290
 
277
291
  export interface LocalManagementAuthContext {
@@ -354,6 +368,54 @@ function hasLocalReadCapability(
354
368
  return true;
355
369
  }
356
370
 
371
+ function hasLocalProviderReloadCapability(
372
+ req: Request,
373
+ local: LocalManagementAuthContext | undefined,
374
+ ): boolean {
375
+ if (admittedLocalProviderReloadRequests.has(req)) return true;
376
+ if (!local || req.method !== "POST") return false;
377
+ let url: URL;
378
+ try {
379
+ url = new URL(req.url);
380
+ } catch {
381
+ return false;
382
+ }
383
+ if (url.pathname !== LOCAL_PROVIDER_RELOAD_PATH || url.search !== "") return false;
384
+ const contentLength = req.headers.get("content-length");
385
+ if (contentLength !== "0" || req.headers.has("transfer-encoding")) return false;
386
+ const expectedPid = parseExpectedLocalProviderReloadPid(
387
+ req.headers.get(LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER),
388
+ );
389
+ if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false;
390
+ const expiresAtRaw = req.headers.get(LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER);
391
+ if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false;
392
+ const expiresAt = Number(expiresAtRaw);
393
+ if (!Number.isSafeInteger(expiresAt)) return false;
394
+ const name = req.headers.get(LOCAL_PROVIDER_RELOAD_NAME_HEADER);
395
+ const capability = req.headers.get(LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER);
396
+ const now = Date.now();
397
+ if (!verifyLocalProviderReloadCapability(
398
+ local.attestationSecret,
399
+ req.headers.get(LOCAL_PROVIDER_RELOAD_NONCE_HEADER),
400
+ req.method,
401
+ url.pathname,
402
+ name,
403
+ local.pid,
404
+ local.port,
405
+ expiresAt,
406
+ capability,
407
+ now,
408
+ )) return false;
409
+ for (const [consumed, retainedUntil] of consumedLocalProviderReloadCapabilities) {
410
+ if (retainedUntil <= now) consumedLocalProviderReloadCapabilities.delete(consumed);
411
+ }
412
+ if (!capability || consumedLocalProviderReloadCapabilities.has(capability)) return false;
413
+ if (consumedLocalProviderReloadCapabilities.size >= LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT) return false;
414
+ consumedLocalProviderReloadCapabilities.set(capability, expiresAt);
415
+ admittedLocalProviderReloadRequests.add(req);
416
+ return true;
417
+ }
418
+
357
419
  /**
358
420
  * The principal for a request that already passed `requireManagementAuth`. Kept as a
359
421
  * separate resolution (rather than a changed return type) so every existing caller
@@ -368,6 +430,7 @@ export function managementPrincipal(
368
430
  local?: LocalManagementAuthContext,
369
431
  ): ManagementPrincipal | null {
370
432
  if (hasSystemRestartCapability(req, local)) return "system-restart-capability";
433
+ if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability";
371
434
  if (hasLocalReadCapability(req, local)) return "local-read-capability";
372
435
  if (!state.available) return null;
373
436
  const actual = req.headers.get("x-opencodex-api-key")?.trim()
@@ -386,6 +449,7 @@ export function requireManagementAuth(
386
449
  local?: LocalManagementAuthContext,
387
450
  ): Response | null {
388
451
  if (hasSystemRestartCapability(req, local)) return null;
452
+ if (hasLocalProviderReloadCapability(req, local)) return null;
389
453
  if (hasLocalReadCapability(req, local)) return null;
390
454
  if (!state.available) {
391
455
  return Response.json({
@@ -20,6 +20,7 @@ export interface HealthzIdentity {
20
20
  pid?: unknown;
21
21
  port?: unknown;
22
22
  restartCapability?: unknown;
23
+ providerReloadCapability?: unknown;
23
24
  }
24
25
 
25
26
  export interface LivenessIo {
@@ -0,0 +1,143 @@
1
+ const MAX_CACHE_BYTES = 8 * 1024 * 1024;
2
+ const MAX_CONCURRENT_RECOVERIES = 32;
3
+ const CACHE_TTL_MS = 15 * 60 * 1000;
4
+
5
+ interface RecoveryCacheEntry {
6
+ assignment: string;
7
+ bytes: number;
8
+ expiresAt: number;
9
+ expiryTimer: ReturnType<typeof setTimeout> | null;
10
+ }
11
+
12
+ interface RecoveryFlight {
13
+ controller: AbortController;
14
+ promise: Promise<string | null>;
15
+ waiters: number;
16
+ settled: boolean;
17
+ }
18
+
19
+ const RECOVERY_CACHE = new Map<string, RecoveryCacheEntry>();
20
+ const RECOVERY_FLIGHTS = new Map<string, RecoveryFlight>();
21
+ let recoveryCacheBytes = 0;
22
+
23
+ function deleteRecoveryCacheEntry(key: string, expected?: RecoveryCacheEntry): void {
24
+ const entry = RECOVERY_CACHE.get(key);
25
+ if (!entry || (expected && entry !== expected)) return;
26
+ RECOVERY_CACHE.delete(key);
27
+ if (entry.expiryTimer) clearTimeout(entry.expiryTimer);
28
+ recoveryCacheBytes = Math.max(0, recoveryCacheBytes - entry.bytes);
29
+ }
30
+
31
+ function sweepRecoveryCache(now: number, maxEntries: number): void {
32
+ for (const [key, entry] of RECOVERY_CACHE) {
33
+ if (entry.expiresAt > now) continue;
34
+ deleteRecoveryCacheEntry(key, entry);
35
+ }
36
+ while (RECOVERY_CACHE.size > maxEntries || recoveryCacheBytes > MAX_CACHE_BYTES) {
37
+ const oldest = RECOVERY_CACHE.keys().next().value;
38
+ if (oldest === undefined) break;
39
+ deleteRecoveryCacheEntry(oldest);
40
+ }
41
+ }
42
+
43
+ function insertRecoveryCacheEntry(key: string, assignment: string, maxEntries: number): void {
44
+ const replaced = RECOVERY_CACHE.get(key);
45
+ if (replaced) deleteRecoveryCacheEntry(key, replaced);
46
+ const insertedAt = Date.now();
47
+ const entry: RecoveryCacheEntry = {
48
+ assignment,
49
+ bytes: Buffer.byteLength(assignment),
50
+ expiresAt: insertedAt + CACHE_TTL_MS,
51
+ expiryTimer: null,
52
+ };
53
+ entry.expiryTimer = setTimeout(
54
+ () => deleteRecoveryCacheEntry(key, entry),
55
+ CACHE_TTL_MS,
56
+ );
57
+ entry.expiryTimer.unref?.();
58
+ RECOVERY_CACHE.set(key, entry);
59
+ recoveryCacheBytes += entry.bytes;
60
+ sweepRecoveryCache(insertedAt, maxEntries);
61
+ }
62
+
63
+ function startRecoveryFlight(
64
+ key: string,
65
+ maxEntries: number,
66
+ request: (signal: AbortSignal) => Promise<string | null>,
67
+ ): RecoveryFlight | null {
68
+ const active = RECOVERY_FLIGHTS.get(key);
69
+ if (active) return active;
70
+ if (RECOVERY_FLIGHTS.size >= MAX_CONCURRENT_RECOVERIES) return null;
71
+
72
+ const controller = new AbortController();
73
+ const flight: RecoveryFlight = {
74
+ controller,
75
+ promise: Promise.resolve(null),
76
+ waiters: 0,
77
+ settled: false,
78
+ };
79
+ flight.promise = request(controller.signal)
80
+ .then((assignment) => {
81
+ if (!assignment || controller.signal.aborted) return null;
82
+ insertRecoveryCacheEntry(key, assignment, maxEntries);
83
+ return assignment;
84
+ })
85
+ .finally(() => {
86
+ flight.settled = true;
87
+ if (RECOVERY_FLIGHTS.get(key) === flight) RECOVERY_FLIGHTS.delete(key);
88
+ });
89
+ RECOVERY_FLIGHTS.set(key, flight);
90
+ return flight;
91
+ }
92
+
93
+ async function waitForRecoveryFlight(
94
+ flight: RecoveryFlight,
95
+ abortSignal?: AbortSignal,
96
+ ): Promise<string | null> {
97
+ if (abortSignal?.aborted) return null;
98
+ flight.waiters += 1;
99
+ let onAbort: (() => void) | undefined;
100
+ try {
101
+ if (!abortSignal) return await flight.promise;
102
+ const cancelled = new Promise<null>((resolve) => {
103
+ onAbort = () => resolve(null);
104
+ abortSignal.addEventListener("abort", onAbort, { once: true });
105
+ if (abortSignal.aborted) onAbort();
106
+ });
107
+ return await Promise.race([flight.promise, cancelled]);
108
+ } finally {
109
+ if (onAbort) abortSignal?.removeEventListener("abort", onAbort);
110
+ flight.waiters = Math.max(0, flight.waiters - 1);
111
+ if (flight.waiters === 0 && !flight.settled) {
112
+ flight.controller.abort(new DOMException("All recovery callers cancelled", "AbortError"));
113
+ }
114
+ }
115
+ }
116
+
117
+ export async function resolveCachedAgentTaskRecovery(
118
+ key: string,
119
+ maxEntries: number,
120
+ request: (signal: AbortSignal) => Promise<string | null>,
121
+ abortSignal?: AbortSignal,
122
+ ): Promise<string | null> {
123
+ if (abortSignal?.aborted) return null;
124
+ sweepRecoveryCache(Date.now(), maxEntries);
125
+ const cached = RECOVERY_CACHE.get(key)?.assignment;
126
+ if (cached) return cached;
127
+ const flight = startRecoveryFlight(key, maxEntries, request);
128
+ return flight ? waitForRecoveryFlight(flight, abortSignal) : null;
129
+ }
130
+
131
+ export function resetAgentTaskRecoveryCache(): void {
132
+ for (const flight of RECOVERY_FLIGHTS.values()) {
133
+ flight.controller.abort(new DOMException("Recovery state reset", "AbortError"));
134
+ }
135
+ RECOVERY_FLIGHTS.clear();
136
+ for (const key of [...RECOVERY_CACHE.keys()]) deleteRecoveryCacheEntry(key);
137
+ }
138
+
139
+ export function agentTaskRecoveryWaiterCountForTests(): number {
140
+ let count = 0;
141
+ for (const flight of RECOVERY_FLIGHTS.values()) count += flight.waiters;
142
+ return count;
143
+ }