Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/codex-app-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,5 @@ ocx sync

opencodex rewrites `models_cache.json` with a deliberately stale cache wrapper whenever catalog
visibility, priority, or metadata changes, so the next Codex model refresh reads the new catalog.

After a catalog or model-cache write, OpenCodex invalidates its cached app-server observation so the next request checks process freshness again. A configuration sync also invalidates the observation when catalog contents are unchanged. This refresh does not restart Codex processes.
3 changes: 3 additions & 0 deletions src/codex/internal/catalog-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
forgetEphemeralSecretPath,
hardenSecretPath,
} from "../../lib/windows-secret-acl";
import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";

export interface PreparedCatalogFileWrite {
readonly path: string;
Expand Down Expand Up @@ -167,6 +168,7 @@ export function replaceActiveCodexCatalog(
): void {
assertCatalogWritePermit(permit, owningCodexHome);
atomicWriteFile(prepared.path, prepared.content, io);
resetCodexAppServerCatalogStateCache();
}

/** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
Expand Down Expand Up @@ -200,4 +202,5 @@ export function replaceCodexModelsCache(
): void {
assertCatalogWritePermit(permit, owningCodexHome);
atomicWriteFile(prepared.path, prepared.content, io);
resetCodexAppServerCatalogStateCache();
}
5 changes: 5 additions & 0 deletions src/codex/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./cat
import { shouldSyncCodexOnStart } from "./desired-state";
import { admitCodexWrite, type CodexAdmission } from "./admission";
import type { CodexCatalogSyncOptions } from "./catalog/sync";
import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";

export interface CodexSyncResult {
/**
Expand Down Expand Up @@ -116,6 +117,10 @@ export async function syncModelsToCodex(
message: admission.message,
};
}
// Config injection is a relevant Codex write even when the catalog bytes are unchanged.
// Drop cached process evidence before async discovery so a process that appeared since the
// last read cannot make native-default guidance report active after this sync.
resetCodexAppServerCatalogStateCache();
const p = port ?? config.port ?? 10100;
const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();

Expand Down
62 changes: 60 additions & 2 deletions tests/codex-integration/codex-models-cache-invalidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import { join } from "node:path";
import { invalidateCodexModelsCache } from "../../src/codex/catalog";
import { invalidateCodexModelsCacheWithPermit } from "../../src/codex/catalog/sync";
import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization";
import { afterCatalogWriteHandleAppServers } from "../../src/codex/app-server-processes";
import {
collectCodexAppServerCatalogStateForRequest,
resetCodexAppServerCatalogStateCache,
afterCatalogWriteHandleAppServers,
} from "../../src/codex/app-server-processes";
import { refreshCodexModelCatalog } from "../../src/codex/refresh";
import { syncModelsToCodex } from "../../src/codex/sync";
import { flushConfigDirHardening } from "../../src/config/paths";
import type { OcxConfig } from "../../src/types";
import { removeTreeWithRetry } from "../helpers/remove-tree";

Expand All @@ -32,7 +37,9 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => {
process.env.OPENCODEX_HOME = opencodexHome;
});

afterEach(() => {
afterEach(async () => {
await flushConfigDirHardening(opencodexHome);
resetCodexAppServerCatalogStateCache();
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME;
Expand Down Expand Up @@ -298,4 +305,55 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => {
expect(errors).toEqual([]);
expect(logs).toEqual([]);
});
test("sync invalidates a cached not-running observation before a catalog write", async () => {
let snapshots: Array<{ pid: number; commandLine: string }> = [];
const io = {
platform: "win32" as const,
now: () => 3_000,
listSnapshotsAsync: async () => snapshots,
readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 1_000])),
catalogMtimeMs: () => 2_000,
};
resetCodexAppServerCatalogStateCache();
expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running");
snapshots = [{ pid: 42, commandLine: "codex app-server" }];
// Prove the real request cache is warm; injected synchronous IO bypasses it.
expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running");

writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: "gpt-5.5" }] }));
expect(invalidateCodexModelsCache({ allowWhenDesiredDisabled: true })).toBe(true);

expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("stale");
});

test("sync invalidates cached process state even when catalog refresh is a no-op", async () => {
let snapshots: Array<{ pid: number; commandLine: string }> = [];
const io = {
platform: "win32" as const,
now: () => 3_000,
listSnapshotsAsync: async () => snapshots,
readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 1_000])),
catalogMtimeMs: () => 2_000,
};
resetCodexAppServerCatalogStateCache();
expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running");
snapshots = [{ pid: 42, commandLine: "codex app-server" }];
// Prove the real request cache is warm; injected synchronous IO bypasses it.
expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("not_running");

await syncModelsToCodex(19107, emptyConfig, null, {
refreshCodexModelCatalog: async () => ({
added: 0,
path: join(codexHome, "opencodex-catalog.json"),
catalogExists: false,
catalogWritten: false,
cacheSynced: false,
comboOmissions: [],
}),
injectCodexConfig: async () => ({ success: true, message: "injected" }),
currentExternalCodexModelProvider: () => null,
});

expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("stale");
});
});
Loading