@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.
- package/gui/dist/assets/index-Co12XTT-.js +76 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +5 -1
- package/src/adapters/cursor/request-builder.ts +3 -3
- package/src/adapters/google.ts +25 -5
- package/src/adapters/openai-chat.ts +182 -6
- package/src/adapters/openai-responses.ts +17 -7
- package/src/codex/catalog/bundled.ts +16 -0
- package/src/codex/catalog/metadata.ts +180 -5
- package/src/codex/catalog/parsing.ts +7 -6
- package/src/codex/catalog/sync.ts +73 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence.ts +20 -0
- package/src/codex/prompt-journal.ts +50 -13
- package/src/codex/prompt-layers.ts +1 -1
- package/src/config.ts +57 -0
- package/src/generated/compatibility-version.json +64 -48
- package/src/generated/model-metadata.ts +3 -3
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/oauth/login-cli.ts +52 -26
- package/src/providers/derive.ts +2 -0
- package/src/providers/openai-sidecar.ts +9 -2
- package/src/providers/quota.ts +57 -0
- package/src/providers/registry.ts +53 -25
- package/src/responses/state.ts +22 -0
- package/src/router.ts +1 -0
- package/src/server/claude-messages.ts +57 -11
- package/src/server/direct-local-http.ts +7 -3
- package/src/server/images.ts +6 -0
- package/src/server/index.ts +51 -11
- package/src/server/live.ts +117 -13
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/config-routes.ts +20 -3
- package/src/server/management/logs-usage-routes.ts +28 -0
- package/src/server/management/model-routes.ts +11 -3
- package/src/server/management/model-rows.ts +18 -3
- package/src/server/management/provider-routes.ts +107 -3
- package/src/server/management-auth.ts +65 -1
- package/src/server/proxy-liveness.ts +1 -0
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +460 -0
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core.ts +142 -6
- package/src/server/responses/encrypted-payload.ts +4 -1
- package/src/server/search.ts +4 -0
- package/src/types.ts +27 -0
- package/src/usage/expected-prices.ts +11 -0
- package/src/vision/describe.ts +4 -0
- package/src/web-search/anthropic-executor.ts +5 -1
- package/src/web-search/executor.ts +9 -1
- package/src/web-search/index.ts +5 -0
- package/src/web-search/loop.ts +42 -5
- 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
|
|
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({
|
|
@@ -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
|
+
}
|