From 9f666b33a5070f37f80108d45a9563e13dd3bff2 Mon Sep 17 00:00:00 2001 From: Yumi Date: Mon, 7 Sep 2026 19:09:02 -0600 Subject: [PATCH] fix(codex): invalidate app-server observations at catalog boundaries Extracted and adapted from fork commit 54c7793a21c7f3c942df0a1b8bc32012516b7714. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../content/docs/guides/codex-app-models.md | 2 + src/codex/internal/catalog-writer.ts | 3 + src/codex/sync.ts | 5 ++ .../codex-models-cache-invalidate.test.ts | 62 ++++++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index bcda44080b..6f9b756789 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -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. diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 370bbda95e..0d9bee79ef 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -16,6 +16,7 @@ import { forgetEphemeralSecretPath, hardenSecretPath, } from "../../lib/windows-secret-acl"; +import { resetCodexAppServerCatalogStateCache } from "../app-server-processes"; export interface PreparedCatalogFileWrite { readonly path: string; @@ -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. */ @@ -200,4 +202,5 @@ export function replaceCodexModelsCache( ): void { assertCatalogWritePermit(permit, owningCodexHome); atomicWriteFile(prepared.path, prepared.content, io); + resetCodexAppServerCatalogStateCache(); } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 4c10068b74..6d7008a01a 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -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 { /** @@ -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)(); diff --git a/tests/codex-integration/codex-models-cache-invalidate.test.ts b/tests/codex-integration/codex-models-cache-invalidate.test.ts index 3efb316511..b644740e40 100644 --- a/tests/codex-integration/codex-models-cache-invalidate.test.ts +++ b/tests/codex-integration/codex-models-cache-invalidate.test.ts @@ -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"; @@ -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; @@ -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"); + }); });