@bitkyc08/opencodex 2.59.0 → 2.60.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-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/request-builder.ts +7 -1
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +3 -1
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +50 -13
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +29 -5
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +37 -6
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +5 -1
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +15 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/bridge/sse.ts +3 -151
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/doctor.ts +28 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +7 -2
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +2 -2
- package/src/client/runtime.ts +26 -2
- package/src/codex/account-store.ts +65 -0
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/retained-sync.ts +22 -26
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +20 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +29 -10
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing.ts +7 -1
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +18 -0
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/config/schema/leaf-validators.ts +12 -0
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +249 -169
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +50 -2
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +66 -18
- package/src/providers/model-rename-migration.ts +55 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +35 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +9 -0
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +5 -2
- package/src/server/claude-messages.ts +6 -3
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +16 -13
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +119 -10
- package/src/server/request-log.ts +55 -2
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/compact.ts +32 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/passthrough-delivery.ts +31 -14
- package/src/server/responses/passthrough-dispatch.ts +36 -9
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +67 -17
- package/src/server/responses/request-send-budget.ts +5 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +2 -2
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/config.ts +11 -3
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +14 -0
- package/src/types.ts +1 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -113,6 +113,15 @@ export interface GatherRoutedModelsOptions {
|
|
|
113
113
|
providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
|
|
114
114
|
/** Internal convergence sink for the immutable policy that produced the returned rows. */
|
|
115
115
|
discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
|
|
116
|
+
/**
|
|
117
|
+
* Each provider's cache content revision as of the moment its rows were chosen.
|
|
118
|
+
*
|
|
119
|
+
* A caller that derives something from these rows and wants to know later whether the rows are
|
|
120
|
+
* still current must use this, not a revision sampled after the gather returns: another flight
|
|
121
|
+
* can publish between the choice and the sample, and the derived value would then carry that
|
|
122
|
+
* flight's identity while holding these rows.
|
|
123
|
+
*/
|
|
124
|
+
providerContentRevisions?: Map<string, string>;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
interface GatherFlightResult {
|
|
@@ -121,6 +130,7 @@ interface GatherFlightResult {
|
|
|
121
130
|
providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
|
|
122
131
|
providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
|
|
123
132
|
discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
|
|
133
|
+
providerContentRevisions: ReadonlyMap<string, string>;
|
|
124
134
|
}
|
|
125
135
|
interface GatherInflightEntry {
|
|
126
136
|
readonly discoveryPolicyIdentity: string;
|
|
@@ -256,6 +266,7 @@ async function gatherRoutedModelsWithAuth(
|
|
|
256
266
|
providerAuthOutcomes,
|
|
257
267
|
providerModelOutcomes,
|
|
258
268
|
discoveryPolicySnapshots,
|
|
269
|
+
providerContentRevisions,
|
|
259
270
|
} = await entry.promise;
|
|
260
271
|
if (options?.comboOmissions) {
|
|
261
272
|
options.comboOmissions.length = 0;
|
|
@@ -269,6 +280,12 @@ async function gatherRoutedModelsWithAuth(
|
|
|
269
280
|
options.providerModelOutcomes.length = 0;
|
|
270
281
|
options.providerModelOutcomes.push(...providerModelOutcomes);
|
|
271
282
|
}
|
|
283
|
+
if (options?.providerContentRevisions) {
|
|
284
|
+
options.providerContentRevisions.clear();
|
|
285
|
+
for (const [provider, revision] of providerContentRevisions) {
|
|
286
|
+
options.providerContentRevisions.set(provider, revision);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
272
289
|
if (options?.discoveryPolicySnapshots) {
|
|
273
290
|
options.discoveryPolicySnapshots.length = 0;
|
|
274
291
|
options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
|
|
@@ -763,6 +780,8 @@ async function gatherRoutedModelsUncached(
|
|
|
763
780
|
providerAuthOutcomes: localProviderAuthOutcomes,
|
|
764
781
|
providerModelOutcomes,
|
|
765
782
|
discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
|
|
783
|
+
// Stamped by each provider at the moment it chose its rows, not sampled here.
|
|
784
|
+
providerContentRevisions: new Map(providerResults.map(result => [result.outcome.provider, result.contentRevision])),
|
|
766
785
|
};
|
|
767
786
|
}
|
|
768
787
|
|
|
@@ -7,14 +7,17 @@ export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex";
|
|
|
7
7
|
|
|
8
8
|
/** The single definition of the opt-in, shared by injection and the runtime gate. */
|
|
9
9
|
export function contextExperimentalEnabled(configContent: string): boolean {
|
|
10
|
-
let parsed: { features?: { context_management?: { experimental_mode?: boolean } } };
|
|
10
|
+
let parsed: { features?: { context_management?: boolean | { experimental_mode?: boolean } } };
|
|
11
11
|
try {
|
|
12
12
|
parsed = Bun.TOML.parse(configContent) as typeof parsed;
|
|
13
13
|
} catch {
|
|
14
14
|
// Injection tolerates incomplete user config; malformed TOML is not an opt-in.
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
// Codex FeatureToml accepts either a boolean or the experimental_mode table.
|
|
18
|
+
// Keep strict true checks: malformed values must not activate a credentialed relay.
|
|
19
|
+
const setting = parsed.features?.context_management;
|
|
20
|
+
return setting === true || (typeof setting === "object" && setting?.experimental_mode === true);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
let activation: { key: string; active: boolean } | undefined;
|
|
@@ -260,7 +260,10 @@ export async function syncCodexOnStartIfEnabled(
|
|
|
260
260
|
// The `.catch` is deliberate and stays: a failure to APPLY must not stop the
|
|
261
261
|
// proxy from coming up. A failed sync simply reports no writes. The readiness
|
|
262
262
|
// gate observes the real outcome so /readyz reflects the sync state exactly as
|
|
263
|
-
// the
|
|
263
|
+
// the contract defines: ready on ok=true once the sync has settled. A nonempty
|
|
264
|
+
// `warning` is a local-Codex-artifact degradation the sync chose to continue
|
|
265
|
+
// past, and #5181 is what it cost to treat it as terminal — a healthy
|
|
266
|
+
// multi-provider proxy reported itself permanently unready.
|
|
264
267
|
const outcome = readinessGate
|
|
265
268
|
? await runStartupReadinessSync(readinessGate, async () => (await sync(port)) ?? null)
|
|
266
269
|
: await sync(port).catch(() => undefined);
|
package/src/codex/history-job.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
CodexHistoryWorkerOperation,
|
|
24
24
|
HistoryWorkerResult,
|
|
25
25
|
} from "./history-worker";
|
|
26
|
-
import { currentHistoryDbBusyTimeoutMs,
|
|
26
|
+
import { currentHistoryDbBusyTimeoutMs, resolveExistingHistoryBackupPath } from "./history-provider";
|
|
27
27
|
import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
|
|
28
28
|
import { getCodexHome, resolveCodexStateDbPath } from "./paths";
|
|
29
29
|
|
|
@@ -32,7 +32,7 @@ import { getCodexHome, resolveCodexStateDbPath } from "./paths";
|
|
|
32
32
|
*
|
|
33
33
|
* The SQLite root can differ from CODEX_HOME and both environment/config inputs
|
|
34
34
|
* can change between invocations. The parent resolves one exact target and hands
|
|
35
|
-
* those
|
|
35
|
+
* those resolved paths to the Worker rather than asking the Worker to infer a
|
|
36
36
|
* possibly different environment.
|
|
37
37
|
*/
|
|
38
38
|
export function resolveCodexHistoryJobTarget(): {
|
|
@@ -45,10 +45,10 @@ export function resolveCodexHistoryJobTarget(): {
|
|
|
45
45
|
return {
|
|
46
46
|
canonicalCodexHome: home,
|
|
47
47
|
canonicalStateDbPath: stateDb,
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
canonicalBackupPath:
|
|
48
|
+
// Resolve through the provider's canonical-first compatibility rule. Passing
|
|
49
|
+
// only the newly normalized name would hide a pre-#4442 Windows manifest from
|
|
50
|
+
// the Worker and make an upgrade look like an empty backup.
|
|
51
|
+
canonicalBackupPath: resolveExistingHistoryBackupPath(stateDb),
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { parseSessionMetaLine, type ParsedSessionMeta } from "./history-rollout-read";
|
|
5
|
+
export { MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES, readThreadFieldsFromRollout, type RolloutThreadFields } from "./history-rollout-read";
|
|
5
6
|
import { Database } from "bun:sqlite";
|
|
6
7
|
import { resolveCodexStateDbPath } from "./paths";
|
|
7
8
|
import { openCodexStateForPreflight } from "./history-state-open";
|
|
@@ -16,13 +17,6 @@ import {
|
|
|
16
17
|
type CodexHistoryBackupManifest,
|
|
17
18
|
} from "./history-manifest";
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
|
|
21
|
-
* Bounds peak memory while reconstructing thread rows; never write the decoded
|
|
22
|
-
* JSONL to disk.
|
|
23
|
-
*/
|
|
24
|
-
export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
25
|
-
|
|
26
20
|
/**
|
|
27
21
|
* The manifest that shadows one state database.
|
|
28
22
|
*
|
|
@@ -441,18 +435,32 @@ export function preflightCodexHistoryInjection(
|
|
|
441
435
|
const paginatedColumn = columns.some(column => column.name === "history_mode");
|
|
442
436
|
if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
|
|
443
437
|
for (const entry of restoreEntries) assertLegacyHistoryWritable(entry.rolloutPath);
|
|
444
|
-
const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(`
|
|
445
|
-
SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}
|
|
438
|
+
const rows = db.query<{ rollout_path: string; history_mode: string | null; model_provider: string }, []>(`
|
|
439
|
+
SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}, model_provider
|
|
446
440
|
FROM threads
|
|
447
441
|
WHERE ${providerTableMode
|
|
448
442
|
? resumeHistory ? "model_provider IN ('openai', 'opencodex')" : "0"
|
|
449
443
|
: "model_provider = 'opencodex'"}
|
|
450
444
|
`).all();
|
|
445
|
+
// No ORDER BY: a paginated opencodex row can precede a paginated openai one, so the
|
|
446
|
+
// verdict waits for the full scan instead of standing down on the first paginated row.
|
|
447
|
+
let foundPaginatedRow = false;
|
|
448
|
+
let foundPaginatedOpenaiRow = false;
|
|
451
449
|
for (const row of rows) {
|
|
452
|
-
if (paginatedColumn || row.history_mode === "paginated")
|
|
450
|
+
if (paginatedColumn || row.history_mode === "paginated") {
|
|
451
|
+
foundPaginatedRow = true;
|
|
452
|
+
// A provider-table transition removes the root openai_base_url, and a row already
|
|
453
|
+
// paginated cannot be relabeled: standing it down would route the openai-tagged
|
|
454
|
+
// thread to Codex's built-in OpenAI endpoint. A still-legacy row only stands down.
|
|
455
|
+
if (providerTableMode && row.history_mode === "paginated" && row.model_provider === "openai") {
|
|
456
|
+
foundPaginatedOpenaiRow = true;
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
453
460
|
assertLegacyHistoryWritable(row.rollout_path);
|
|
454
461
|
}
|
|
455
|
-
return
|
|
462
|
+
if (foundPaginatedOpenaiRow) return "history_paginated_openai_requires_native_writer";
|
|
463
|
+
return foundPaginatedRow ? HISTORY_RELABEL_STANDS_DOWN : null;
|
|
456
464
|
} catch (error) {
|
|
457
465
|
return error instanceof CodexHistoryIntegrityError
|
|
458
466
|
? error.message
|
|
@@ -1021,24 +1029,6 @@ function assertRestoreReadback(
|
|
|
1021
1029
|
}
|
|
1022
1030
|
}
|
|
1023
1031
|
|
|
1024
|
-
interface ParsedSessionMeta {
|
|
1025
|
-
record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
/** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
|
|
1029
|
-
function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
|
|
1030
|
-
let parsed: unknown;
|
|
1031
|
-
try {
|
|
1032
|
-
parsed = JSON.parse(line);
|
|
1033
|
-
} catch {
|
|
1034
|
-
return null;
|
|
1035
|
-
}
|
|
1036
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
1037
|
-
const record = parsed as ParsedSessionMeta["record"];
|
|
1038
|
-
if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
|
|
1039
|
-
return { record };
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
1032
|
/**
|
|
1043
1033
|
* Find the LAST `session_meta` line in a rollout, mirroring the app's last-writer-wins fold
|
|
1044
1034
|
* (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we
|
|
@@ -1107,142 +1097,6 @@ function compensateConcurrentSessionMetaAppend(
|
|
|
1107
1097
|
}
|
|
1108
1098
|
}
|
|
1109
1099
|
|
|
1110
|
-
/**
|
|
1111
|
-
* Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
|
|
1112
|
-
* Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
|
|
1113
|
-
*
|
|
1114
|
-
* Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
|
|
1115
|
-
* first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
|
|
1116
|
-
*/
|
|
1117
|
-
export interface RolloutThreadFields {
|
|
1118
|
-
id: string;
|
|
1119
|
-
modelProvider: string;
|
|
1120
|
-
source: string;
|
|
1121
|
-
firstUserMessage: string;
|
|
1122
|
-
hasUserEvent: number;
|
|
1123
|
-
cwd?: string;
|
|
1124
|
-
historyMode?: string;
|
|
1125
|
-
cliVersion?: string;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
function textFromContentParts(content: unknown): string | null {
|
|
1129
|
-
if (typeof content === "string" && content.trim()) return content.trim();
|
|
1130
|
-
if (!Array.isArray(content)) return null;
|
|
1131
|
-
const parts: string[] = [];
|
|
1132
|
-
for (const part of content) {
|
|
1133
|
-
if (!part || typeof part !== "object") continue;
|
|
1134
|
-
const p = part as Record<string, unknown>;
|
|
1135
|
-
if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
|
|
1136
|
-
else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
|
|
1137
|
-
}
|
|
1138
|
-
const joined = parts.join("\n").trim();
|
|
1139
|
-
return joined || null;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
/** Extract the first user-message preview from a rollout line, or null. */
|
|
1143
|
-
function extractUserMessagePreview(line: string): string | null {
|
|
1144
|
-
let parsed: unknown;
|
|
1145
|
-
try {
|
|
1146
|
-
parsed = JSON.parse(line);
|
|
1147
|
-
} catch {
|
|
1148
|
-
return null;
|
|
1149
|
-
}
|
|
1150
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
1151
|
-
const record = parsed as { type?: unknown; payload?: unknown };
|
|
1152
|
-
const payload = record.payload;
|
|
1153
|
-
if (!payload || typeof payload !== "object") return null;
|
|
1154
|
-
const p = payload as Record<string, unknown>;
|
|
1155
|
-
|
|
1156
|
-
if (record.type === "event_msg") {
|
|
1157
|
-
// codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
|
|
1158
|
-
if (p.type === "user_message" || typeof p.message === "string") {
|
|
1159
|
-
if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
|
|
1160
|
-
const fromContent = textFromContentParts(p.content);
|
|
1161
|
-
if (fromContent) return fromContent;
|
|
1162
|
-
}
|
|
1163
|
-
return null;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
if (record.type === "response_item") {
|
|
1167
|
-
if (p.type === "message" && p.role === "user") {
|
|
1168
|
-
return textFromContentParts(p.content);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
return null;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
/**
|
|
1175
|
-
* Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
|
|
1176
|
-
* Returns null when the file is missing or has no parseable `session_meta`.
|
|
1177
|
-
*
|
|
1178
|
-
* Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
|
|
1179
|
-
* rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
|
|
1180
|
-
* no decompressed copy is written to disk.
|
|
1181
|
-
*/
|
|
1182
|
-
export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
|
|
1183
|
-
if (!path || !existsSync(path)) return null;
|
|
1184
|
-
let raw: string;
|
|
1185
|
-
try {
|
|
1186
|
-
raw = path.endsWith(".zst")
|
|
1187
|
-
? decompressRolloutZstUtf8(path)
|
|
1188
|
-
: readFileSync(path, "utf8");
|
|
1189
|
-
} catch {
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
return parseThreadFieldsFromRolloutText(raw);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
function decompressRolloutZstUtf8(
|
|
1196
|
-
path: string,
|
|
1197
|
-
maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
|
|
1198
|
-
): string {
|
|
1199
|
-
const compressed = readFileSync(path);
|
|
1200
|
-
const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
|
|
1201
|
-
maxOutputLength: maxBytes,
|
|
1202
|
-
});
|
|
1203
|
-
if (decoded.byteLength > maxBytes) {
|
|
1204
|
-
throw new Error("rollout_zst_too_large");
|
|
1205
|
-
}
|
|
1206
|
-
return new TextDecoder().decode(decoded);
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
|
|
1210
|
-
const lines = raw.split("\n");
|
|
1211
|
-
let latest: ParsedSessionMeta | null = null;
|
|
1212
|
-
let firstUserMessage = "";
|
|
1213
|
-
for (const line of lines) {
|
|
1214
|
-
if (!line) continue;
|
|
1215
|
-
if (line.includes("\"session_meta\"")) {
|
|
1216
|
-
const meta = parseSessionMetaLine(line);
|
|
1217
|
-
if (meta) latest = meta;
|
|
1218
|
-
}
|
|
1219
|
-
if (!firstUserMessage) {
|
|
1220
|
-
const preview = extractUserMessagePreview(line);
|
|
1221
|
-
if (preview) firstUserMessage = preview;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
if (!latest) return null;
|
|
1225
|
-
const payload = latest.record.payload;
|
|
1226
|
-
const id = typeof payload.id === "string" ? payload.id : "";
|
|
1227
|
-
if (!id) return null;
|
|
1228
|
-
const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
|
|
1229
|
-
? payload.model_provider
|
|
1230
|
-
: "openai";
|
|
1231
|
-
const source = typeof payload.source === "string" && payload.source
|
|
1232
|
-
? payload.source
|
|
1233
|
-
: "cli";
|
|
1234
|
-
return {
|
|
1235
|
-
id,
|
|
1236
|
-
modelProvider,
|
|
1237
|
-
source,
|
|
1238
|
-
firstUserMessage,
|
|
1239
|
-
hasUserEvent: firstUserMessage.trim() ? 1 : 0,
|
|
1240
|
-
...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
|
|
1241
|
-
...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
|
|
1242
|
-
...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
1100
|
/**
|
|
1247
1101
|
* Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
|
|
1248
1102
|
* rather than rewriting line 1. The appended line clones the latest metadata payload (so no field
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reading side of Codex rollout JSONL files: the `session_meta` fold and the thread fields a
|
|
6
|
+
* quarantine restore reconstructs from a rollout.
|
|
7
|
+
*
|
|
8
|
+
* Split out of `history-provider.ts` when that file crossed the repository file-size ratchet at
|
|
9
|
+
* 2009 lines. The cut follows a real seam rather than a line count: everything here reads a
|
|
10
|
+
* rollout file and returns plain data, while the module it left keeps the database and manifest
|
|
11
|
+
* writes. Nothing here opens a database or touches a backup manifest.
|
|
12
|
+
*
|
|
13
|
+
* `history-provider.ts` re-exports the public names, so existing importers are unaffected.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
|
|
17
|
+
* Bounds peak memory while reconstructing thread rows; never write the decoded
|
|
18
|
+
* JSONL to disk.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
export interface ParsedSessionMeta {
|
|
23
|
+
record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
|
|
27
|
+
export function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
|
|
28
|
+
let parsed: unknown;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(line);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
35
|
+
const record = parsed as ParsedSessionMeta["record"];
|
|
36
|
+
if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
|
|
37
|
+
return { record };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
|
|
42
|
+
* Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
|
|
43
|
+
*
|
|
44
|
+
* Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
|
|
45
|
+
* first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
|
|
46
|
+
*/
|
|
47
|
+
export interface RolloutThreadFields {
|
|
48
|
+
id: string;
|
|
49
|
+
modelProvider: string;
|
|
50
|
+
source: string;
|
|
51
|
+
firstUserMessage: string;
|
|
52
|
+
hasUserEvent: number;
|
|
53
|
+
cwd?: string;
|
|
54
|
+
historyMode?: string;
|
|
55
|
+
cliVersion?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function textFromContentParts(content: unknown): string | null {
|
|
59
|
+
if (typeof content === "string" && content.trim()) return content.trim();
|
|
60
|
+
if (!Array.isArray(content)) return null;
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
for (const part of content) {
|
|
63
|
+
if (!part || typeof part !== "object") continue;
|
|
64
|
+
const p = part as Record<string, unknown>;
|
|
65
|
+
if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
|
|
66
|
+
else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
|
|
67
|
+
}
|
|
68
|
+
const joined = parts.join("\n").trim();
|
|
69
|
+
return joined || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extract the first user-message preview from a rollout line, or null. */
|
|
73
|
+
function extractUserMessagePreview(line: string): string | null {
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(line);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
81
|
+
const record = parsed as { type?: unknown; payload?: unknown };
|
|
82
|
+
const payload = record.payload;
|
|
83
|
+
if (!payload || typeof payload !== "object") return null;
|
|
84
|
+
const p = payload as Record<string, unknown>;
|
|
85
|
+
|
|
86
|
+
if (record.type === "event_msg") {
|
|
87
|
+
// codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
|
|
88
|
+
if (p.type === "user_message" || typeof p.message === "string") {
|
|
89
|
+
if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
|
|
90
|
+
const fromContent = textFromContentParts(p.content);
|
|
91
|
+
if (fromContent) return fromContent;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (record.type === "response_item") {
|
|
97
|
+
if (p.type === "message" && p.role === "user") {
|
|
98
|
+
return textFromContentParts(p.content);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
|
|
106
|
+
* Returns null when the file is missing or has no parseable `session_meta`.
|
|
107
|
+
*
|
|
108
|
+
* Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
|
|
109
|
+
* rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
|
|
110
|
+
* no decompressed copy is written to disk.
|
|
111
|
+
*/
|
|
112
|
+
export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
|
|
113
|
+
if (!path || !existsSync(path)) return null;
|
|
114
|
+
let raw: string;
|
|
115
|
+
try {
|
|
116
|
+
raw = path.endsWith(".zst")
|
|
117
|
+
? decompressRolloutZstUtf8(path)
|
|
118
|
+
: readFileSync(path, "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return parseThreadFieldsFromRolloutText(raw);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function decompressRolloutZstUtf8(
|
|
126
|
+
path: string,
|
|
127
|
+
maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
|
|
128
|
+
): string {
|
|
129
|
+
const compressed = readFileSync(path);
|
|
130
|
+
const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
|
|
131
|
+
maxOutputLength: maxBytes,
|
|
132
|
+
});
|
|
133
|
+
if (decoded.byteLength > maxBytes) {
|
|
134
|
+
throw new Error("rollout_zst_too_large");
|
|
135
|
+
}
|
|
136
|
+
return new TextDecoder().decode(decoded);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
|
|
140
|
+
const lines = raw.split("\n");
|
|
141
|
+
let latest: ParsedSessionMeta | null = null;
|
|
142
|
+
let firstUserMessage = "";
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
if (line.includes("\"session_meta\"")) {
|
|
146
|
+
const meta = parseSessionMetaLine(line);
|
|
147
|
+
if (meta) latest = meta;
|
|
148
|
+
}
|
|
149
|
+
if (!firstUserMessage) {
|
|
150
|
+
const preview = extractUserMessagePreview(line);
|
|
151
|
+
if (preview) firstUserMessage = preview;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!latest) return null;
|
|
155
|
+
const payload = latest.record.payload;
|
|
156
|
+
const id = typeof payload.id === "string" ? payload.id : "";
|
|
157
|
+
if (!id) return null;
|
|
158
|
+
const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
|
|
159
|
+
? payload.model_provider
|
|
160
|
+
: "openai";
|
|
161
|
+
const source = typeof payload.source === "string" && payload.source
|
|
162
|
+
? payload.source
|
|
163
|
+
: "cli";
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
modelProvider,
|
|
167
|
+
source,
|
|
168
|
+
firstUserMessage,
|
|
169
|
+
hasUserEvent: firstUserMessage.trim() ? 1 : 0,
|
|
170
|
+
...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
|
|
171
|
+
...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
|
|
172
|
+
...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, linkSync, mkdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -37,6 +37,38 @@ export interface CatalogBackupWriteIO {
|
|
|
37
37
|
|
|
38
38
|
let backupTempSequence = 0;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Do the prepared bytes differ from what is already on disk at `prepared.path`?
|
|
42
|
+
*
|
|
43
|
+
* The single no-op rule for both files the catalog layer owns — the active catalog
|
|
44
|
+
* and Codex's models cache — so the two writers cannot drift apart. Every
|
|
45
|
+
* mtime-keyed reader has to treat a rewrite as a change: the app-server staleness
|
|
46
|
+
* classifier (#857) compares a file's mtime against each running Codex's start
|
|
47
|
+
* time, so rewriting identical bytes marks every already-running Codex as stale
|
|
48
|
+
* even though nothing changed. #1459 established this rule for the catalog; the
|
|
49
|
+
* cache is the second writer that has to apply it, because
|
|
50
|
+
* `refreshCodexModelCatalog` reports `cacheSynced` straight into the startup
|
|
51
|
+
* warning in `handleStart`.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
|
|
54
|
+
* substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
|
|
55
|
+
* equal to prepared content holding a legitimately encoded U+FFFD. Comparing
|
|
56
|
+
* decoded strings would then classify a malformed file as identical, skip the
|
|
57
|
+
* atomic repair write, and leave the corruption on disk.
|
|
58
|
+
*
|
|
59
|
+
* An unreadable or absent file reports "differs", so the caller performs the real
|
|
60
|
+
* write; that also converges a file that does not exist yet.
|
|
61
|
+
*/
|
|
62
|
+
export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean {
|
|
63
|
+
let onDisk: Buffer;
|
|
64
|
+
try {
|
|
65
|
+
onDisk = readFileSync(prepared.path);
|
|
66
|
+
} catch {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return !onDisk.equals(Buffer.from(prepared.content, "utf8"));
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
function isMissingPathError(error: unknown): boolean {
|
|
41
73
|
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
42
74
|
}
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number {
|
|
|
71
71
|
cache.delete(provider);
|
|
72
72
|
cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
|
|
73
73
|
if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
|
|
74
|
+
// A removal changes this provider's content as surely as a publication does.
|
|
75
|
+
bumpProviderCacheRevision(provider);
|
|
74
76
|
return entry.sizeBytes;
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -177,6 +179,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri
|
|
|
177
179
|
return generation === captureModelCacheGeneration(provider);
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
/**
|
|
183
|
+
* How many times this provider's cached content has actually changed.
|
|
184
|
+
*
|
|
185
|
+
* Deliberately separate from the generation. A generation revokes an in-flight discovery's right
|
|
186
|
+
* to publish, so it advances on an authority clear and must not be repurposed: moving it on a
|
|
187
|
+
* successful publication would cancel writes that are still legitimate. This counts accepted
|
|
188
|
+
* publications and removals instead, which is what a reader holding a derived roster needs to
|
|
189
|
+
* know, and it advances on exactly the event a generation does not: a discovery that succeeded
|
|
190
|
+
* and changed the rows.
|
|
191
|
+
*
|
|
192
|
+
* Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing
|
|
193
|
+
* one cannot alter what a later capture or publication sees.
|
|
194
|
+
*/
|
|
195
|
+
const providerCacheRevisions = new Map<string, number>();
|
|
196
|
+
let globalContentRevision = 0;
|
|
197
|
+
|
|
198
|
+
function bumpProviderCacheRevision(provider: string): void {
|
|
199
|
+
providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function observeModelCacheRevision(provider: string): string {
|
|
203
|
+
// The global term covers a clear that empties the map wholesale, which per-provider counters
|
|
204
|
+
// cannot express: without it, wiping every entry and republishing identical-looking rows would
|
|
205
|
+
// read as unchanged. It also survives pruning, so a retired provider cannot come back with a
|
|
206
|
+
// counter that matches a roster built before it left.
|
|
207
|
+
return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
/**
|
|
181
211
|
* Store a live result unless the cache was cleared while that asynchronous discovery was running.
|
|
182
212
|
* The optional generation keeps existing direct cache writers unchanged while discovery callers can
|
|
@@ -200,6 +230,8 @@ export function setCached(
|
|
|
200
230
|
oldestCachedAt = now;
|
|
201
231
|
}
|
|
202
232
|
enforceAppOwnedMemoryBudget();
|
|
233
|
+
// Published and accepted, so anything derived from this provider's rows is now out of date.
|
|
234
|
+
bumpProviderCacheRevision(provider);
|
|
203
235
|
return true;
|
|
204
236
|
}
|
|
205
237
|
|
|
@@ -220,6 +252,11 @@ export function clearModelCache(
|
|
|
220
252
|
} else {
|
|
221
253
|
if (revokesInFlightDiscovery) globalCacheGeneration += 1;
|
|
222
254
|
cache.clear();
|
|
255
|
+
// A wholesale clear changes every provider's content at once, and clearing the map means no
|
|
256
|
+
// per-provider counter can record it. Advancing the global term retires every derived roster
|
|
257
|
+
// and lets the per-provider entries be dropped without an ABA on the way back.
|
|
258
|
+
globalContentRevision += 1;
|
|
259
|
+
providerCacheRevisions.clear();
|
|
223
260
|
cacheBytes = 0;
|
|
224
261
|
oldestCachedProvider = undefined;
|
|
225
262
|
oldestCachedAt = null;
|
|
@@ -237,6 +274,7 @@ export function reconcileModelCacheProviders(
|
|
|
237
274
|
const removedProviders = new Set<string>();
|
|
238
275
|
const trackedProviders = new Set([
|
|
239
276
|
...providerCacheGenerations.keys(),
|
|
277
|
+
...providerCacheRevisions.keys(),
|
|
240
278
|
...failureAt.keys(),
|
|
241
279
|
...discoveryStatus.keys(),
|
|
242
280
|
...liveModelCounts.keys(),
|
|
@@ -247,6 +285,10 @@ export function reconcileModelCacheProviders(
|
|
|
247
285
|
if (validProviders.has(provider)) continue;
|
|
248
286
|
if (!revokedRemovedProviderAuthority) {
|
|
249
287
|
globalCacheGeneration += 1;
|
|
288
|
+
// Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot
|
|
289
|
+
// present the counter a roster was built against. Without this the delete below is an ABA:
|
|
290
|
+
// the entry returns at zero and an old stamp matches again.
|
|
291
|
+
globalContentRevision += 1;
|
|
250
292
|
revokedRemovedProviderAuthority = true;
|
|
251
293
|
}
|
|
252
294
|
providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
|
|
@@ -255,6 +297,11 @@ export function reconcileModelCacheProviders(
|
|
|
255
297
|
failureAt.delete(provider);
|
|
256
298
|
discoveryStatus.delete(provider);
|
|
257
299
|
liveModelCounts.delete(provider);
|
|
300
|
+
// AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate
|
|
301
|
+
// the entry we just removed. Dropped rather than left behind: a provider the configuration no
|
|
302
|
+
// longer has must not keep an entry alive for the life of the process merely because nothing
|
|
303
|
+
// cleared the whole cache. The global epoch advanced above, so the removal is not an ABA.
|
|
304
|
+
providerCacheRevisions.delete(provider);
|
|
258
305
|
removedProviders.add(provider);
|
|
259
306
|
}
|
|
260
307
|
lastReconciledGeneration = generation;
|