From 3990288007b11d65d663cc48af51e680b7d49ea7 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 13:52:56 +0000 Subject: [PATCH 1/4] fix(claude): sync agent roster on proxy startup --- .../src/content/docs/guides/claude-code.md | 7 +- src/cli/claude-agent-startup-sync.ts | 45 +++++++++++ src/cli/index.ts | 7 ++ structure/03_catalog-and-subagents.md | 20 +++++ tests/claude-agent-startup-sync.test.ts | 76 +++++++++++++++++++ 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/cli/claude-agent-startup-sync.ts create mode 100644 tests/claude-agent-startup-sync.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 7c3a86dea3..869da6752a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -263,8 +263,8 @@ When both `tierModels.haiku` and `smallFastModel` are absent, OpenCodex leaves b ## Roster agents (injectAgents) -`ocx claude` (and the system-env daemon) syncs your featured subagent roster (Subagents tab, -up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`. +Proxy startup/ensure, `ocx claude`, and relevant dashboard saves sync your featured subagent roster +(Subagents tab, up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`. - **`ocx-self`** pins your `/model` picker default (falling back to `claudeCode.model`); omitted when neither exists. It does NOT use model inheritance. @@ -276,7 +276,8 @@ up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`. overwritten or pruned; your own agents are never touched. - Files are atomically synced per file (write + rename). - `enabled: false` or `injectAgents: false` prunes all verified-owned definitions. -- GUI PUT and roster changes resync immediately; launcher/system-env sync at launch. +- GUI PUT and roster changes resync immediately; every foreground or background proxy start/ensure + reconciles the owned files before a later Claude Code launch reads them. Dispatch: `subagent_type: "ocx-gpt-5-6-sol"`. 1M-capable targets carry `[1m]` automatically. diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts new file mode 100644 index 0000000000..048fba2978 --- /dev/null +++ b/src/cli/claude-agent-startup-sync.ts @@ -0,0 +1,45 @@ +import type { OcxConfig } from "../types"; +import { injectClaudeAgentDefs } from "../claude/agents-inject"; +import { fetchClaudeContextWindows } from "./claude"; + +export interface ClaudeAgentStartupSyncDeps { + fetchContextWindows?: typeof fetchClaudeContextWindows; + injectAgentDefs?: typeof injectClaudeAgentDefs; + warn?: (message: string) => void; +} + +/** + * Reconcile the generated Claude Code roster after the proxy listener is live. + * + * This belongs to the owning CLI lifecycle rather than `startServer`: the latter is also a + * library/test primitive and must not mutate a developer's real `~/.claude` directory merely + * because an in-process test server was created. The live Management API supplies the same bounded + * context-window map used by `ocx claude`; failure keeps startup available and falls back to an + * unmarked roster. Disabled integrations skip discovery and prune verified-owned definitions. + */ +export async function syncClaudeAgentDefsAtProxyStartup( + config: OcxConfig, + port: number, + deps: ClaudeAgentStartupSyncDeps = {}, +): Promise { + const inject = deps.injectAgentDefs ?? injectClaudeAgentDefs; + const warn = deps.warn ?? (message => console.warn(message)); + + try { + if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) { + return inject(config, {}); + } + + let windows: Record = {}; + try { + windows = await (deps.fetchContextWindows ?? fetchClaudeContextWindows)(config, port); + } catch { + // Startup remains best-effort. The next management mutation or `ocx claude` launch can + // restore context markers after a transient catalog/Management API failure. + } + return inject(config, windows); + } catch (error) { + warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e723db0e56..f38b106898 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -55,6 +55,7 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { syncClaudeAgentDefsAtProxyStartup } from "./claude-agent-startup-sync"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -381,6 +382,11 @@ async function handleStart(options: { block?: boolean } = {}) { // The hook is useful only for an installed Claude Code CLI. Reconcile instead of // appending unconditionally so stale OpenCodex-owned hooks are removed as well. reportShellHookFailure(reconcileShellHook(systemEnv.injected)); + // `injectSystemEnv` owns this sync only on macOS with systemEnv enabled. Every other + // foreground/service start still has to converge the generated roster before another + // Claude process reads it. Keep the owning CLI boundary: `startServer` is also a library + // primitive and must not mutate ~/.claude from tests or embedders. + if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, port); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start // Post-startup sync drives the readiness gate AND the #1046 stale app-server @@ -469,6 +475,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); + if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, live.port); // Refresh the Grok Build fence too (same contract as start). live.hostname is the // hostname the running proxy actually bound — config.hostname may have drifted. try { diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 49272b9f1c..d56ce1a4b2 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -340,3 +340,23 @@ native passthrough is enabled; `modelMap` claims and `nativePassthrough:false` r guard avoids creating oversized skill messages before the proxy can intervene; inbound elision remains the fallback if a client still sends a blocked bundle. An explicit empty list disables both routed-model behaviors. + +[Decision Log] +- 목적과 의도: keep generated Claude Code `ocx-*.md` roster files synchronized when the proxy is + started or ensured on Linux, Windows, and macOS, including background service restarts. +- 기존 구현 및 제약 조건: explicit `ocx claude` launches and Management API writes reconciled the + files, while the startup call inside `injectSystemEnv` ran only on macOS with system-env enabled. + `startServer` is also used as an in-process library/test primitive and cannot safely mutate the + real user home on every invocation. +- 검토한 주요 대안: write from `startServer`; duplicate hooks in each OS service manager; reconcile + once from the owning CLI lifecycle after the listener becomes available. +- 선택한 방식: the foreground/service start and live-proxy ensure paths call one best-effort helper + after bind, using the live Management API context-window map and the existing marker-verified + atomic roster writer. macOS system-env startup keeps its existing shared-window sync and skips the + duplicate call. +- 다른 대안 대신 이 방식을 선택한 이유: it covers every supported service entrypoint without + adding home-directory side effects to server-library consumers or creating a second roster format. +- 장점, 단점 및 영향: stale owned definitions converge on every daemon start, disabled integration + prunes them without provider discovery, and catalog failure falls back to unmarked definitions so + startup remains available. A later dashboard save or `ocx claude` launch restores missing context + markers after a transient failure. diff --git a/tests/claude-agent-startup-sync.test.ts b/tests/claude-agent-startup-sync.test.ts new file mode 100644 index 0000000000..b42bfa803c --- /dev/null +++ b/tests/claude-agent-startup-sync.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { syncClaudeAgentDefsAtProxyStartup } from "../src/cli/claude-agent-startup-sync"; +import type { OcxConfig } from "../src/types"; + +const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({ + providers: [], + claudeCode, +} as OcxConfig); + +describe("Claude agent roster proxy-start synchronization (#2200)", () => { + test("uses the live proxy context-window map for an enabled roster", async () => { + const calls: Array<{ port: number; windows?: Record }> = []; + const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, { + fetchContextWindows: async (_cfg, port) => { + calls.push({ port }); + return { "google/gemini-3.7-flash": 1_000_000 }; + }, + injectAgentDefs: (_cfg, windows) => { + calls.push({ port: 0, windows }); + return ["ocx-google-gemini-3-7-flash.md"]; + }, + }); + + expect(result).toEqual(["ocx-google-gemini-3-7-flash.md"]); + expect(calls).toEqual([ + { port: 10100 }, + { port: 0, windows: { "google/gemini-3.7-flash": 1_000_000 } }, + ]); + }); + + test("disabled integration prunes owned definitions without touching discovery", async () => { + let fetched = false; + let injected: Record | undefined; + const result = await syncClaudeAgentDefsAtProxyStartup(config({ injectAgents: false }), 10100, { + fetchContextWindows: async () => { + fetched = true; + return { stale: 1_000_000 }; + }, + injectAgentDefs: (_cfg, windows) => { + injected = windows; + return []; + }, + }); + + expect(result).toEqual([]); + expect(fetched).toBe(false); + expect(injected).toEqual({}); + }); + + test("catalog failure falls back to an unmarked best-effort roster", async () => { + let injected: Record | undefined; + const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, { + fetchContextWindows: async () => { throw new Error("catalog unavailable"); }, + injectAgentDefs: (_cfg, windows) => { + injected = windows; + return ["ocx-self.md"]; + }, + }); + + expect(result).toEqual(["ocx-self.md"]); + expect(injected).toEqual({}); + }); + + test("write failures are warned and never fail proxy startup", async () => { + const warnings: string[] = []; + const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, { + fetchContextWindows: async () => ({}), + injectAgentDefs: () => { throw new Error("permission denied"); }, + warn: message => warnings.push(message), + }); + + expect(result).toBeNull(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("permission denied"); + }); +}); From 0a0dff1c4c99d85a484279d15f8de24f2e6198a1 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 14:39:20 +0000 Subject: [PATCH 2/4] fix(claude): await roster sync during ensure --- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../src/content/docs/ko/guides/claude-code.md | 2 +- .../src/content/docs/ru/guides/claude-code.md | 2 +- .../content/docs/zh-cn/guides/claude-code.md | 2 +- src/cli/index.ts | 16 ++++++---- tests/claude-agent-startup-sync.test.ts | 31 +++++++++++++++++++ 6 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 532419e23f..bc7e84e39d 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -142,7 +142,7 @@ Claude ページで圧縮値を調整できます。**警告:** モデルの実 ## ロスターエージェント(injectAgents) -`ocx claude` とシステム環境デーモンは推奨サブエージェントロスター(Subagents タブ、最大 5 モデル)と +プロキシの起動/ensure、`ocx claude`、関連するダッシュボード保存は推奨サブエージェントロスター(Subagents タブ、最大 5 モデル)と `ocx-self` を `~/.claude/agents/ocx-*.md` に同期します。 - **`ocx-self`** は `/model` ピッカーのデフォルトを固定し、値がない場合は `claudeCode.model` を使います。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index c5aae96d36..0183b5b72f 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -179,7 +179,7 @@ Claude 페이지에서 압축 값을 조절할 수 있어요. **경고:** 모델 ## 로스터 에이전트(injectAgents) -`ocx claude`와 시스템 환경 데몬은 추천 서브에이전트 로스터(Subagents 탭, 최대 5개 모델)와 +프록시 시작/ensure, `ocx claude`, 관련 대시보드 저장은 추천 서브에이전트 로스터(Subagents 탭, 최대 5개 모델)와 `ocx-self`를 `~/.claude/agents/ocx-*.md`에 동기화해요. - **`ocx-self`**는 `/model` 선택기의 기본값을 고정하고, 값이 없으면 `claudeCode.model`을 사용해요. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 46bc060c40..b04612625b 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -155,7 +155,7 @@ smallFastModel`; оно подставляется в обе переменны ## Агенты из ростера (injectAgents) -`ocx claude` (и демон системного окружения) синхронизирует ваш ростер избранных подагентов +Запуск/ensure прокси, `ocx claude` и сохранение связанных настроек в панели синхронизируют ваш ростер избранных подагентов (вкладка Subagents, до 5 моделей) плюс `ocx-self` в `~/.claude/agents/ocx-*.md`. - **`ocx-self`** закрепляет модель по умолчанию из селектора `/model` (с откатом на diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 08fb965f27..e5140cb8a7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -145,7 +145,7 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / ## 名册代理(injectAgents) -`ocx claude`(以及系统环境守护进程)会把你的精选子代理名册(Subagents 标签页,最多 5 个模型) +代理启动/ensure、`ocx claude` 和相关的控制面板保存会把你的精选子代理名册(Subagents 标签页,最多 5 个模型) 和 `ocx-self` 同步到 `~/.claude/agents/ocx-*.md`。 - **`ocx-self`** 固定你在 `/model` 选择器中的默认模型(回退到 `claudeCode.model`);两者均 diff --git a/src/cli/index.ts b/src/cli/index.ts index f38b106898..abd5c48161 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -382,12 +382,6 @@ async function handleStart(options: { block?: boolean } = {}) { // The hook is useful only for an installed Claude Code CLI. Reconcile instead of // appending unconditionally so stale OpenCodex-owned hooks are removed as well. reportShellHookFailure(reconcileShellHook(systemEnv.injected)); - // `injectSystemEnv` owns this sync only on macOS with systemEnv enabled. Every other - // foreground/service start still has to converge the generated roster before another - // Claude process reads it. Keep the owning CLI boundary: `startServer` is also a library - // primitive and must not mutate ~/.claude from tests or embedders. - if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, port); - await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start // Post-startup sync drives the readiness gate AND the #1046 stale app-server // warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle @@ -396,6 +390,12 @@ async function handleStart(options: { block?: boolean } = {}) { // half-synced proxy as ready while /healthz stays live. const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); + // `injectSystemEnv` owns this sync only on macOS with systemEnv enabled. Run every other + // foreground/service roster reconcile AFTER native Codex startup sync: /healthz is already + // live at this point, and putting a catalog fetch before injection created a measurable window + // where healthy callers still observed the untouched native config. `ocx ensure` separately + // awaits this same idempotent fence before returning from a newly spawned proxy. + if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -519,6 +519,10 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent + // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are + // still observable. Always use the live port, including fallback-port starts. + await syncClaudeAgentDefsAtProxyStartup(config, port); console.log(`✅ Proxy running on port ${port}`); return true; } diff --git a/tests/claude-agent-startup-sync.test.ts b/tests/claude-agent-startup-sync.test.ts index b42bfa803c..48d9db0927 100644 --- a/tests/claude-agent-startup-sync.test.ts +++ b/tests/claude-agent-startup-sync.test.ts @@ -1,13 +1,44 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { syncClaudeAgentDefsAtProxyStartup } from "../src/cli/claude-agent-startup-sync"; import type { OcxConfig } from "../src/types"; +const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); + +function sourceSlice(start: string, end: string): string { + const from = cliSource.indexOf(start); + const to = cliSource.indexOf(end, from); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + return cliSource.slice(from, to); +} + const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({ providers: [], claudeCode, } as OcxConfig); describe("Claude agent roster proxy-start synchronization (#2200)", () => { + test("foreground startup injects Codex before potentially slow roster discovery", () => { + const start = sourceSlice("async function handleStart(", "async function handleEnsure("); + expect(start.indexOf("await syncCodexOnStartIfEnabled(")).toBeLessThan( + start.indexOf("await syncClaudeAgentDefsAtProxyStartup("), + ); + }); + + test("a newly spawned ensure awaits roster reconciliation before reporting success", () => { + const ensure = sourceSlice("async function handleEnsure(", "async function handleTrayProxyStart("); + const spawned = ensure.slice(ensure.indexOf("const pinPort")); + const healthyAt = spawned.indexOf("await waitForProxy()"); + const rosterAt = spawned.indexOf("await syncClaudeAgentDefsAtProxyStartup(config, port)"); + const successAt = spawned.indexOf("console.log(`✅ Proxy running on port ${port}`)"); + + expect(healthyAt).toBeGreaterThan(-1); + expect(rosterAt).toBeGreaterThan(healthyAt); + expect(successAt).toBeGreaterThan(rosterAt); + }); + test("uses the live proxy context-window map for an enabled roster", async () => { const calls: Array<{ port: number; windows?: Record }> = []; const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, { From b141d9caeecfd22e0596d3ea1ed80c9bf5e22762 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 15:15:28 +0000 Subject: [PATCH 3/4] fix(claude): hold readiness through roster sync --- src/cli/claude-agent-startup-sync.ts | 28 ++++++++ src/cli/index.ts | 28 ++++---- structure/03_catalog-and-subagents.md | 4 +- tests/claude-agent-startup-sync.test.ts | 85 ++++++++++++++----------- tests/cli-ready.test.ts | 27 ++++---- 5 files changed, 109 insertions(+), 63 deletions(-) diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts index 048fba2978..10751ae8de 100644 --- a/src/cli/claude-agent-startup-sync.ts +++ b/src/cli/claude-agent-startup-sync.ts @@ -1,6 +1,7 @@ import type { OcxConfig } from "../types"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { fetchClaudeContextWindows } from "./claude"; +import type { ReadinessGate } from "../server/readiness"; export interface ClaudeAgentStartupSyncDeps { fetchContextWindows?: typeof fetchClaudeContextWindows; @@ -8,6 +9,33 @@ export interface ClaudeAgentStartupSyncDeps { warn?: (message: string) => void; } +/** + * Keep the public readiness gate pending until both startup reconciliations have settled. + * + * The Codex sync remains the authority for ready versus failed. Claude roster repair is + * deliberately best-effort (#2200), but readiness must not become observable between the + * Codex write and that repair: a service manager could otherwise launch Claude Code against + * stale `ocx-*.md` files. A small forwarding gate delays only the successful transition; + * terminal Codex failure is still published immediately. + */ +export async function reconcileClientStartupBeforeReady( + readinessGate: ReadinessGate, + syncCodex: (deferredGate: ReadinessGate) => Promise, + syncClaudeRoster: () => Promise, +): Promise { + let codexReady = false; + const deferredGate: ReadinessGate = { + getStatus: () => readinessGate.getStatus(), + markReady: () => { codexReady = true; }, + markFailed: () => readinessGate.markFailed(), + }; + + const result = await syncCodex(deferredGate); + await syncClaudeRoster(); + if (codexReady) readinessGate.markReady(); + return result; +} + /** * Reconcile the generated Claude Code roster after the proxy listener is live. * diff --git a/src/cli/index.ts b/src/cli/index.ts index abd5c48161..a2c1599b3d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -55,7 +55,10 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; -import { syncClaudeAgentDefsAtProxyStartup } from "./claude-agent-startup-sync"; +import { + reconcileClientStartupBeforeReady, + syncClaudeAgentDefsAtProxyStartup, +} from "./claude-agent-startup-sync"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -383,19 +386,18 @@ async function handleStart(options: { block?: boolean } = {}) { // appending unconditionally so stale OpenCodex-owned hooks are removed as well. reportShellHookFailure(reconcileShellHook(systemEnv.injected)); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start - // Post-startup sync drives the readiness gate AND the #1046 stale app-server - // warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle - // (OFF → no sync) and reports whether anything was written; the readiness gate - // observes the real sync outcome (ok/warning) so /readyz never advertises a - // half-synced proxy as ready while /healthz stays live. - const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate); + // Codex sync owns the ready/failed verdict, but its successful transition is + // deferred until the best-effort Claude roster reconciliation settles. This + // keeps /readyz closed across both startup writes without making an optional + // Claude integration failure prevent the proxy from starting. + const startupSync = await reconcileClientStartupBeforeReady( + readinessGate, + gate => syncCodexOnStartIfEnabled(port, config, undefined, gate), + () => systemEnv.injected + ? Promise.resolve(null) + : syncClaudeAgentDefsAtProxyStartup(config, port), + ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); - // `injectSystemEnv` owns this sync only on macOS with systemEnv enabled. Run every other - // foreground/service roster reconcile AFTER native Codex startup sync: /healthz is already - // live at this point, and putting a catalog fetch before injection created a measurable window - // where healthy callers still observed the untouched native config. `ocx ensure` separately - // awaits this same idempotent fence before returning from a newly spawned proxy. - if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d56ce1a4b2..6e2fd53786 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -79,7 +79,9 @@ instead. Codex's own `models_cache.json` is a different cache, invalidated by ca ## Startup readiness Each `startServer` invocation owns a private, one-shot readiness gate created before the listener -binds. `handleStart` supplies its gate and transitions it after the shared catalog sync settles. +binds. `handleStart` supplies its gate and transitions it only after the shared catalog sync and +best-effort Claude Code roster reconciliation have both settled. The catalog sync remains the +authority for ready versus failed; a roster warning does not make an otherwise healthy proxy fail. Calls without a supplied gate receive a fresh private gate that intentionally remains pending. Only `ok: true` with no nonempty warning becomes ready; `null`, a throw, `ok !== true`, or a nonempty warning becomes failed. State is isolated per server instance. diff --git a/tests/claude-agent-startup-sync.test.ts b/tests/claude-agent-startup-sync.test.ts index 48d9db0927..ab70857ac3 100644 --- a/tests/claude-agent-startup-sync.test.ts +++ b/tests/claude-agent-startup-sync.test.ts @@ -1,42 +1,40 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { syncClaudeAgentDefsAtProxyStartup } from "../src/cli/claude-agent-startup-sync"; +import { tmpdir } from "node:os"; +import { + reconcileClientStartupBeforeReady, + syncClaudeAgentDefsAtProxyStartup, +} from "../src/cli/claude-agent-startup-sync"; +import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; +import { createReadinessGate } from "../src/server/readiness"; import type { OcxConfig } from "../src/types"; -const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); - -function sourceSlice(start: string, end: string): string { - const from = cliSource.indexOf(start); - const to = cliSource.indexOf(end, from); - expect(from).toBeGreaterThan(-1); - expect(to).toBeGreaterThan(from); - return cliSource.slice(from, to); -} - const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({ providers: [], claudeCode, } as OcxConfig); describe("Claude agent roster proxy-start synchronization (#2200)", () => { - test("foreground startup injects Codex before potentially slow roster discovery", () => { - const start = sourceSlice("async function handleStart(", "async function handleEnsure("); - expect(start.indexOf("await syncCodexOnStartIfEnabled(")).toBeLessThan( - start.indexOf("await syncClaudeAgentDefsAtProxyStartup("), - ); - }); + test("keeps readiness pending until the best-effort roster fence settles", async () => { + const gate = createReadinessGate(); + let releaseRoster!: () => void; + const rosterPending = new Promise(resolve => { releaseRoster = resolve; }); - test("a newly spawned ensure awaits roster reconciliation before reporting success", () => { - const ensure = sourceSlice("async function handleEnsure(", "async function handleTrayProxyStart("); - const spawned = ensure.slice(ensure.indexOf("const pinPort")); - const healthyAt = spawned.indexOf("await waitForProxy()"); - const rosterAt = spawned.indexOf("await syncClaudeAgentDefsAtProxyStartup(config, port)"); - const successAt = spawned.indexOf("console.log(`✅ Proxy running on port ${port}`)"); + const startup = reconcileClientStartupBeforeReady( + gate, + async deferredGate => { + deferredGate.markReady(); + return { ran: true }; + }, + () => rosterPending, + ); - expect(healthyAt).toBeGreaterThan(-1); - expect(rosterAt).toBeGreaterThan(healthyAt); - expect(successAt).toBeGreaterThan(rosterAt); + await Promise.resolve(); + expect(gate.getStatus()).toBe("pending"); + releaseRoster(); + expect(await startup).toEqual({ ran: true }); + expect(gate.getStatus()).toBe("ready"); }); test("uses the live proxy context-window map for an enabled roster", async () => { @@ -78,18 +76,29 @@ describe("Claude agent roster proxy-start synchronization (#2200)", () => { expect(injected).toEqual({}); }); - test("catalog failure falls back to an unmarked best-effort roster", async () => { - let injected: Record | undefined; - const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, { - fetchContextWindows: async () => { throw new Error("catalog unavailable"); }, - injectAgentDefs: (_cfg, windows) => { - injected = windows; - return ["ocx-self.md"]; - }, - }); + test("catalog failure still runs the real injector with an unmarked roster", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-startup-roster-")); + try { + const configured = { + providers: [], + subagentModels: ["gpt-5.6-sol"], + claudeCode: { model: "gpt-5.6-sol" }, + } as OcxConfig; + const result = await syncClaudeAgentDefsAtProxyStartup(configured, 10100, { + fetchContextWindows: async () => { throw new Error("catalog unavailable"); }, + injectAgentDefs: (cfg, windows) => injectClaudeAgentDefs(cfg, windows, dir), + }); - expect(result).toEqual(["ocx-self.md"]); - expect(injected).toEqual({}); + expect(result?.sort()).toEqual(["ocx-gpt-5-6-sol.md", "ocx-self.md"]); + expect(readdirSync(join(dir, "agents")).sort()).toEqual(result?.sort()); + for (const file of result ?? []) { + const body = readFileSync(join(dir, "agents", file), "utf8"); + expect(body).toContain("generated-by: opencodex"); + expect(body).not.toContain("[1m]"); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test("write failures are warned and never fail proxy startup", async () => { diff --git a/tests/cli-ready.test.ts b/tests/cli-ready.test.ts index cab4c1ab4c..577491e0ae 100644 --- a/tests/cli-ready.test.ts +++ b/tests/cli-ready.test.ts @@ -636,12 +636,10 @@ describe("runReady --wait deadline correctness", () => { // A bounded source-level assertion reading ONLY src/cli/index.ts. It verifies // that the SAME identifier `readinessGate` is (1) created in handleStart via // createReadinessGate(), (2) passed to startServer in the retry path, and -// (3) threaded into the startup sync via syncCodexOnStartIfEnabled(..., gate), -// in that order. The gate-drive itself lives in syncCodexOnStartIfEnabled (the -// modern dev startup path, which respects the Codex integration toggle and the -// #1046 write-tracking contract); this catches a regression where the gate is -// wired to only one of the two call sites. It complements the executable -// runStartupReadinessSync outcome tests in tests/proxy-liveness.test.ts. +// (3) passed to reconcileClientStartupBeforeReady before that helper gives a +// deferred gate to syncCodexOnStartIfEnabled. The successful transition is held +// until the Claude roster fence settles; this source guard complements the +// executable delayed-roster test in tests/claude-agent-startup-sync.test.ts. describe("handleStart readinessGate wiring (source-level)", () => { const cliSource = readFileSync(join(import.meta.dir, "../src/cli/index.ts"), "utf8"); @@ -652,17 +650,24 @@ describe("handleStart readinessGate wiring (source-level)", () => { const startMatch = cliSource.match(/startServer\s*\(\s*port\s*,\s*\{\s*[^}]*readinessGate[^}]*\}\s*\)/); expect(startMatch, "startServer must be called with readinessGate among its deps in the retry path").not.toBeNull(); + const reconcileMatch = cliSource.match( + /reconcileClientStartupBeforeReady\s*\(\s*readinessGate\s*,/, + ); + expect(reconcileMatch, "startup reconciliation must receive the server readinessGate").not.toBeNull(); + const syncMatch = cliSource.match( - /syncCodexOnStartIfEnabled\s*\(\s*port\s*,\s*config\s*,\s*undefined\s*,\s*readinessGate\s*\)/, + /gate\s*=>\s*syncCodexOnStartIfEnabled\s*\(\s*port\s*,\s*config\s*,\s*undefined\s*,\s*gate\s*\)/, ); - expect(syncMatch, "syncCodexOnStartIfEnabled must receive readinessGate so the startup sync drives /readyz").not.toBeNull(); + expect(syncMatch, "Codex startup sync must receive the deferred reconciliation gate").not.toBeNull(); - // Source order must be: create → startServer → startup sync. + // Source order must be: create → startServer → reconciliation → Codex sync. const createIdx = createMatch!.index!; const startIdx = startMatch!.index!; + const reconcileIdx = reconcileMatch!.index!; const syncIdx = syncMatch!.index!; expect(createIdx).toBeLessThan(startIdx); - expect(startIdx).toBeLessThan(syncIdx); + expect(startIdx).toBeLessThan(reconcileIdx); + expect(reconcileIdx).toBeLessThan(syncIdx); }); test("the readinessGate identifier is the SAME symbol at all three call sites", () => { @@ -670,7 +675,7 @@ describe("handleStart readinessGate wiring (source-level)", () => { // call site references that identifier (no shadowing, no second local). const declarations = cliSource.match(/\breadinessGate\s*=/g); expect(declarations, "readinessGate must be assigned exactly once").toHaveLength(1); - // Three references total: one declaration + startServer + startup sync. + // Three references total: one declaration + startServer + reconciliation helper. const references = cliSource.match(/\breadinessGate\b/g); expect(references?.length ?? 0).toBeGreaterThanOrEqual(3); }); From 5df962b0d952e1143b34249eeb1e1d405f181b98 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 15:30:35 +0000 Subject: [PATCH 4/4] docs: clarify roster definition ownership --- structure/03_catalog-and-subagents.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 6e2fd53786..52b635b7f7 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -358,7 +358,7 @@ behaviors. duplicate call. - 다른 대안 대신 이 방식을 선택한 이유: it covers every supported service entrypoint without adding home-directory side effects to server-library consumers or creating a second roster format. -- 장점, 단점 및 영향: stale owned definitions converge on every daemon start, disabled integration +- 장점, 단점 및 영향: stale OpenCodex-owned definitions converge on every daemon start, disabled integration prunes them without provider discovery, and catalog failure falls back to unmarked definitions so startup remains available. A later dashboard save or `ocx claude` launch restores missing context markers after a transient failure.