diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 03019ca987d..39778e9453d 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -273,7 +273,9 @@ le CLI, l’API, et le GUI utilisent les mêmes octets. Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisation, les diagnostics et les mises à jour. -`ocx system codex-restart --yes` redémarre les serveurs d'application Codex et quitte puis relance entièrement l'application Codex Desktop, via le même module que `ocx sync --restart-codex`. Lorsque le proxy lui-même s'exécute dans l'application Codex, la commande refuse avec un message actionnable au lieu de promettre un transfert qu'elle ne peut pas mener à bien. +`ocx system codex-restart --yes` redémarre les serveurs d'application Codex via le même module que `ocx sync --restart-codex`. Elle quitte et relance l'application Codex Desktop si celle-ci a démarré avant l'écriture du catalogue de modèles actuel ou si cette requête a modifié sa liaison `model_catalog_json`, car le sélecteur de modèles est construit au lancement. Sinon l'application reste ouverte, relance son propre app-server et conserve les brouillons non envoyés et les approbations en attente. Si l'état de l'application ou du catalogue est illisible, elle effectue le redémarrage complet. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop refuse avec un message actionnable au lieu de promettre un transfert qu'il ne peut pas mener à bien. + +Une liaison `model_catalog_json` modifiée par cette requête impose également le redémarrage Desktop, même si le processus a démarré après l’écriture du fichier du catalogue. ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index fb6a70a30aa..b41651ea12a 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -353,7 +353,7 @@ lui-même s'il souhaite ajouter une étoile au dépôt. | `POST /api/system/restart` | Amorcer un redémarrage du processus qui attend l'évacuation des requêtes, sans retirer l'injection du client | Renvoie 202 ; les appels répétés signalent l'évacuation déjà en cours | | `POST /api/stop` | Arrêter le service, restaurer Codex en mode natif, retirer l'injection Grok gérée et évacuer les requêtes du proxy | 409 conflit de propriété du service; 409 `respawnable_service` lorsqu'un wrapper du Planificateur de tâches Windows pourrait relancer le proxy et que l'appelant n'est pas `ocx stop` (rien n'est modifié) ; 409 lorsque le gestionnaire installé refuse de s'arrêter ; 409 `service_state_unknown` lorsque l'état du Planificateur de tâches ne peut pas être lu (rien n'est modifié ; réparez la requête puis réessayez) | | `GET /api/system/codex-app-server` | Indiquer si les serveurs d'application Codex en cours d'exécution sont antérieurs au catalogue de modèles actuel | — | -| `POST /api/system/codex-restart` | Actualiser le catalogue, puis redémarrer les serveurs d'application Codex obsolètes et quitter puis relancer entièrement l'application Codex Desktop afin que le sélecteur de modèles se recharge. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop est refusé plutôt que transféré. | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas | +| `POST /api/system/codex-restart` | Actualiser le catalogue, puis redémarrer les serveurs d'application Codex. L'application Codex Desktop n'est quittée puis relancée entièrement si elle a démarré avant l'écriture du catalogue actuel, si cette requête change la liaison `model_catalog_json` (même si le processus est plus récent que le fichier du catalogue), ou si l'état de son sélecteur ne peut pas être déterminé, afin que le sélecteur de modèles se recharge ; une application en cours avec un sélecteur à jour reste ouverte avec `desktopApp.reason: "picker_current"`. Une application fermée renvoie `desktopApp.reason: "no_targets"` ; son `pickerState` peut être `not_running`, ou `stale` si cette requête a changé la liaison au catalogue. `desktopApp.pickerState` indique la lecture qui a décidé (`current`, `stale`, `unknown` ou `not_running`). Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop est refusé plutôt que transféré. | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas | ### Délégation de l'authentification Codex diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 9c891b8b913..2666966d8c2 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -204,7 +204,9 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ヘッドレス ランタイムの設定、起動、同期、診断、更新を管理します。 -`ocx system codex-restart --yes` は `ocx sync --restart-codex` と同じモジュールで Codex app-server を再起動し、デスクトップ アプリも完全に終了して再起動します。プロキシ自体が Codex アプリ内で動いている場合、完了できない引き渡しを約束せず、実行可能な案内とともに拒否します。 +`ocx system codex-restart --yes` は `ocx sync --restart-codex` と同じモジュールで Codex app-server を再起動します。モデル ピッカーは起動時に構築されるため、デスクトップ アプリを完全に終了して再起動するのは、現在のモデル カタログが書き込まれる前にアプリが起動していた場合、またはこのリクエストで `model_catalog_json` の参照先が変わった場合です。それ以外の場合、アプリは開いたまま自身の app-server を再起動し、未送信の下書きや保留中の承認を保持します。アプリまたはカタログの状態を読み取れない場合は完全な再起動を行います。プロキシ自体が Codex アプリ内で動いている場合、デスクトップの再起動は完了できない引き渡しを約束せず、実行可能な案内とともに拒否します。 + +このリクエストで `model_catalog_json` の参照先が変わった場合も、シェルがカタログファイルの書き込み後に起動していてもデスクトップを再起動します。 ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 3e19c5b55db..8d839481b43 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -237,9 +237,14 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 헤드리스 런타임 설정, 시작, 동기화, 진단, 업데이트를 관리합니다. `ocx system codex-restart --yes`는 `ocx sync --restart-codex`와 같은 모듈로 Codex -app-server를 재시작하고 데스크톱 앱도 완전히 종료한 뒤 다시 띄웁니다. 프록시 자체가 -Codex 앱 안에서 실행 중이면 넘길 수 없는 handoff를 약속하지 않고, 대신 실행 가능한 -안내와 함께 거절합니다. +app-server를 재시작합니다. 모델 선택기는 앱 실행 시점에 만들어지므로, 데스크톱 앱은 현재 +모델 카탈로그가 기록되기 전에 실행된 경우나 이 요청으로 `model_catalog_json` 경로가 바뀐 경우 완전히 종료한 뒤 다시 띄웁니다. 그렇지 않으면 +앱은 열린 채로 자체 app-server를 다시 띄우고, 보내지 않은 초안과 대기 중인 승인을 유지합니다. +앱이나 카탈로그 상태를 읽을 수 없으면 전체 재시작을 수행합니다. 프록시 자체가 Codex 앱 안에서 +실행 중이면 데스크톱 재시작은 넘길 수 없는 handoff를 약속하지 않고, 대신 실행 가능한 안내와 +함께 거절합니다. + +이 요청으로 `model_catalog_json` 경로가 바뀌면 셸이 카탈로그 파일 작성 후 시작되었어도 데스크톱을 재시작합니다. ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index dafc3fea2b5..a5fdc6319f7 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1023,10 +1023,15 @@ the CLI, the API, and the GUI use the same bytes. Manage headless runtime settings, startup, sync, diagnostics, and updates. -`ocx system codex-restart --yes` restarts Codex app-servers and fully quits and relaunches the -Codex desktop app, through the same module as `ocx sync --restart-codex`. When the proxy itself -is running inside the Codex app, the command refuses with an actionable message instead of -promising a handoff it cannot complete. +`ocx system codex-restart --yes` restarts Codex app-servers through the same module as +`ocx sync --restart-codex`. It quits and relaunches the Codex desktop app when the app launched before the current model catalog was written or this request changed +its `model_catalog_json` binding, because the model picker is built at +launch. Otherwise the app stays open, respawns its own app-server, and keeps unsaved drafts and +pending approvals. If the app or catalog state cannot be read, it takes the full restart. When +the proxy itself is running inside the Codex app, the desktop restart refuses with an actionable +message instead of promising a handoff it cannot complete. + +A `model_catalog_json` binding changed by this request also forces the desktop restart, even when the shell started after the catalog file was written. ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c71bea71d29..3651cadf52d 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -738,7 +738,7 @@ the 503 without reporting model capacity; provider overload errors retain their | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection; optional JSON `drainGraceMs` explicitly shortens the default 60s grace | Returns 202; repeated calls report the existing grace; 400 invalid body or grace | | `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | -| `POST /api/system/codex-restart` | Refresh the catalog, then restart stale Codex app-servers and fully quit and relaunch the Codex desktop app so the model picker reloads. When the proxy itself is running inside the Codex app, the desktop restart is refused rather than handed off. | Returns 200 with `code: partially_stopped` when a target survives | +| `POST /api/system/codex-restart` | Refresh the catalog, then restart Codex app-servers. The Codex desktop app is fully quit and relaunched when it launched before the current catalog was written, when this request changes the `model_catalog_json` binding (even if the shell is newer than the catalog file), or when its picker state cannot be determined, so the model picker reloads; a running desktop with a current picker stays open with `desktopApp.reason: "picker_current"`. A closed desktop returns `desktopApp.reason: "no_targets"`; its `pickerState` may be `not_running`, or `stale` when this request changed the catalog binding. `desktopApp.pickerState` reports the reading that decided (`current`, `stale`, `unknown` or `not_running`). When the proxy itself is running inside the Codex app, the desktop restart is refused rather than handed off. | Returns 200 with `code: partially_stopped` when a target survives | ### Codex authentication delegation diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 8ebf9402e23..5bfe1b379bb 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -256,7 +256,9 @@ env-reference, либо несекретную loopback-заглушку. Адр Управляйте headless runtime-setting'ами, startup, sync, diagnostics и update. -`ocx system codex-restart --yes` перезапускает Codex app-server'ы и полностью закрывает и заново запускает Desktop-приложение Codex тем же модулем, что и `ocx sync --restart-codex`. Если сам proxy запущен внутри приложения Codex, команда отказывается с actionable-сообщением вместо handoff, который она не может завершить. +`ocx system codex-restart --yes` перезапускает Codex app-server'ы тем же модулем, что и `ocx sync --restart-codex`. Desktop-приложение Codex полностью закрывается и запускается заново, если оно было запущено до записи текущего каталога моделей или этот запрос изменил привязку `model_catalog_json`, потому что выбор модели строится при запуске. Иначе приложение остаётся открытым, само перезапускает свой app-server и сохраняет неотправленные черновики и ожидающие подтверждения. Если состояние приложения или каталога прочитать нельзя, выполняется полный перезапуск. Если сам proxy запущен внутри приложения Codex, перезапуск Desktop отказывается с actionable-сообщением вместо handoff, который он не может завершить. + +Изменение привязки `model_catalog_json` этим запросом также вызывает перезапуск Desktop, даже если процесс оболочки запущен после записи файла каталога. ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index e328f84b62c..6a412bc69a9 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -302,10 +302,15 @@ Başsız çalışma zamanı ayarlarını, başlatmayı, senkronizasyonu, tanıla güncellemeleri yönetin. `ocx system codex-restart --yes`, `ocx sync --restart-codex` ile aynı modül -üzerinden Codex app-server'larını yeniden başlatır ve Codex masaüstü -uygulamasını tamamen kapatıp yeniden başlatır. Proxy'nin kendisi Codex -uygulamasının içinde çalışıyorsa, tamamlayamayacağı bir devri vaat etmek -yerine eyleme geçirilebilir bir iletiyle reddeder. +üzerinden Codex app-server'larını yeniden başlatır. Model seçici uygulama +açılırken oluşturulduğu için Codex masaüstü uygulamasını güncel model kataloğu yazılmadan önce başlatılmışsa veya bu istek `model_catalog_json` bağını değiştirdiyse tamamen kapatıp yeniden başlatır. Aksi +halde uygulama açık kalır, kendi app-server'ını yeniden başlatır ve +gönderilmemiş taslakları ve bekleyen onayları korur. Uygulama veya katalog +durumu okunamazsa tam yeniden başlatma yapılır. Proxy'nin kendisi Codex +uygulamasının içinde çalışıyorsa masaüstü yeniden başlatması, tamamlayamayacağı +bir devri vaat etmek yerine eyleme geçirilebilir bir iletiyle reddeder. + +Bu isteğin `model_catalog_json` bağını değiştirmesi de kabuk katalog dosyası yazıldıktan sonra başlamış olsa bile masaüstünün yeniden başlatılmasını gerektirir. ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index d2c231de196..6a465fa6ded 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -208,7 +208,9 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 管理无头运行时设置、启动、同步、诊断和更新。 -`ocx system codex-restart --yes` 通过与 `ocx sync --restart-codex` 相同的模块重启 Codex app-server,并完全退出再重新启动 Codex 桌面应用。若代理本身运行在 Codex 应用内部,该命令会给出可执行提示并拒绝,而不是承诺无法完成的移交。 +`ocx system codex-restart --yes` 通过与 `ocx sync --restart-codex` 相同的模块重启 Codex app-server。模型选择器在应用启动时构建,因此当桌面应用在当前模型目录写入之前启动,或本次请求更改了 `model_catalog_json` 绑定时,会完全退出再重新启动它。否则应用保持打开,自行重启其 app-server,并保留未发送的草稿和待处理的审批。若无法读取应用或目录状态,则执行完整重启。若代理本身运行在 Codex 应用内部,桌面重启会给出可执行提示并拒绝,而不是承诺无法完成的移交。 + +如果本次请求更改了 `model_catalog_json` 绑定,即使桌面进程启动于目录文件写入之后,也会触发桌面重启。 ```bash ocx system settings --stream-mode eager-relay diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 92bbc9fde20..27ab086c517 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -209,7 +209,9 @@ opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不 管理無頭執行階段設定、啟動、同步、診斷與更新。 -`ocx system codex-restart --yes` 透過與 `ocx sync --restart-codex` 相同的模組重啟 Codex app-server,並完全結束再重新啟動 Codex 桌面應用程式。若代理本身在 Codex 應用程式內部執行,此命令會給出可執行提示並拒絕,而不是承諾無法完成的移交。 +`ocx system codex-restart --yes` 透過與 `ocx sync --restart-codex` 相同的模組重啟 Codex app-server。模型選擇器在應用程式啟動時建立,因此當桌面應用程式於目前模型目錄寫入之前啟動,或本次請求變更了 `model_catalog_json` 綁定時,會完全結束再重新啟動它。否則應用程式保持開啟,自行重啟其 app-server,並保留未送出的草稿與待處理的核准。若無法讀取應用程式或目錄狀態,則執行完整重啟。若代理本身在 Codex 應用程式內部執行,桌面重啟會給出可執行提示並拒絕,而不是承諾無法完成的移交。 + +如果本次請求變更了 `model_catalog_json` 綁定,即使桌面程序在目錄檔案寫入後才啟動,也會觸發桌面重新啟動。 ```bash ocx system settings --stream-mode eager-relay diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 6422b47700d..f35bcf379aa 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -418,7 +418,7 @@ JSON mode: `envelope`. Usage: `ocx system codex-restart --yes [--json]` -Restart the Codex desktop app and app-servers. +Restart the Codex app-servers, and the desktop app when its model picker predates the catalog. State-changing: yes. @@ -428,13 +428,13 @@ State-changing: yes. | Flag | Value | Meaning | |---|---|---| -| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers. | +| `--yes` | boolean | Required: restarts the Codex app-servers, interrupting running turns, and fully quits and relaunches the operator's Codex desktop app when its model picker predates the current catalog or that cannot be determined, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts. | | `--json` | boolean | Emit the restart result as JSON. | JSON mode: `payload`. - `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request. -- Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request. +- Restarts the app-servers through the same module the CLI uses. The desktop app is quit and relaunched when its model picker predates the current catalog or this request changes the `model_catalog_json` binding, because the picker is built at launch; otherwise it is left running and respawns its own app-server, keeping unsaved client state. An unreadable desktop or catalog state falls back to the full restart. When the proxy itself runs inside the Codex app the desktop restart is refused, because restarting the app would kill the request. - --yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand. ### `ocx logs filter` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 4a374354b43..e80b99f6335 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -571,12 +571,12 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["system","codex-restart"], - summary: "Restart the Codex desktop app and app-servers.", + summary: "Restart the Codex app-servers, and the desktop app when its model picker predates the catalog.", routes: [{"method":"POST","path":"/api/system/codex-restart"}], - flags: [{"name":"--yes","value":"boolean","summary":"Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers."},{"name":"--json","value":"boolean","summary":"Emit the restart result as JSON."}], + flags: [{"name":"--yes","value":"boolean","summary":"Required: restarts the Codex app-servers, interrupting running turns, and fully quits and relaunches the operator's Codex desktop app when its model picker predates the current catalog or that cannot be determined, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts."},{"name":"--json","value":"boolean","summary":"Emit the restart result as JSON."}], mutates: true, json: "payload", - details: ["`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.","Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.","--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand."], + details: ["`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.","Restarts the app-servers through the same module the CLI uses. The desktop app is quit and relaunched when its model picker predates the current catalog or this request changes the `model_catalog_json` binding, because the picker is built at launch; otherwise it is left running and respawns its own app-server, keeping unsaved client state. An unreadable desktop or catalog state falls back to the full restart. When the proxy itself runs inside the Codex app the desktop restart is refused, because restarting the app would kill the request.","--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand."], usage: "ocx system codex-restart --yes [--json]", }, { diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 7b5da7b9b2d..6ed931b5147 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -270,24 +270,55 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE); printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson); } else if (sub === "codex-restart") { - // --yes required: this fully quits and relaunches the user's Codex desktop app as well as - // restarting app-servers, which can discard unsaved drafts, selections, and approval prompts. + // --yes required: this interrupts running Codex turns, and when the desktop picker predates + // the catalog it also quits and relaunches the app, which can discard unsaved client state. const args = [...rest]; const wantsJson = takeFlag(args, "--json"); const yes = takeFlag(args, "--yes"); if (!yes) throw new CliUsageError( - "system codex-restart requires --yes: this fully quits and relaunches the Codex desktop app, so unsaved composer drafts, model-picker selections, and pending approval prompts may be lost; it also restarts the app-servers", + "system codex-restart requires --yes: this restarts the Codex app-servers, interrupting running turns, and when the desktop app's model picker predates the current catalog (or that cannot be determined) it fully quits and relaunches the Codex desktop app, so unsaved composer drafts, model-picker selections, and pending approval prompts may be lost", USAGE, ); rejectArgs(args, USAGE); - printData( - await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), - wantsJson, - ["Codex desktop app and app-server restart requested. Unsaved composer drafts, model-picker selections, and pending approval prompts may be lost."], - ); + const result = await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps); + printData(result, wantsJson, codexRestartLines(result)); } else if (sub === "update") await update(rest, deps); else throw new CliUsageError(`unknown system command ${sub}`, USAGE); }); } +/** + * Operator text for a codex-restart outcome. It reports what happened to the desktop app, + * because whether unsaved client state survived is the one thing the operator needs to know. + */ +export function codexRestartLines(result: unknown): string[] { + const view = recordValue(result); + const desktop = recordValue(view?.desktopApp); + const reason = typeof desktop?.reason === "string" ? desktop.reason : undefined; + // Only a reported `stale` reading may claim the picker predated the catalog; an unreadable + // reading also takes the full restart, and an older proxy reports no reading at all. + const why = desktop?.pickerState === "stale" + ? "its model picker predated the current catalog" + : "its model picker could not be confirmed current"; + const stopped = Array.isArray(view?.stopped) ? view.stopped.length : 0; + const appServers = view?.code === "stopped" + ? `Stopped ${stopped} Codex app-server(s); their owners start replacements.` + : view?.code === "partially_stopped" + ? "Some Codex app-servers did not exit; stop them manually if Codex stays stale." + : view?.code === "enumeration_unavailable" + ? "Could not list Codex app-servers, so none were stopped." + : "No Codex app-server needed stopping."; + if (reason === "picker_current") { + return [appServers, "The Codex desktop app was left running: its model picker already shows the current catalog, so unsaved drafts and pending approvals were kept."]; + } + if (reason === "no_targets") return [appServers, "The Codex desktop app is not running."]; + if (desktop?.relaunch === "started") { + return [appServers, `The Codex desktop app was quit and relaunched because ${why}. Unsaved composer drafts, model-picker selections, and pending approval prompts may have been lost.`]; + } + if (desktop?.attempted === true) { + return [appServers, `The Codex desktop app restart did not complete (${reason ?? "no outcome reported"}). Unsaved composer drafts, model-picker selections, and pending approval prompts may have been lost.`]; + } + return [appServers, `The Codex desktop app was not restarted (${reason ?? "no outcome reported"}).`]; +} + export const SYSTEM_USAGE = USAGE; diff --git a/src/codex/app-server-restart-service.ts b/src/codex/app-server-restart-service.ts index aa1679c5550..c2e36e0a11c 100644 --- a/src/codex/app-server-restart-service.ts +++ b/src/codex/app-server-restart-service.ts @@ -34,6 +34,7 @@ import type { CodexRestartResponse, } from "../lib/codex-restart-contract"; import { getServerListenPort } from "../server/lifecycle"; +import type { DesktopPickerState } from "./desktop-app/picker-freshness"; async function defaultRestartDesktopApp(): Promise { const { restartCodexDesktopApp } = await import("./desktop-app-restart"); @@ -47,9 +48,40 @@ async function defaultRestartDesktopApp(): Promise { }; } +async function defaultDesktopPickerState(): Promise { + const { readCodexDesktopPickerState } = await import("./desktop-app/picker-freshness"); + return readCodexDesktopPickerState(); +} + +async function defaultCatalogBinding(): Promise { + const { readCodexCatalogBinding } = await import("./desktop-app/picker-freshness"); + return readCodexCatalogBinding(); +} + +async function readBinding(io: CodexRestartServiceIo): Promise { + try { + return await (io.catalogBinding ?? defaultCatalogBinding)(); + } catch { + // Distinct from null ("binds no catalog"): an unreadable binding cannot prove sameness. + return undefined; + } +} + +async function readPickerState(io: CodexRestartServiceIo): Promise { + try { + return await (io.desktopPickerState ?? defaultDesktopPickerState)(); + } catch { + return "unknown"; + } +} + export interface CodexRestartServiceIo { /** Desktop-restart seam, so a route test cannot terminate the developer's own Codex. */ restartDesktopApp?: () => Promise; + /** Whether the desktop picker predates the catalog; decides if the shell must restart. */ + desktopPickerState?: () => Promise; + /** The catalog config.toml binds, read before and after the sync to catch a rebinding. */ + catalogBinding?: () => Promise; /** Process-layer seam, forwarded to every app-server-processes call. */ processIo?: CodexAppServerProcessIo; /** Catalog refresh seam. Resolves to whether a catalog or cache write happened. */ @@ -133,6 +165,7 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise ({ desktopApp: desktop, diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index e6d509ff004..68a6f27858d 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -206,6 +206,27 @@ export function listCodexDesktopAppPids(io: DesktopAppRestartIo = {}): number[] return processes === null ? null : processes.map(entry => entry.pid); } +/** + * Pids of the running desktop-app ROOT shells, or null when discovery or the probe failed. + * + * Read-only. The shell is the process whose renderer built the model picker, so its start + * time is what tells `performCodexRestart` whether the picker predates the catalog (#4761). + * `[]` means the probe ran and the app is not running. + */ +export function listCodexDesktopShellPids(io: DesktopAppRestartIo = {}): number[] | null { + const platform = io.platform ?? process.platform; + const selected = ADAPTERS[platform]; + const adapter = io.adapter ?? selected?.adapter; + if (!adapter) return null; + const exec = io.execFile ?? selected?.exec; + if (!exec) return null; + const install = adapter.discover(exec); + if (!install) return null; + const processes = adapter.listProcesses(exec, install); + if (processes === null) return null; + return rootShells(processes, install, adapter).map(entry => entry.pid); +} + export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult { const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason, diff --git a/src/codex/desktop-app/picker-freshness.ts b/src/codex/desktop-app/picker-freshness.ts new file mode 100644 index 00000000000..6e16483db66 --- /dev/null +++ b/src/codex/desktop-app/picker-freshness.ts @@ -0,0 +1,96 @@ +/** + * Whether the Codex desktop app's model picker already shows the current catalog (#4761). + * + * `ocx system codex-restart` used to quit and relaunch the desktop shell every time, which + * discards everything that lives only in the client: composer drafts, an unsent picker + * selection, a pending approval prompt. Upstream's own restart (`codex app-server daemon + * restart`) restarts only the app-server and lets clients reconnect, and the desktop app + * already respawns its own app-server child when it exits. + * + * The one thing an app-server restart cannot refresh is the picker: the renderer builds + * the roster once when the shell launches, and `model_catalog_json` is startup-only. So + * the shell has to restart only when it launched before the catalog it should show was + * written. The catalog writer skips byte-identical rewrites to keep this mtime honest + * (`src/codex/catalog/retained-sync.ts`), which is what makes the comparison usable. + * + * The mtime only means something for the catalog `config.toml` actually binds. With no + * `model_catalog_json` the shell shows Codex's own roster, which no local file dates, so + * an unbound config is `unknown`. `config.toml`'s own mtime is no substitute: injection + * rewrites it on every sync even when the bytes are identical. A binding changed by THIS + * request's sync is caught by the caller comparing it across the sync; one changed by an + * earlier sync over a byte-identical catalog file is not observable here. + * + * Anything we cannot read answers `unknown`, and the caller falls back to the full + * desktop restart: a stale picker the operator asked us to fix is the failure this + * command exists to prevent, so missing evidence never skips it. + */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { activeCodexConfigPath, resolveActiveCodexConfigPath } from "../catalog/parsing"; +import { readRootTomlString } from "../paths"; +import { readProcessStartMsBatch } from "../app-server-processes"; +import { listCodexDesktopShellPids, type DesktopAppRestartIo } from "../desktop-app-restart"; +import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; +import type { CodexDesktopPickerState } from "../../lib/codex-restart-contract"; + +export type DesktopPickerState = CodexDesktopPickerState; + +/** + * Pure decision. `shellStartsMs === null` means the shells could not be listed; a null + * entry means one shell's start time was unreadable. + * + * Strictly later, never equal: macOS `ps lstart` has one-second granularity, so a shell + * that started in the same second as the catalog write may predate it. + */ +export function classifyDesktopPicker( + shellStartsMs: readonly (number | null)[] | null, + catalogMtimeMs: number | null, +): DesktopPickerState { + if (shellStartsMs === null) return "unknown"; + if (shellStartsMs.length === 0) return "not_running"; + if (catalogMtimeMs === null) return "unknown"; + if (shellStartsMs.some(start => start === null)) return "unknown"; + return shellStartsMs.every(start => start! > catalogMtimeMs) ? "current" : "stale"; +} + +export interface DesktopPickerIo extends DesktopAppRestartIo { + readStartMs?: (pids: readonly number[]) => Map; + /** The catalog `config.toml` binds, or null when it binds none or cannot be read. */ + catalogBinding?: () => string | null; + catalogMtimeMs?: (path: string) => number | null; +} + +/** The resolved `model_catalog_json` path, or null when config.toml binds no catalog. */ +export function readCodexCatalogBinding(): string | null { + try { + const configPath = activeCodexConfigPath(); + if (!existsSync(configPath)) return null; + const path = readRootTomlString(readFileSync(configPath, "utf-8"), "model_catalog_json"); + return path ? resolveActiveCodexConfigPath(path) : null; + } catch { + return null; + } +} + +function defaultCatalogMtimeMs(path: string): number | null { + try { + return statSync(path).mtimeMs; + } catch { + return null; + } +} + +export function readCodexDesktopPickerState(io: DesktopPickerIo = {}): DesktopPickerState { + // An armed test process without an injected executor answers `unknown`, which keeps the + // caller on the desktop-restart seam the test injected. Reading is harmless, but whether + // the developer's own Codex is open must not decide which branch a test takes. + if (!io.execFile && isTestHomeGuardArmed()) return "unknown"; + const platform = io.platform ?? process.platform; + const pids = listCodexDesktopShellPids(io); + if (pids === null || pids.length === 0) return classifyDesktopPicker(pids, null); + const starts = (io.readStartMs ?? (list => readProcessStartMsBatch(list, platform)))(pids); + const binding = (io.catalogBinding ?? readCodexCatalogBinding)(); + return classifyDesktopPicker( + pids.map(pid => starts.get(pid) ?? null), + binding === null ? null : (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(binding), + ); +} diff --git a/src/lib/codex-restart-contract.ts b/src/lib/codex-restart-contract.ts index e0dce042c46..ba468a066ba 100644 --- a/src/lib/codex-restart-contract.ts +++ b/src/lib/codex-restart-contract.ts @@ -36,6 +36,10 @@ export interface CodexAppServerStateResponse { * The desktop half of a restart. Scalar-only for the same reason as the rest of this * contract: pid lists and a closed-vocabulary reason, never a command line or an OS * error message, both of which routinely embed a path or a username. + * + * `reason: "picker_current"` means the shell was deliberately left running because it + * launched after the current catalog was written, so only the app-servers restarted and + * the operator's unsaved client state was kept (#4761). */ export interface CodexDesktopRestartSummary { attempted: boolean; @@ -43,8 +47,17 @@ export interface CodexDesktopRestartSummary { surviving: number[]; relaunch: "started" | "skipped"; reason?: string; + /** + * Why the desktop was or was not restarted: `stale` (the shell predates the bound + * catalog, or this request rebound it) and `unknown` (the evidence was unreadable) both + * take the full restart. Absent on a proxy older than #4761. + */ + pickerState?: CodexDesktopPickerState; } +export type CodexDesktopPickerState = "current" | "stale" | "unknown" | "not_running"; +const DESKTOP_PICKER_STATES: readonly string[] = ["current", "stale", "unknown", "not_running"]; + /** POST response. All four arrays are pid lists — never command lines. */ export interface CodexRestartResponse { success: boolean; @@ -130,6 +143,8 @@ export function isCodexRestartResponse(value: unknown): value is CodexRestartRes if (!isPidList(d.stopped) || !isPidList(d.surviving)) return false; if (d.relaunch !== "started" && d.relaunch !== "skipped") return false; if (d.reason !== undefined && typeof d.reason !== "string") return false; + if (d.pickerState !== undefined + && (typeof d.pickerState !== "string" || !DESKTOP_PICKER_STATES.includes(d.pickerState))) return false; // A started relaunch cannot have left anything behind: the ladder refuses to // relaunch beside a survivor precisely so a second shell never appears. if (d.relaunch === "started" && (d.surviving as number[]).length > 0) return false; diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 886205dd02c..06772631f73 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -2,10 +2,23 @@ ## Explicit restart -`ocx system codex-restart` requests a full Codex app and app-server restart through -the management endpoint. `src/cli/capabilities.ts` and `src/cli/system-command.ts` -warn that unsaved drafts, picker selections and pending approvals may be lost. +`ocx system codex-restart` requests a Codex app-server restart through the management +endpoint. `src/codex/app-server-restart-service.ts` also quits and relaunches the desktop +app only when `src/codex/desktop-app/picker-freshness.ts` reports that a root shell started +at or before the mtime of the catalog `config.toml` binds through `model_catalog_json`, +because the picker is built at shell launch and an app-server restart cannot refresh it. +A shell that started strictly later is left running (`desktopApp.reason: "picker_current"`), +so its unsaved drafts, picker selections and pending approvals survive while it respawns its +own app-server. An unbound config, an unreadable shell list, start time or catalog mtime, or a +binding the request's own sync changed all take the full restart; `desktopApp.pickerState` +reports which reading decided. `config.toml`'s own mtime is not evidence, because injection +rewrites identical bytes. A binding changed by an earlier sync over a byte-identical catalog +is not observable. +`src/cli/capabilities.ts` and `src/cli/system-command.ts` warn that unsaved client state +may be lost and report which outcome happened. The unconfirmed path sends no restart request; JSON output preserves refused outcomes. +`tests/codex-integration/codex-app-server-restart-service.test.ts` and +`tests/clients/desktop-app-restart.test.ts` cover the decision. Under the test preload's `OCX_TEST_HOME_GUARD=1`, `src/codex/desktop-app-restart.ts` skips real restart without an injected executor. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index e4ddc352bfc..1677e93d952 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -13,7 +13,7 @@ import { handleProviderRuntimeCommand } from "../../src/cli/provider-runtime"; import { providerQuotaLine } from "../../src/cli/account-extended"; import { formatAccountTable } from "../../src/cli/account"; import { handleConnectCommand } from "../../src/cli/connect"; -import { handleSystemCommand } from "../../src/cli/system-command"; +import { codexRestartLines, handleSystemCommand } from "../../src/cli/system-command"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -29,6 +29,8 @@ describe("ocx system codex-restart confirmation", () => { expect(requests).toHaveLength(0); const warning = errors.mock.calls.flat().join(" "); expect(warning).toContain("requires --yes"); + expect(warning).toContain("restarts the Codex app-servers"); + expect(warning).toContain("model picker predates the current catalog"); expect(warning).toContain("fully quits and relaunches the Codex desktop app"); expect(warning).toContain("unsaved composer drafts"); expect(warning).toContain("model-picker selections"); @@ -49,15 +51,31 @@ describe("ocx system codex-restart confirmation", () => { const text = output.mock.calls.flat().join("\n"); if (wantsJson) expect(JSON.parse(text)).toEqual(result); else { - expect(text).toContain("Codex desktop app"); - expect(text).toContain("restart requested."); - expect(text).toContain("Unsaved composer drafts"); - expect(text).toContain("model-picker selections"); - expect(text).toContain("pending approval prompts"); - expect(text).not.toContain("restarted"); + expect(text).toContain("No Codex app-server needed stopping."); + expect(text).toContain("The Codex desktop app was not restarted (self_ancestry)."); + expect(text).not.toContain("relaunched"); } } finally { output.mockRestore(); } }); + + test.each([ + [{ code: "stopped", stopped: [1, 2], desktopApp: { attempted: false, relaunch: "skipped", reason: "picker_current" } }, + ["Stopped 2 Codex app-server(s)", "left running", "unsaved drafts and pending approvals were kept"], ["may have been lost"]], + [{ code: "stopped", stopped: [1], desktopApp: { attempted: true, relaunch: "started", stopped: [9], surviving: [], pickerState: "stale" } }, + ["quit and relaunched", "predated the current catalog", "may have been lost"], ["left running", "could not be confirmed"]], + [{ code: "stopped", stopped: [1], desktopApp: { attempted: true, relaunch: "started", stopped: [9], surviving: [], pickerState: "unknown" } }, + ["quit and relaunched", "could not be confirmed current", "may have been lost"], ["predated"]], + [{ code: "stopped", stopped: [1], desktopApp: { attempted: true, relaunch: "started", stopped: [9], surviving: [] } }, + ["quit and relaunched", "could not be confirmed current"], ["predated"]], + [{ code: "nothing_running", stopped: [], desktopApp: { attempted: true, relaunch: "skipped", reason: "relaunch_failed" } }, + ["restart did not complete (relaunch_failed)", "may have been lost"], ["left running"]], + [{ code: "enumeration_unavailable", stopped: [], desktopApp: { attempted: false, relaunch: "skipped", reason: "no_targets" } }, + ["Could not list Codex app-servers", "is not running"], ["may have been lost"]], + ])("reports whether unsaved desktop state survived (%#)", (result, present, absent) => { + const text = codexRestartLines(result).join("\n"); + for (const fragment of present) expect(text).toContain(fragment); + for (const fragment of absent) expect(text).not.toContain(fragment); + }); }); describe("ocx system settings desktop switches", () => { diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 8bbfcec3503..f0fe963117f 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { listCodexDesktopShellPids, restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { classifyDesktopPicker, readCodexCatalogBinding, readCodexDesktopPickerState } from "../../src/codex/desktop-app/picker-freshness"; +import type { DesktopAppAdapter, DesktopProcess } from "../../src/codex/desktop-app/types"; import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; import { handleDesktopAppRestart } from "../../src/cli/restart-scope"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** * #2292: the desktop restart is the one path in the CLI that terminates a user's @@ -526,3 +529,91 @@ describe("#2557 a failed probe is not an absent app", () => { expect(script).not.toContain("SilentlyContinue' $root"); }); }); + +describe("desktop picker freshness inputs (#4761)", () => { + const install = { id: "fixture", root: "/Applications/ChatGPT.app", relaunch: "fixture" }; + const shellExe = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; + const helperExe = "/Applications/ChatGPT.app/Contents/Frameworks/Helper"; + function fixtureAdapter(listed: DesktopProcess[] | null, discovered = true): DesktopAppAdapter { + const refuse = () => { throw new Error("a read-only probe must not act"); }; + return { + discover: () => discovered ? install : null, + listProcesses: () => listed, + isShell: entry => entry.executable === shellExe, + ancestryPids: () => [], + requestQuit: refuse, + forceStop: refuse, + captureRelaunchContext: refuse, + relaunch: refuse, + }; + } + const exec = () => { throw new Error("the fixture adapter never execs"); }; + const listed: DesktopProcess[] = [ + { pid: 10, parentPid: 1, createdAt: "a", executable: shellExe }, + { pid: 11, parentPid: 10, createdAt: "b", executable: helperExe }, + { pid: 12, parentPid: 10, createdAt: "c", executable: shellExe }, + ]; + + test("lists only root shells, and null when discovery or the probe fails", () => { + expect(listCodexDesktopShellPids({ adapter: fixtureAdapter(listed), execFile: exec })).toEqual([10]); + expect(listCodexDesktopShellPids({ adapter: fixtureAdapter([]), execFile: exec })).toEqual([]); + expect(listCodexDesktopShellPids({ adapter: fixtureAdapter(null), execFile: exec })).toBeNull(); + expect(listCodexDesktopShellPids({ adapter: fixtureAdapter(listed, false), execFile: exec })).toBeNull(); + }); + + test("classifies a picker as current only when every shell started strictly after the catalog", () => { + expect(classifyDesktopPicker([2_000, 3_000], 1_000)).toBe("current"); + expect(classifyDesktopPicker([2_000, 1_000], 1_000)).toBe("stale"); + expect(classifyDesktopPicker([500], 1_000)).toBe("stale"); + expect(classifyDesktopPicker([], 1_000)).toBe("not_running"); + expect(classifyDesktopPicker([], null)).toBe("not_running"); + expect(classifyDesktopPicker(null, 1_000)).toBe("unknown"); + expect(classifyDesktopPicker([2_000, null], 1_000)).toBe("unknown"); + expect(classifyDesktopPicker([2_000], null)).toBe("unknown"); + }); + + test("reads shell start times and the bound catalog's mtime through its seams", () => { + const read = (binding: string | null, catalogMtimeMs: number | null, starts: Map) => readCodexDesktopPickerState({ + adapter: fixtureAdapter(listed), execFile: exec, + readStartMs: pids => { expect(pids).toEqual([10]); return starts; }, + catalogBinding: () => binding, + catalogMtimeMs: path => { expect(path).toBe("/codex/opencodex-catalog.json"); return catalogMtimeMs; }, + }); + const bound = "/codex/opencodex-catalog.json"; + expect(read(bound, 1_000, new Map([[10, 5_000]]))).toBe("current"); + expect(read(bound, 9_000, new Map([[10, 5_000]]))).toBe("stale"); + expect(read(bound, 1_000, new Map([[10, null]]))).toBe("unknown"); + expect(read(bound, null, new Map([[10, 5_000]]))).toBe("unknown"); + // No model_catalog_json: the shell shows Codex's own roster, which no local file dates. + expect(read(null, 1_000, new Map([[10, 5_000]]))).toBe("unknown"); + }); + + test("the catalog binding is the configured model_catalog_json, never the default-path fallback", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-picker-binding-")); + const previous = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + try { + expect(readCodexCatalogBinding()).toBeNull(); + writeFileSync(join(home, "config.toml"), 'model = "gpt-5.5"\n'); + writeFileSync(join(home, "opencodex-catalog.json"), "{}\n"); + expect(readCodexCatalogBinding()).toBeNull(); + writeFileSync(join(home, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n'); + expect(readCodexCatalogBinding()).toBe(join(realpathSync.native(home), "opencodex-catalog.json")); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + removeTreeWithRetry(home); + } + }); + + test("an armed test process without an injected executor never probes the real app", () => { + const previous = process.env.OCX_TEST_HOME_GUARD; + process.env.OCX_TEST_HOME_GUARD = "1"; + try { + expect(readCodexDesktopPickerState({ adapter: fixtureAdapter(listed) })).toBe("unknown"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_HOME_GUARD; + else process.env.OCX_TEST_HOME_GUARD = previous; + } + }); +}); diff --git a/tests/codex-integration/codex-app-server-restart-service.test.ts b/tests/codex-integration/codex-app-server-restart-service.test.ts index e0e9bb23946..9419a80645a 100644 --- a/tests/codex-integration/codex-app-server-restart-service.test.ts +++ b/tests/codex-integration/codex-app-server-restart-service.test.ts @@ -612,4 +612,97 @@ describe("last-moment identity gate (through the real restart helper)", () => { expect(serialized).not.toContain("CodexAppServerIdentityChanged"); expect(result.failed).toEqual([4242]); }); -}); \ No newline at end of file +}); +describe("desktop picker freshness decides the desktop restart (#4761)", () => { + const staleServers = { + collectState: () => ({ state: "stale" as const, processes: [{ pid: 100, startedAtMs: 1 }], catalogMtimeMs: 10 }), + listProcesses: () => [proc(100)], + readStartMs: () => new Map([[100, 1]]), + restart: () => ({ requested: [100], stopped: [100], surviving: [], failed: [] }), + catalogBinding: async () => "/codex/opencodex-catalog.json", + }; + const relaunched = { attempted: true, stopped: [7], surviving: [], relaunch: "started" as const }; + + test("a current picker keeps the desktop app open and still restarts every app-server", async () => { + let desktopRestarts = 0; + const result = await performCodexRestart(baseIo({ + ...staleServers, + desktopPickerState: async () => "current", + restartDesktopApp: async () => { desktopRestarts += 1; return relaunched; }, + })); + expect(desktopRestarts).toBe(0); + expect(result.desktopApp).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "picker_current", pickerState: "current", + }); + expect(result.code).toBe("stopped"); + expect(result.stopped).toEqual([100]); + expect(isCodexRestartResponse(result)).toBe(true); + }); + + test("a closed desktop app is reported as not running without a restart attempt", async () => { + let desktopRestarts = 0; + const result = await performCodexRestart(baseIo({ + catalogBinding: staleServers.catalogBinding, + desktopPickerState: async () => "not_running", + restartDesktopApp: async () => { desktopRestarts += 1; return relaunched; }, + })); + expect(desktopRestarts).toBe(0); + expect(result.desktopApp).toMatchObject({ reason: "no_targets", pickerState: "not_running" }); + }); + + for (const [label, state, reported] of [ + ["a picker older than the catalog", async () => "stale" as const, "stale"], + ["an unreadable picker state", async () => "unknown" as const, "unknown"], + ["a picker probe that throws", async (): Promise<"stale"> => { throw new Error("probe failed"); }, "unknown"], + ] as const) { + test(`${label} falls back to the full desktop restart`, async () => { + let desktopRestarts = 0; + const result = await performCodexRestart(baseIo({ + ...staleServers, + desktopPickerState: state, + restartDesktopApp: async () => { desktopRestarts += 1; return relaunched; }, + })); + expect(desktopRestarts).toBe(1); + expect(result.desktopApp).toEqual({ ...relaunched, pickerState: reported }); + expect(result.stopped).toEqual([100]); + expect(isCodexRestartResponse(result)).toBe(true); + }); + } + + for (const [label, bindings] of [ + ["a sync that installs the catalog binding", [null, "/codex/opencodex-catalog.json"]], + ["a sync that moves the catalog binding", ["/codex/old.json", "/codex/opencodex-catalog.json"]], + ["an unreadable binding", ["/codex/opencodex-catalog.json", new Error("unreadable")]], + ] as const) { + test(`${label} restarts the desktop without trusting the mtime probe`, async () => { + // The catalog bytes did not change, so its mtime still predates the shell; only the + // binding the shell loaded at launch did. + let probes = 0; + let reads = 0; + let desktopRestarts = 0; + const result = await performCodexRestart(baseIo({ + ...staleServers, + catalogBinding: async () => { + const value = bindings[Math.min(reads++, 1)]; + if (value instanceof Error) throw value; + return value; + }, + desktopPickerState: async () => { probes += 1; return "current"; }, + restartDesktopApp: async () => { desktopRestarts += 1; return relaunched; }, + })); + expect(probes).toBe(0); + expect(desktopRestarts).toBe(1); + expect(result.desktopApp?.pickerState).toBe("stale"); + }); + } + + test("the binding is read around the sync and the picker is judged after it", async () => { + const order: string[] = []; + await performCodexRestart(baseIo({ + catalogBinding: async () => { order.push("binding"); return null; }, + syncCatalog: async () => { order.push("sync"); return true; }, + desktopPickerState: async () => { order.push("picker"); return "current"; }, + })); + expect(order).toEqual(["binding", "sync", "binding", "picker"]); + }); +});