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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/codex/app-server-restart-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,22 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
code: before.state === "unknown" ? "enumeration_unavailable" : "nothing_running",
});

// `unknown` is not only the empty enumeration-failure case: the classifier also
// returns it WITH processes when the catalog mtime or a start time is unreadable.
// In that state it has not established that any server predates the catalog, so
// signalling would kill a possibly-current app-server on a guess.
if (before.state === "unknown" || before.processes.length === 0) return nothingToDo();
// Only a stale verdict establishes that any server predates the catalog.
// `unknown` can include processes with unreadable timestamps, while `fresh`
// explicitly establishes that none should be interrupted.
const catalogMtimeMs = before.catalogMtimeMs;
if (before.state !== "stale" || catalogMtimeMs === null || before.processes.length === 0) {
return nothingToDo();
Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report fresh servers as already current

When the catalog sync leaves an already-current app-server, before.state is fresh with a nonempty process list, but this branch calls nothingToDo(), which returns code: "nothing_running". The dashboard maps that code to “No Codex app-server is running,” so it reports a false process state even though the server remains live. Return a distinct already-current outcome and handle it in the response contract and translated dashboard/docs copy rather than reusing nothing_running.

AGENTS.md reference: AGENTS.md:L279-L280

Useful? React with 👍 / 👎.

}

// The classifier carries { pid, startedAtMs } and no command line, but
// restartCodexAppServers needs the full identity so it can refuse to signal a
// recycled pid. Re-list and intersect on pid rather than reconstructing an
// identity we never verified.
const classifiedStarts = new Map(
before.processes.map(entry => [entry.pid, entry.startedAtMs] as const),
before.processes
.filter(entry => entry.startedAtMs !== null && entry.startedAtMs <= catalogMtimeMs)
.map(entry => [entry.pid, entry.startedAtMs] as const),
);
const live = (io.listProcesses ?? listCodexAppServerProcesses)(io.processIo ?? {});
const candidates = live.filter(process => classifiedStarts.has(process.pid));
Expand Down
42 changes: 41 additions & 1 deletion tests/codex-app-server-restart-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,46 @@ describe("performCodexRestart", () => {
expect(result.requested).toEqual([]);
});

test("a fresh classifier reading signals nothing", async () => {
let restarted = false;
const result = await performCodexRestart(baseIo({
collectState: () => ({
state: "fresh",
processes: [{ pid: 100, startedAtMs: 20 }],
catalogMtimeMs: 10,
}),
listProcesses: () => [proc(100)],
restart: () => {
restarted = true;
return { requested: [100], stopped: [100], surviving: [], failed: [] };
},
}));

expect(restarted).toBe(false);
expect(result.code).toBe("nothing_running");
expect(result.stateBefore).toBe("fresh");
expect(result.requested).toEqual([]);
});

test("a mixed stale and fresh reading signals only stale app-servers", async () => {
let received: CodexAppServerProcess[] = [];
await performCodexRestart(baseIo({
collectState: () => ({
state: "stale",
processes: [{ pid: 100, startedAtMs: 5 }, { pid: 200, startedAtMs: 20 }],
catalogMtimeMs: 10,
}),
listProcesses: () => [proc(100), proc(200)],
readStartMs: () => new Map([[100, 5], [200, 20]]),
restart: targets => {
received = [...targets];
return { requested: [100], stopped: [100], surviving: [], failed: [] };
},
}));

expect(received.map(entry => entry.pid)).toEqual([100]);
});

test("a survivor makes the result partially_stopped and unsuccessful", async () => {
const result = await performCodexRestart(baseIo({
collectState: () => ({
Expand Down Expand Up @@ -556,4 +596,4 @@ describe("last-moment identity gate (through the real restart helper)", () => {
expect(serialized).not.toContain("CodexAppServerIdentityChanged");
expect(result.failed).toEqual([4242]);
});
});
});
Loading