diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f06b217022..3b71f3fbe2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -267,7 +267,7 @@ export const de: Record = { "dash.codexRestarting": "Wird gestoppt…", "dash.codexRestartConfirm": "Codex-App-Server stoppen, damit sie die Modellliste neu laden? Ein laufender Codex-Vorgang wird unterbrochen, und Codex startet nicht von selbst neu — öffne es danach erneut.", "dash.codexRestartDone": "{count} Codex-App-Server gestoppt. Öffne Codex erneut, um die aktuelle Modellliste zu laden.", - "dash.codexRestartNothing": "Es läuft kein Codex-App-Server. Der nächste Start liest die aktuelle Modellliste.", + "dash.codexRestartNothing": "Kein veralteter Codex-App-Server musste neu gestartet werden. Es wurde nichts beendet.", "dash.codexRestartUnknown": "Prozesse konnten nicht aufgelistet werden, daher wurde nichts gestoppt.", "dash.codexRestartPartial": "{count} App-Server wurden nicht beendet. Beende sie manuell, falls die Modellliste veraltet bleibt.", "dash.codexRestartFailed": "Codex-Modelle konnten nicht neu geladen werden (HTTP {status}).", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d1e5389165..95248e9f3e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -279,7 +279,7 @@ export const en = { "dash.codexRestarting": "Stopping…", "dash.codexRestartConfirm": "Stop Codex app-servers so they reload the model list? Any Codex turn in progress is interrupted, and Codex does not relaunch on its own — reopen it afterwards.", "dash.codexRestartDone": "Stopped {count} Codex app-server(s). Reopen Codex to load the current model list.", - "dash.codexRestartNothing": "No Codex app-server is running. The next launch reads the current model list.", + "dash.codexRestartNothing": "No stale Codex app-server required a restart. Nothing was stopped.", "dash.codexRestartUnknown": "Could not list processes, so nothing was stopped.", "dash.codexRestartPartial": "{count} app-server(s) did not exit. Stop them manually if the model list stays stale.", "dash.codexRestartFailed": "Failed to reload Codex models (HTTP {status}).", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a6a34e5954..e94d5c23f1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -269,7 +269,7 @@ export const fr: Record = { "dash.codexRestarting": "Arrêt…", "dash.codexRestartConfirm": "Arrêter les serveurs d’application Codex afin qu’ils rechargent la liste des modèles ? Tout tour Codex en cours sera interrompu et Codex ne redémarrera pas automatiquement — rouvrez-le ensuite.", "dash.codexRestartDone": "{count} serveur(s) d’application Codex arrêté(s). Rouvrez Codex pour charger la liste actuelle des modèles.", - "dash.codexRestartNothing": "Aucun serveur d’application Codex n’est en cours d’exécution. Le prochain lancement lira la liste actuelle des modèles.", + "dash.codexRestartNothing": "Aucun serveur d’application Codex obsolète ne nécessitait de redémarrage. Aucun processus n’a été arrêté.", "dash.codexRestartUnknown": "Impossible de répertorier les processus ; aucun n’a donc été arrêté.", "dash.codexRestartPartial": "{count} serveur(s) d’application ne se sont pas arrêtés. Arrêtez-les manuellement si la liste des modèles reste obsolète.", "dash.codexRestartFailed": "Échec du rechargement des modèles Codex (HTTP {status}).", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 23889a0742..86964ed9a6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -276,7 +276,7 @@ export const ja: Record = { "dash.codexRestarting": "停止中…", "dash.codexRestartConfirm": "Codex app-server を停止してモデル一覧を読み直させますか? 進行中の Codex の処理は中断され、Codex は自動では再起動しないので後で開き直してください。", "dash.codexRestartDone": "Codex app-server を {count} 個停止しました。Codex を開き直すと最新のモデル一覧が読み込まれます。", - "dash.codexRestartNothing": "実行中の Codex app-server はありません。次回起動時に最新のモデル一覧を読み込みます。", + "dash.codexRestartNothing": "再起動が必要な古い Codex app-server はありません。何も停止していません。", "dash.codexRestartUnknown": "プロセスを列挙できなかったため、何も停止しませんでした。", "dash.codexRestartPartial": "app-server が {count} 個終了しませんでした。モデル一覧が古いままなら手動で停止してください。", "dash.codexRestartFailed": "Codex のモデル一覧を再読み込みできませんでした (HTTP {status})。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index dc7fb87462..1c7865582a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -271,7 +271,7 @@ export const ko: Record = { "dash.codexRestarting": "종료하는 중…", "dash.codexRestartConfirm": "Codex app-server를 종료해 모델 목록을 다시 읽게 할까요? 진행 중인 Codex 작업이 끊기고, Codex가 저절로 다시 뜨지는 않으니 끝나면 직접 열어야 합니다.", "dash.codexRestartDone": "Codex app-server {count}개를 종료했습니다. Codex를 다시 열면 최신 모델 목록이 보입니다.", - "dash.codexRestartNothing": "실행 중인 Codex app-server가 없습니다. 다음 실행 때 최신 목록을 읽습니다.", + "dash.codexRestartNothing": "재시작이 필요한 오래된 Codex app-server가 없습니다. 아무 프로세스도 종료하지 않았습니다.", "dash.codexRestartUnknown": "프로세스 목록을 읽지 못해 아무것도 종료하지 않았습니다.", "dash.codexRestartPartial": "app-server {count}개가 종료되지 않았습니다. 모델 목록이 최신 상태로 바뀌지 않으면 직접 종료하세요.", "dash.codexRestartFailed": "Codex 모델 목록을 새로고침하지 못했습니다 (HTTP {status}).", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2368ee0257..aa20548931 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -276,7 +276,7 @@ export const ru: Record = { "dash.codexRestarting": "Останавливается…", "dash.codexRestartConfirm": "Остановить app-server'ы Codex, чтобы они перечитали список моделей? Текущий ход Codex будет прерван, и Codex не перезапустится сам — откройте его заново.", "dash.codexRestartDone": "Остановлено app-server Codex: {count}. Откройте Codex заново, чтобы загрузить актуальный список моделей.", - "dash.codexRestartNothing": "Ни один app-server Codex не запущен. При следующем запуске будет прочитан актуальный список моделей.", + "dash.codexRestartNothing": "Устаревших app-server Codex, требующих перезапуска, нет. Ничего не было остановлено.", "dash.codexRestartUnknown": "Не удалось получить список процессов, поэтому ничего не остановлено.", "dash.codexRestartPartial": "app-server не завершились: {count}. Остановите их вручную, если список моделей остаётся устаревшим.", "dash.codexRestartFailed": "Не удалось обновить список моделей Codex (HTTP {status}).", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7644e50c89..02c1569171 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -277,7 +277,7 @@ export const tr: Record = { "dash.codexRestarting": "Durduruluyor…", "dash.codexRestartConfirm": "Model listesini yeniden okumaları için Codex app-server'ları durdurulsun mu? Süren bir Codex işlemi kesilir ve Codex kendiliğinden yeniden başlamaz — sonrasında yeniden açın.", "dash.codexRestartDone": "{count} Codex app-server durduruldu. Güncel model listesi için Codex'i yeniden açın.", - "dash.codexRestartNothing": "Çalışan Codex app-server yok. Sonraki açılışta güncel model listesi okunur.", + "dash.codexRestartNothing": "Yeniden başlatılması gereken eski bir Codex app-server yok. Hiçbir işlem durdurulmadı.", "dash.codexRestartUnknown": "Süreçler listelenemedi, bu yüzden hiçbir şey durdurulmadı.", "dash.codexRestartPartial": "{count} app-server kapanmadı. Model listesi eski kalırsa bunları elle durdurun.", "dash.codexRestartFailed": "Codex model listesi yenilenemedi (HTTP {status}).", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4f5cb8331d..61a974ea13 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -170,7 +170,7 @@ export const zhTW: Record = { "dash.codexRestarting": "正在停止…", "dash.codexRestartConfirm": "停止 Codex app-server 以重新讀取模型清單?進行中的 Codex 工作會中斷,且 Codex 不會自動重啟,請稍後自行重新開啟。", "dash.codexRestartDone": "已停止 {count} 個 Codex app-server。重新開啟 Codex 即可載入最新的模型清單。", - "dash.codexRestartNothing": "沒有執行中的 Codex app-server。下次啟動會讀取最新的模型清單。", + "dash.codexRestartNothing": "沒有需要重新啟動的舊版 Codex app-server。未停止任何程序。", "dash.codexRestartUnknown": "無法列舉行程,因此沒有停止任何行程。", "dash.codexRestartPartial": "有 {count} 個 app-server 未結束。若模型清單仍然過舊,請手動停止。", "dash.codexRestartFailed": "無法重新載入 Codex 模型清單 (HTTP {status})。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3589644f4a..d4f8ac45a8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -271,7 +271,7 @@ export const zh: Record = { "dash.codexRestarting": "正在停止…", "dash.codexRestartConfirm": "停止 Codex app-server 以便重新读取模型列表?进行中的 Codex 任务会被中断,且 Codex 不会自动重启,请稍后自行重新打开。", "dash.codexRestartDone": "已停止 {count} 个 Codex app-server。重新打开 Codex 即可加载最新模型列表。", - "dash.codexRestartNothing": "没有正在运行的 Codex app-server。下次启动会读取最新模型列表。", + "dash.codexRestartNothing": "没有需要重启的旧 Codex app-server。未停止任何进程。", "dash.codexRestartUnknown": "无法枚举进程,因此没有停止任何进程。", "dash.codexRestartPartial": "有 {count} 个 app-server 未退出。若模型列表仍然过旧,请手动停止。", "dash.codexRestartFailed": "无法重新加载 Codex 模型列表 (HTTP {status})。", diff --git a/gui/src/use-codex-restart.ts b/gui/src/use-codex-restart.ts index ba6d6c7bdb..ad3fd138fc 100644 --- a/gui/src/use-codex-restart.ts +++ b/gui/src/use-codex-restart.ts @@ -8,9 +8,10 @@ export interface CodexRestartController { /** * Resolves to the response code, or null when the user declined the confirm or * the call failed. Callers that track staleness must treat BOTH `stopped` and - * `nothing_running` as "no stale app-server remains" — the second is the race - * where the target exited on its own, and refreshing on only the first would - * leave a staleness banner up after a successful outcome. + * `nothing_running` as "no stale app-server remains" — that includes no + * process, a target that exited on its own, and app-servers already newer than + * the catalog. Refreshing on only `stopped` would leave a staleness banner up + * after any of those successful outcomes. */ restart: () => Promise; } @@ -84,7 +85,8 @@ export function useCodexRestart( } else if (result.code === "enumeration_unavailable") { alert(t("dash.codexRestartUnknown")); } else { - alert(t("dash.codexRestartPartial", { count: String(result.surviving.length) })); + const unresolvedCount = new Set([...result.surviving, ...result.failed]).size; + alert(t("dash.codexRestartPartial", { count: String(unresolvedCount) })); } // Only while mounted: a settled callback typically starts a refresh fetch, @@ -95,4 +97,3 @@ export function useCodexRestart( return { restarting, restart }; } - diff --git a/gui/tests/codex-restart.test.ts b/gui/tests/codex-restart.test.ts index b1d4e9cf28..1aefaa224b 100644 --- a/gui/tests/codex-restart.test.ts +++ b/gui/tests/codex-restart.test.ts @@ -147,10 +147,10 @@ describe("requestCodexRestart", () => { test("passes each response code through with a self-consistent body", async () => { const cases = [ - { code: "stopped", success: true, stopped: [4242], surviving: [], failed: [] }, - { code: "nothing_running", success: true, stopped: [], surviving: [], failed: [] }, - { code: "enumeration_unavailable", success: true, stopped: [], surviving: [], failed: [] }, - { code: "partially_stopped", success: false, stopped: [], surviving: [4242], failed: [] }, + { stateBefore: "stale", code: "stopped", success: true, requested: [4242], stopped: [4242], surviving: [], failed: [] }, + { stateBefore: "stale", code: "nothing_running", success: true, requested: [], stopped: [], surviving: [], failed: [] }, + { stateBefore: "unknown", code: "enumeration_unavailable", success: true, requested: [], stopped: [], surviving: [], failed: [] }, + { stateBefore: "stale", code: "partially_stopped", success: false, requested: [4242], stopped: [], surviving: [4242], failed: [] }, ] as const; for (const patch of cases) { const outcome = await requestCodexRestart("", { @@ -181,10 +181,24 @@ describe("requestCodexRestart", () => { })) as typeof fetch, ...formatters, }); + const stoppedWithoutAccounting = await requestCodexRestart("", { + fetchFn: (async () => response({ ...STOPPED, stopped: [] })) as typeof fetch, + ...formatters, + }); + const duplicatePids = await requestCodexRestart("", { + fetchFn: (async () => response({ + ...STOPPED, + requested: [4242, 4242], + stopped: [4242, 4242], + })) as typeof fetch, + ...formatters, + }); expect(successWithFailureCode).toEqual({ ok: false, message: "malformed" }); expect(cleanCodeWithSurvivors).toEqual({ ok: false, message: "malformed" }); expect(nothingRunningButStopped).toEqual({ ok: false, message: "malformed" }); + expect(stoppedWithoutAccounting).toEqual({ ok: false, message: "malformed" }); + expect(duplicatePids).toEqual({ ok: false, message: "malformed" }); }); test("a body that never finishes arriving is a timeout, not a malformed body", async () => { @@ -219,4 +233,3 @@ describe("requestCodexRestart", () => { expect(outcome).toEqual({ ok: false, message: "malformed" }); }); }); - diff --git a/gui/tests/codex-stale-banner-dom.test.tsx b/gui/tests/codex-stale-banner-dom.test.tsx index 5b59c3c489..10fe9143c7 100644 --- a/gui/tests/codex-stale-banner-dom.test.tsx +++ b/gui/tests/codex-stale-banner-dom.test.tsx @@ -162,10 +162,40 @@ test("nothing_running also counts as settled", async () => { expect(reloads).toBe(1); }); +test("a fresh no-op reports that no stale target was stopped", async () => { + let reloads = 0; + let message = ""; + Object.defineProperty(globalThis, "alert", { + configurable: true, + value: (value: unknown) => { message = String(value); }, + }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => new Response(JSON.stringify(restartBody({ + stateBefore: "fresh", + code: "nothing_running", + requested: [], + stopped: [], + })), { status: 200, headers: { "content-type": "application/json" } }), + }); + + render( { reloads += 1; }} />); + const head = host.querySelector('[data-testid="head"]') as HTMLButtonElement; + await act(async () => { head.click(); }); + + expect(reloads).toBe(1); + expect(message).toBe("No stale Codex app-server required a restart. Nothing was stopped."); +}); + test("an unresolved outcome does not clear the banner", async () => { // partially_stopped means a target is still holding the old catalog, so the // banner must stay and the state must not be re-read as if it were settled. let reloads = 0; + let message = ""; + Object.defineProperty(globalThis, "alert", { + configurable: true, + value: (value: unknown) => { message = String(value); }, + }); Object.defineProperty(globalThis, "fetch", { configurable: true, value: async () => new Response(JSON.stringify(restartBody({ @@ -173,6 +203,7 @@ test("an unresolved outcome does not clear the banner", async () => { code: "partially_stopped", stopped: [], surviving: [4242], + failed: [4242], })), { status: 200, headers: { "content-type": "application/json" } }), }); @@ -182,6 +213,9 @@ test("an unresolved outcome does not clear the banner", async () => { expect(reloads).toBe(0); expect(host.querySelector(".codex-stale-banner")).not.toBeNull(); + expect(message).toBe( + "1 app-server(s) did not exit. Stop them manually if the model list stays stale.", + ); }); test("a declined confirm sends no request and does not refresh", async () => { diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index c70c3af5c5..654b42b9b1 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -86,6 +86,13 @@ export interface CodexAppServerProcessIo { getuid?: () => number | undefined; listSnapshots?: () => ProcessSnapshot[]; isAlive?: (pid: number) => boolean; + /** + * Last-moment fail-closed identity guard. It runs after the helper's + * pid+command/executable match and immediately before the platform-specific + * termination path, so callers can refuse a signal without replacing the + * trusted Windows taskkill implementation. + */ + beforeSignal?: (pid: number, signal: NodeJS.Signals) => void; kill?: (pid: number, signal: NodeJS.Signals) => void; /** Windows termination seam: drives the taskkill branch without a real exec. */ execFile?: (file: string, args: readonly string[]) => void; @@ -541,6 +548,7 @@ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = proc export function readProcessStartMsBatch( pids: readonly number[], platform: NodeJS.Platform = process.platform, + timeoutMs?: number, ): Map { const out = new Map(); if (pids.length === 0) return out; @@ -549,7 +557,7 @@ export function readProcessStartMsBatch( const stdout = execFileSync("/bin/ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], - timeout: 3_000, + timeout: timeoutMs ?? 3_000, }); const byPid = new Map(); for (const raw of stdout.split(/\r?\n/)) { @@ -573,7 +581,12 @@ export function readProcessStartMsBatch( "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }); + ], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: timeoutMs ?? 5_000, + windowsHide: true, + }); const byPid = new Map(); for (const line of stdout.split(/\r?\n/)) { const tab = line.indexOf("\t"); @@ -760,8 +773,10 @@ function defaultKillCodexAppServer( try { exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"]); } catch { - // Fall back to the previous behavior rather than reporting a failure the old - // code would not have reported. + // taskkill may have raced with process exit and PID reuse before returning + // an error. Re-run the caller's identity gate immediately before the + // single-process fallback so a replacement process is never terminated. + io.beforeSignal?.(pid, signal); signalProcess(pid, signal); } } @@ -773,6 +788,7 @@ export function restartCodexAppServers( io: CodexAppServerProcessIo = {}, ): RestartCodexAppServersResult { const isAlive = io.isAlive ?? isProcessAlive; + const beforeSignal = io.beforeSignal; const kill = io.kill ?? ((pid, signal) => { defaultKillCodexAppServer(pid, signal, io); }); const wait = io.waitExit ?? waitForExit; const now = io.now ?? Date.now; @@ -794,9 +810,11 @@ export function restartCodexAppServers( if (!live || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) { // Original target exited (or identity changed); do not signal a replacement. if (!isAlive(proc.pid)) stopped.push(proc.pid); + else surviving.push(proc.pid); continue; } try { + beforeSignal?.(proc.pid, "SIGTERM"); kill(proc.pid, "SIGTERM"); signaled.push(proc); } catch (error) { diff --git a/src/codex/app-server-restart-service.ts b/src/codex/app-server-restart-service.ts index 47a3cf4855..528e47e515 100644 --- a/src/codex/app-server-restart-service.ts +++ b/src/codex/app-server-restart-service.ts @@ -30,6 +30,7 @@ import type { CodexAppServerStateResponse, CodexRestartResponse, } from "../lib/codex-restart-contract"; +import { isProcessAlive } from "../lib/process-control"; import { getServerListenPort } from "../server/lifecycle"; export interface CodexRestartServiceIo { @@ -48,9 +49,14 @@ export interface CodexRestartServiceIo { restart?: typeof restartCodexAppServers; resetStateCache?: () => void; /** Start-time reader used to re-confirm process identity before signalling. */ - readStartMs?: (pids: readonly number[]) => Map; + readStartMs?: (pids: readonly number[], timeoutMs?: number) => Map; } +// The final identity read happens synchronously inside the signal loop. Keep its +// fail-closed wait much shorter than the initial batched classification query so +// one slow ps/CIM lookup cannot consume the full platform timeout per target. +const FINAL_IDENTITY_READ_TIMEOUT_MS = 1_500; + /** * Thrown by the final identity gate when a pid no longer belongs to the process * that was classified. restartCodexAppServers turns a kill throw into a `failed` @@ -123,45 +129,80 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise [entry.pid, entry.startedAtMs] as const), - ); + const isUsableStartMs = (value: number | null): value is number => + value !== null && Number.isFinite(value) && value >= 0; + const classifiedStarts = new Map(); + const unresolved = new Set(); + for (const entry of before.processes) { + if (!isUsableStartMs(entry.startedAtMs)) { + // A stale aggregate carrying an unreadable row is inconsistent, but still + // not permission to forget a process that may be running the old catalog. + unresolved.add(entry.pid); + } else if (entry.startedAtMs <= catalogMtimeMs) { + classifiedStarts.set(entry.pid, entry.startedAtMs); + } + } const live = (io.listProcesses ?? listCodexAppServerProcesses)(io.processIo ?? {}); const candidates = live.filter(process => classifiedStarts.has(process.pid)); + const candidatePids = new Set(candidates.map(process => process.pid)); + const isAlive = io.processIo?.isAlive ?? isProcessAlive; + + // An empty second listing can mean that every stale target exited, but the + // default platform enumerator also fails closed to an empty list. Distinguish + // those cases with the existing liveness seam: a still-live classified pid + // whose command identity cannot be recovered must keep the outcome unsettled. + for (const pid of classifiedStarts.keys()) { + if (candidatePids.has(pid)) continue; + try { + if (isAlive(pid)) unresolved.add(pid); + } catch { + // A liveness probe failure is not proof that the stale process exited. + unresolved.add(pid); + } + } // A pid plus a command line is not an identity: a replacement app-server launched // by the same Codex install has both. Re-read start times and drop any candidate // whose process started after the reading we classified, so a recycled pid can // never receive a signal meant for the process that held it. const platform = io.processIo?.platform ?? process.platform; + const readStartMs = io.readStartMs + ?? ((pids: readonly number[], timeoutMs?: number) => readProcessStartMsBatch(pids, platform, timeoutMs)); const startsNow = candidates.length > 0 - ? (io.readStartMs ?? (pids => readProcessStartMsBatch(pids, platform)))( - candidates.map(process => process.pid), - ) + ? readStartMs(candidates.map(process => process.pid)) : new Map(); const targets = candidates.filter(process => { const classified = classifiedStarts.get(process.pid) ?? null; const current = startsNow.get(process.pid) ?? null; - // An unreadable start time on either side means we cannot prove sameness. - if (classified === null || current === null) return false; - return classified === current; + // An unreadable or changed start time means we cannot prove this live pid is + // the stale process we classified. Refuse the signal, but do not report the + // request as settled while that pid may still hold the old catalog. + if (classified === null || current === null || classified !== current) { + unresolved.add(process.pid); + return false; + } + return true; }); - if (targets.length === 0) { + if (targets.length === 0 && unresolved.size === 0) { // Every classified process exited, or the pid now belongs to a different // process. Reporting "stopped" would claim credit for work this request did // not do. @@ -192,32 +233,68 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise { + beforeSignal: (pid, signal) => { const classified = classifiedStarts.get(pid) ?? null; - const current = (io.readStartMs ?? (pids => readProcessStartMsBatch(pids, platform)))([pid]) + const current = readStartMs([pid], FINAL_IDENTITY_READ_TIMEOUT_MS) .get(pid) ?? null; - if (classified === null || current === null || classified !== current) { + if (!isUsableStartMs(classified) || !isUsableStartMs(current) || classified !== current) { throw new CodexAppServerIdentityChanged(pid); } - const send = io.processIo?.kill ?? ((target: number, sig: NodeJS.Signals) => { - process.kill(target, sig); - }); - send(pid, signal); + io.processIo?.beforeSignal?.(pid, signal); }, }; - const result = (io.restart ?? restartCodexAppServers)(targets, guardedProcessIo); - const clean = result.surviving.length === 0 && result.failed.length === 0; + const result = targets.length > 0 + ? (io.restart ?? restartCodexAppServers)(targets, guardedProcessIo) + : { requested: [], stopped: [], surviving: [], failed: [] }; + const stoppedSet = new Set(result.stopped); + const survivingSet = new Set(result.surviving); + const failedSet = new Set(result.failed.map(entry => entry.pid)); + for (const pid of failedSet) survivingSet.add(pid); + for (const pid of unresolved) { + survivingSet.add(pid); + failedSet.add(pid); + } + const requestedSet = new Set([ + ...targets.map(target => target.pid), + ...result.requested, + ...stoppedSet, + ...survivingSet, + ...failedSet, + ]); + + // A helper stop can race with a still-live pid, and a custom seam or final + // helper re-list can omit a requested pid from every terminal bucket. Recheck + // every non-survivor instead of interpreting either case as a successful stop. + for (const pid of requestedSet) { + if (survivingSet.has(pid)) continue; + try { + if (isAlive(pid)) survivingSet.add(pid); + else if (!stoppedSet.has(pid)) stoppedSet.add(pid); + } catch { + survivingSet.add(pid); + failedSet.add(pid); + } + } + for (const pid of survivingSet) stoppedSet.delete(pid); + + const ascending = (left: number, right: number) => left - right; + const requested = [...requestedSet].sort(ascending); + const stopped = [...stoppedSet].sort(ascending); + const surviving = [...survivingSet].sort(ascending); + const failed = [...failedSet].sort(ascending); + const clean = surviving.length === 0; return { success: clean, stateBefore: before.state, synced, - requested: result.requested, - stopped: result.stopped, - surviving: result.surviving, + requested, + stopped, + surviving, // Project { pid, error } down to pids: an OS error message can embed a path - // or the account name. - failed: result.failed.map(entry => entry.pid), + // or the account name. Identity-verification failures use the same private- + // data-free pid projection and remain unsettled for the GUI. + failed, code: clean ? "stopped" : "partially_stopped", }; } @@ -229,4 +306,3 @@ async function defaultSyncCatalog(port?: number): Promise { const result = await syncModelsToCodex(port, undefined, null); return result.catalogWritten || result.cacheSynced; } - diff --git a/src/lib/codex-restart-contract.ts b/src/lib/codex-restart-contract.ts index 274bec19fe..9e09995aad 100644 --- a/src/lib/codex-restart-contract.ts +++ b/src/lib/codex-restart-contract.ts @@ -20,6 +20,11 @@ export const CODEX_APP_SERVER_STATE_PATH = "/api/system/codex-app-server"; /** Mirrors CodexAppServerCatalogState so the GUI never imports runtime code. */ export type CodexAppServerState = "fresh" | "stale" | "not_running" | "unknown"; +/** + * `nothing_running` is retained for wire compatibility. It means no stale + * restart target remained, not necessarily that the process list was empty; + * stateBefore distinguishes fresh, not-running, and exited-before-signal cases. + */ export type CodexRestartCode = | "stopped" | "nothing_running" @@ -59,8 +64,18 @@ const RESTART_CODES: readonly string[] = [ * let a malformed body reach UI code that renders counts and indexes lengths. */ function isPidList(value: unknown): value is number[] { - return Array.isArray(value) - && value.every(entry => typeof entry === "number" && Number.isSafeInteger(entry) && entry > 0); + if (!Array.isArray(value)) return false; + if (!value.every(entry => + typeof entry === "number" && Number.isSafeInteger(entry) && entry > 0 + )) return false; + return new Set(value).size === value.length; +} + +function isSubset(subset: ReadonlySet, superset: ReadonlySet): boolean { + for (const pid of subset) { + if (!superset.has(pid)) return false; + } + return true; } /** @@ -89,20 +104,55 @@ export function isCodexRestartResponse(value: unknown): value is CodexRestartRes const success = view.success as boolean; const code = view.code as CodexRestartCode; + const stateBefore = view.stateBefore as CodexAppServerState; + const requested = view.requested as number[]; const surviving = view.surviving as number[]; const failed = view.failed as number[]; const stopped = view.stopped as number[]; + const requestedSet = new Set(requested); + const stoppedSet = new Set(stopped); + const survivingSet = new Set(surviving); + const failedSet = new Set(failed); // `success` and `code` must agree: only partially_stopped is an unsuccessful code. if (success !== (code !== "partially_stopped")) return false; - // A clean outcome cannot leave anything behind. - if (success && (surviving.length > 0 || failed.length > 0)) return false; - // An unsuccessful outcome must name what survived. - if (!success && surviving.length === 0 && failed.length === 0) return false; - // Nothing can be reported stopped when the service says nothing was running. - if ((code === "nothing_running" || code === "enumeration_unavailable") && stopped.length > 0) { + // The code must describe the classifier state that can produce it. A stale + // target may exit before signalling, so stale+nothing_running is intentional; + // unknown is never a clean no-op, and fresh/not-running can never be stopped. + if ((code === "stopped" || code === "partially_stopped") && stateBefore !== "stale") { return false; } + if (code === "enumeration_unavailable" && stateBefore !== "unknown") return false; + if (code === "nothing_running" && stateBefore === "unknown") return false; + + // Every terminal bucket is an accounting of a requested pid. A failed signal + // remains a survivor, while a stopped pid cannot simultaneously be live. + if (!isSubset(stoppedSet, requestedSet) || !isSubset(survivingSet, requestedSet)) return false; + if (!isSubset(failedSet, survivingSet)) return false; + for (const pid of stoppedSet) { + if (survivingSet.has(pid)) return false; + } + + if (code === "nothing_running" || code === "enumeration_unavailable") { + return requested.length === 0 + && stopped.length === 0 + && surviving.length === 0 + && failed.length === 0; + } + + if (requested.length === 0) return false; + if (code === "stopped") { + return surviving.length === 0 + && failed.length === 0 + && stoppedSet.size === requestedSet.size; + } + + // A partial result must account for every request as either stopped or still + // alive. failed is diagnostic detail for the surviving subset. + if (surviving.length === 0) return false; + for (const pid of requestedSet) { + if (!stoppedSet.has(pid) && !survivingSet.has(pid)) return false; + } return true; } @@ -117,4 +167,3 @@ export function isCodexAppServerStateResponse( && Number.isSafeInteger(view.runningCount) && view.runningCount >= 0; } - diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 4a63be2f2e..a69d5afab8 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -303,7 +303,7 @@ describe("Codex app-server process matching (#476)", () => { ); expect(signals).toEqual([]); expect(result.stopped).toEqual([]); - expect(result.surviving).toEqual([]); + expect(result.surviving).toEqual([42]); expect(result.failed).toEqual([]); }); @@ -323,7 +323,7 @@ describe("Codex app-server process matching (#476)", () => { ); expect(signals).toEqual([]); expect(result.stopped).toEqual([]); - expect(result.surviving).toEqual([]); + expect(result.surviving).toEqual([42]); expect(result.failed).toEqual([]); }); @@ -724,16 +724,19 @@ describe("platform termination ladder", () => { // process.kill(SIGTERM) on Windows is already an unconditional terminate of // one process; /T adds the child cleanup it lacks. const execCalls: Array<{ file: string; args: readonly string[] }> = []; + const guarded: number[] = []; const signals: number[] = []; restartCodexAppServers([target], { platform: "win32", listSnapshots: snapshots, + beforeSignal: pid => { guarded.push(pid); }, execFile: (file, args) => { execCalls.push({ file, args }); }, processKill: pid => { signals.push(pid); }, isAlive: () => false, waitExit: () => true, }); + expect(guarded).toEqual([4242]); expect(execCalls).toHaveLength(1); expect(execCalls[0]!.args).toEqual(["/PID", "4242", "/T", "/F"]); expect(execCalls[0]!.file.toLowerCase()).toContain("taskkill"); @@ -742,17 +745,20 @@ describe("platform termination ladder", () => { test("a failing taskkill falls back to the previous behavior", () => { // The branch that keeps a Windows regression from being worse than the code - // it replaced. + // it replaced, while preserving the caller's final identity gate. + const guarded: number[] = []; const signals: Array<{ pid: number; signal: string }> = []; restartCodexAppServers([target], { platform: "win32", listSnapshots: snapshots, + beforeSignal: pid => { guarded.push(pid); }, execFile: () => { throw new Error("taskkill unavailable"); }, processKill: (pid, signal) => { signals.push({ pid, signal }); }, isAlive: () => false, waitExit: () => true, }); + expect(guarded).toEqual([4242, 4242]); expect(signals).toEqual([{ pid: 4242, signal: "SIGTERM" }]); }); diff --git a/tests/codex-app-server-restart-service.test.ts b/tests/codex-app-server-restart-service.test.ts index 9884ea9de9..850cad2065 100644 --- a/tests/codex-app-server-restart-service.test.ts +++ b/tests/codex-app-server-restart-service.test.ts @@ -16,6 +16,7 @@ import { import type { CodexRestartServiceIo } from "../src/codex/app-server-restart-service"; import type { CodexAppServerProcess } from "../src/codex/app-server-processes"; import { isCodexRestartResponse } from "../src/lib/codex-restart-contract"; +import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; function proc(pid: number, commandLine = `/opt/codex app-server --pid ${pid}`): CodexAppServerProcess { return { pid, commandLine }; @@ -29,6 +30,7 @@ function baseIo(overrides: CodexRestartServiceIo = {}): CodexRestartServiceIo { resetStateCache: () => {}, collectState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), listProcesses: () => [], + processIo: { isAlive: () => false }, restart: () => ({ requested: [], stopped: [], surviving: [], failed: [] }), readStartMs: pids => new Map(pids.map(pid => [pid, 1])), ...overrides, @@ -95,6 +97,55 @@ describe("performCodexRestart", () => { expect(result.requested).toEqual([]); }); + test("a fresh classifier reading signals nothing", async () => { + let listed = false; + let restarted = false; + const result = await performCodexRestart(baseIo({ + collectState: () => ({ + state: "fresh", + processes: [{ pid: 100, startedAtMs: 20 }], + catalogMtimeMs: 10, + }), + listProcesses: () => { + listed = true; + return [proc(100)]; + }, + restart: () => { + restarted = true; + return { requested: [100], stopped: [100], surviving: [], failed: [] }; + }, + })); + + expect(listed).toBe(false); + expect(restarted).toBe(false); + expect(result.code).toBe("nothing_running"); + expect(result.stateBefore).toBe("fresh"); + expect(result.requested).toEqual([]); + }); + + test("a mixed reading signals only stale app-servers, including the equality boundary", async () => { + let received: CodexAppServerProcess[] = []; + await performCodexRestart(baseIo({ + collectState: () => ({ + state: "stale", + processes: [ + { pid: 100, startedAtMs: 5 }, + { pid: 200, startedAtMs: 20 }, + { pid: 300, startedAtMs: 10 }, + ], + catalogMtimeMs: 10, + }), + listProcesses: () => [proc(100), proc(200), proc(300)], + readStartMs: () => new Map([[100, 5], [200, 20], [300, 10]]), + restart: targets => { + received = [...targets]; + return { requested: [100, 300], stopped: [100, 300], surviving: [], failed: [] }; + }, + })); + + expect(received.map(entry => entry.pid)).toEqual([100, 300]); + }); + test("a survivor makes the result partially_stopped and unsuccessful", async () => { const result = await performCodexRestart(baseIo({ collectState: () => ({ @@ -112,6 +163,29 @@ describe("performCodexRestart", () => { expect(result.surviving).toEqual([200]); }); + test("a helper stop stays unsettled while the pid is still alive", async () => { + const result = await performCodexRestart(baseIo({ + collectState: () => ({ + state: "stale", + processes: [{ pid: 100, startedAtMs: 1 }], + catalogMtimeMs: 10, + }), + listProcesses: () => [proc(100)], + readStartMs: () => new Map([[100, 1]]), + processIo: { isAlive: () => true }, + restart: () => ({ requested: [100], stopped: [100], surviving: [], failed: [] }), + })); + + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [100], + stopped: [], + surviving: [100], + failed: [], + }); + }); + test("a target that exits between classification and signalling is not credited", async () => { // Race: the classifier saw a stale server, but it exited on its own before we // re-listed. Claiming "stopped" here would take credit for work we did not do. @@ -134,6 +208,34 @@ describe("performCodexRestart", () => { expect(restarted).toBe(false); }); + test("a failed second enumeration keeps a live stale target unsettled", async () => { + let restarted = false; + const result = await performCodexRestart(baseIo({ + collectState: () => ({ + state: "stale", + processes: [{ pid: 100, startedAtMs: 1 }], + catalogMtimeMs: 10, + }), + listProcesses: () => [], + processIo: { isAlive: () => true }, + restart: () => { + restarted = true; + return { requested: [100], stopped: [100], surviving: [], failed: [] }; + }, + })); + + expect(restarted).toBe(false); + expect(result).toMatchObject({ + success: false, + stateBefore: "stale", + code: "partially_stopped", + requested: [100], + stopped: [], + surviving: [100], + failed: [100], + }); + }); + test("only classified pids are signalled, and they carry a real command line", async () => { // The identity bridge: the classifier returns { pid, startedAtMs } with no // command line, so the service must re-list to get one. A live app-server the @@ -318,8 +420,14 @@ describe("identity and concurrency protection", () => { })); expect(restarted).toBe(false); - expect(result.code).toBe("nothing_running"); - expect(result.requested).toEqual([]); + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [4242], + stopped: [], + surviving: [4242], + failed: [4242], + }); }); test("a matching start time still lets the real target through", async () => { @@ -344,7 +452,7 @@ describe("identity and concurrency protection", () => { test("an unreadable start time refuses to signal rather than guessing", async () => { let restarted = false; - await performCodexRestart(baseIo({ + const result = await performCodexRestart(baseIo({ collectState: () => ({ state: "stale", processes: [{ pid: 4242, startedAtMs: 1_000 }], @@ -359,6 +467,40 @@ describe("identity and concurrency protection", () => { })); expect(restarted).toBe(false); + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [4242], + stopped: [], + surviving: [4242], + failed: [4242], + }); + }); + + test("an invalid start in a stale classifier result remains unresolved", async () => { + let restarted = false; + const result = await performCodexRestart(baseIo({ + collectState: () => ({ + state: "stale", + processes: [{ pid: 4242, startedAtMs: null }], + catalogMtimeMs: 5_000, + }), + listProcesses: () => [proc(4242)], + restart: () => { + restarted = true; + return { requested: [], stopped: [], surviving: [], failed: [] }; + }, + })); + + expect(restarted).toBe(false); + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [4242], + stopped: [], + surviving: [4242], + failed: [4242], + }); }); test("unknown WITH known processes signals nothing", async () => { @@ -485,13 +627,17 @@ describe("last-moment identity gate (through the real restart helper)", () => { test("a stable pid is signalled through the same path", async () => { const killed: number[] = []; + const startReadTimeouts: Array = []; const result = await performCodexRestart({ syncCatalog: async () => true, listenPort: () => 41999, resetStateCache: () => {}, collectState: stale, listProcesses: () => [proc(4242)], - readStartMs: pids => new Map(pids.map(pid => [pid, 1_000])), + readStartMs: (pids, timeoutMs) => { + startReadTimeouts.push(timeoutMs); + return new Map(pids.map(pid => [pid, 1_000])); + }, processIo: { listSnapshots: () => [{ pid: 4242, commandLine: "/opt/codex app-server --pid 4242" }], kill: pid => { killed.push(pid); }, @@ -501,10 +647,115 @@ describe("last-moment identity gate (through the real restart helper)", () => { }); expect(killed).toEqual([4242]); + expect(startReadTimeouts).toEqual([undefined, 1_500]); expect(result.code).toBe("stopped"); expect(result.success).toBe(true); }); + test("a live target omitted by the helper's final re-list remains unsettled", async () => { + const killed: number[] = []; + const result = await performCodexRestart({ + syncCatalog: async () => true, + listenPort: () => 41999, + resetStateCache: () => {}, + collectState: stale, + listProcesses: () => [proc(4242)], + readStartMs: pids => new Map(pids.map(pid => [pid, 1_000])), + processIo: { + listSnapshots: () => [], + kill: pid => { killed.push(pid); }, + isAlive: () => true, + waitExit: () => false, + }, + }); + + expect(killed).toEqual([]); + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [4242], + stopped: [], + surviving: [4242], + failed: [], + }); + }); + + test("the service identity guard preserves Windows taskkill tree cleanup", async () => { + const execCalls: Array<{ file: string; args: readonly string[] }> = []; + const signals: number[] = []; + setTrustedWindowsElevationExecutablesForTests({ + taskkill: "C:\\Windows\\System32\\taskkill.exe", + }); + try { + const result = await performCodexRestart({ + syncCatalog: async () => true, + listenPort: () => 41999, + resetStateCache: () => {}, + collectState: stale, + listProcesses: () => [proc(4242)], + readStartMs: pids => new Map(pids.map(pid => [pid, 1_000])), + processIo: { + platform: "win32", + listSnapshots: () => [proc(4242)], + execFile: (file, args) => { execCalls.push({ file, args }); }, + processKill: pid => { signals.push(pid); }, + isAlive: () => false, + waitExit: () => true, + }, + }); + + expect(execCalls).toHaveLength(1); + expect(execCalls[0]!.args).toEqual(["/PID", "4242", "/T", "/F"]); + expect(signals).toEqual([]); + expect(result.code).toBe("stopped"); + } finally { + setTrustedWindowsElevationExecutablesForTests(null); + } + }); + + test("a failed taskkill rechecks identity before the Windows signal fallback", async () => { + const signals: number[] = []; + let startReads = 0; + setTrustedWindowsElevationExecutablesForTests({ + taskkill: "C:\\Windows\\System32\\taskkill.exe", + }); + try { + const result = await performCodexRestart({ + syncCatalog: async () => true, + listenPort: () => 41999, + resetStateCache: () => {}, + collectState: stale, + listProcesses: () => [proc(4242)], + readStartMs: pids => { + startReads += 1; + const startedAtMs = startReads < 3 ? 1_000 : 2_000; + return new Map(pids.map(pid => [pid, startedAtMs])); + }, + processIo: { + platform: "win32", + listSnapshots: () => [proc(4242)], + execFile: () => { throw new Error("taskkill unavailable"); }, + processKill: pid => { signals.push(pid); }, + isAlive: () => true, + waitExit: () => false, + }, + }); + + expect(startReads).toBe(3); + expect(signals).toEqual([]); + expect(result).toMatchObject({ + success: false, + code: "partially_stopped", + requested: [4242], + stopped: [], + surviving: [4242], + failed: [4242], + }); + } finally { + setTrustedWindowsElevationExecutablesForTests(null); + } + }); + test("an unreadable start time at signal time refuses the signal", async () => { const killed: number[] = []; let startReads = 0; @@ -556,4 +807,4 @@ 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 +}); diff --git a/tests/codex-restart-contract-parity.test.ts b/tests/codex-restart-contract-parity.test.ts index 0545c9395b..8eb539f20d 100644 --- a/tests/codex-restart-contract-parity.test.ts +++ b/tests/codex-restart-contract-parity.test.ts @@ -42,6 +42,27 @@ describe("every service response satisfies the shared contract guard", () => { expect(isCodexRestartResponse(JSON.parse(JSON.stringify(result)))).toBe(true); }); + test("nothing_running with fresh app-servers", async () => { + resetCodexRestartInFlightForTests(); + const result = await performCodexRestart({ + syncCatalog: async () => false, + listenPort: () => 41999, + resetStateCache: () => {}, + collectState: () => ({ + state: "fresh", + processes: [{ pid: 100, startedAtMs: 20 }], + catalogMtimeMs: 10, + }), + listProcesses: () => [proc(100)], + readStartMs: () => new Map([[100, 20]]), + restart: () => ({ requested: [100], stopped: [100], surviving: [], failed: [] }), + }); + expect(result.code).toBe("nothing_running"); + expect(result.stateBefore).toBe("fresh"); + expect(result.requested).toEqual([]); + expect(isCodexRestartResponse(JSON.parse(JSON.stringify(result)))).toBe(true); + }); + test("nothing_running via the exited-before-signal race", async () => { resetCodexRestartInFlightForTests(); const result = await performCodexRestart({ @@ -50,6 +71,7 @@ describe("every service response satisfies the shared contract guard", () => { resetStateCache: () => {}, collectState: () => ({ state: "stale", processes: [{ pid: 100, startedAtMs: 1 }], catalogMtimeMs: 10 }), listProcesses: () => [], + processIo: { isAlive: () => false }, readStartMs: () => new Map(), restart: () => ({ requested: [], stopped: [], surviving: [], failed: [] }), }); @@ -72,6 +94,27 @@ describe("every service response satisfies the shared contract guard", () => { expect(isCodexRestartResponse(JSON.parse(JSON.stringify(result)))).toBe(true); }); + test("an unverifiable stale target remains contract-valid and unsettled", async () => { + resetCodexRestartInFlightForTests(); + const result = await performCodexRestart({ + syncCatalog: async () => true, + listenPort: () => 41999, + resetStateCache: () => {}, + collectState: () => ({ state: "stale", processes: [{ pid: 100, startedAtMs: 1 }], catalogMtimeMs: 10 }), + listProcesses: () => [proc(100)], + readStartMs: () => new Map([[100, null]]), + restart: () => ({ requested: [], stopped: [], surviving: [], failed: [] }), + }); + expect(result).toMatchObject({ + success: false, + stateBefore: "stale", + code: "partially_stopped", + surviving: [100], + failed: [100], + }); + expect(isCodexRestartResponse(JSON.parse(JSON.stringify(result)))).toBe(true); + }); + test("partially_stopped with a survivor", async () => { resetCodexRestartInFlightForTests(); const result = await performCodexRestart({ @@ -114,5 +157,60 @@ describe("every service response satisfies the shared contract guard", () => { expect(result.failed).toEqual([100]); expect(isCodexRestartResponse(JSON.parse(JSON.stringify(result)))).toBe(true); }); -}); + test("the guard rejects impossible code and classifier-state pairs", () => { + const base = { + success: true, + stateBefore: "stale", + synced: true, + requested: [100], + stopped: [100], + surviving: [], + failed: [], + code: "stopped", + } as const; + const invalid = [ + { ...base, stateBefore: "fresh" }, + { ...base, stateBefore: "not_running", code: "partially_stopped", success: false, stopped: [], surviving: [100] }, + { ...base, stateBefore: "stale", code: "enumeration_unavailable", stopped: [] }, + { ...base, stateBefore: "unknown", code: "nothing_running", requested: [], stopped: [] }, + { ...base, stopped: [] }, + { ...base, requested: [100, 100], stopped: [100, 100] }, + { ...base, requested: [100, 200], stopped: [100] }, + { ...base, requested: [], stopped: [100] }, + { ...base, code: "nothing_running", requested: [100], stopped: [] }, + { + ...base, + success: false, + code: "partially_stopped", + stopped: [100], + surviving: [100], + }, + { + ...base, + success: false, + code: "partially_stopped", + stopped: [], + surviving: [100], + failed: [200], + }, + ]; + for (const response of invalid) expect(isCodexRestartResponse(response)).toBe(false); + expect(isCodexRestartResponse({ + ...base, + stateBefore: "fresh", + code: "nothing_running", + requested: [], + stopped: [], + })).toBe(true); + expect(isCodexRestartResponse({ + ...base, + success: false, + code: "partially_stopped", + requested: [100, 200], + stopped: [100], + surviving: [200], + failed: [200], + })).toBe(true); + }); +});