Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
37cfcaa
fix: expire idle companion brokers and serialize startup
pecord-ent Nov 3, 2026
1b470f3
refactor: simplify broker request and lock handling
pecord-ent Nov 4, 2026
65b1926
fix: close broker listener before draining and recover stale state
pecord-ent Nov 5, 2026
0f1a5f9
test: add the Windows codex.cmd shim to the lifecycle fixture
pecord-ent Nov 5, 2026
eb7e0cf
fix: end idle guard on final answer, serialize stale-lock reclaim, ig…
pecord-ent Nov 6, 2026
7bfff43
fix: treat zombie lock owners as exited when reclaiming the startup lock
pecord-ent Nov 6, 2026
594acbd
fix: tolerate concurrent broker cleanup and skip zombie test without …
pecord-ent Nov 6, 2026
36b93a8
test: reproduce concurrent broker cleanup deterministically
pecord-ent Nov 9, 2026
555ec0c
fix: wait for collaborations before releasing a turn, serialize Sessi…
pecord-ent Nov 9, 2026
beecc5c
fix: let SessionEnd fall back when the startup lock is held, drop emp…
pecord-ent Nov 9, 2026
3bd4808
fix: clear a thread's pending collaborations when its turn starts or …
pecord-ent Nov 9, 2026
41900ff
test: build the hook path with fileURLToPath
pecord-ent Nov 9, 2026
637e47b
test: wait for the app-server to see the in-flight RPC before disconn…
pecord-ent Nov 9, 2026
5301a69
refactor: trim to the idle-shutdown fix
pecord-ent Nov 9, 2026
5f2688c
fix: restore broker drain, final-answer and cleanup-race handling, cl…
pecord-ent Nov 12, 2026
1860c00
Restart the broker's final-answer grace timer on each collaboration d…
pecord-ent Nov 12, 2026
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ One simple first run is:

## Usage

Commands in a workspace reuse a shared Codex app-server. It shuts down after
60 seconds without connected clients, pending requests, or running turns, so
completed conversations can be archived from Codex even if Claude Code's session
cleanup did not run. Later commands start another server and can resume saved
conversations. Set `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS` to a positive number
of milliseconds to change this idle interval.

### `/codex:review`

Runs a normal Codex review on your current work. It gives you the same quality of code review as running `/review` inside Codex directly.
Expand Down
136 changes: 116 additions & 20 deletions plugins/codex/scripts/app-server-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import process from "node:process";
import { parseArgs } from "./lib/args.mjs";
import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs";
import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs";
import { clearBrokerSessionIfCurrent } from "./lib/broker-lifecycle.mjs";

const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);

Expand Down Expand Up @@ -70,6 +71,47 @@ async function main() {
let activeStreamSocket = null;
let activeStreamThreadIds = null;
const sockets = new Set();
const activeTurns = new Set();
const pendingCollaborations = new Map();
const finalAnswered = new Map();
const settleTimers = new Map();
let pendingRequests = 0;
let idleTimer = null;
let shuttingDown = false;
const configuredIdleMs = Number(process.env.CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS);
const idleTimeoutMs = Number.isFinite(configuredIdleMs) && configuredIdleMs > 0 ? configuredIdleMs : 60000;

function updateIdleTimer() {
clearTimeout(idleTimer);
idleTimer = null;
if (shuttingDown || sockets.size || pendingRequests || activeTurns.size) return;
idleTimer = setTimeout(() => {
shutdown(server).then(() => process.exit(0)).catch((error) => {
Comment thread
pecord-ent marked this conversation as resolved.
Resolved

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 Invalidate the saved PID when the idle broker exits

On POSIX, if the OS reuses the expired broker's PID for another process-group leader before Claude's SessionEnd, this idle exit leaves broker.json pointing at that unrelated process. The still-unchecked consumer is handleSessionEnd() in session-lifecycle-hook.mjs: it passes the saved PID to teardownBrokerSession() with terminateProcessTree, which sends SIGTERM to that process group even when the broker endpoint is gone. The new endpoint-reachability check only protects connections, not teardown. Clear the matching saved session/PID on idle exit or verify process ownership before terminating it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 5f2688c: on exit the broker removes broker.json when it still names its own endpoint, so SessionEnd no longer reads the expired pid, and a replacement's state is kept. Tests: an expired broker leaves no saved session; clearing for a different endpoint keeps the replacement's.

process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}, idleTimeoutMs);
}

function cancelSettle(turnKey) {
clearTimeout(settleTimers.get(turnKey));
settleTimers.delete(turnKey);
}

// One timer per turn: a collaboration that drains again restarts the grace period.
function settleFinalAnswer(turnKey, threadId) {
cancelSettle(turnKey);
if (pendingCollaborations.get(threadId)?.size) return;
const timer = setTimeout(() => {
settleTimers.delete(turnKey);
if (pendingCollaborations.get(threadId)?.size || !finalAnswered.has(turnKey)) return;
Comment on lines +102 to +107

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 Reset the final-answer timer after each collaboration drain

When a final-answer-only turn's collaborations drain repeatedly within 250 ms, each call leaves the previous timeout armed, unlike scheduleInferredCompletion() in lib/codex.mjs. An older timeout can remove the turn between collaborations before the latest grace period expires; a collaboration starting in that remaining window is then unprotected after the caller disconnects, allowing idle shutdown to terminate unfinished work. A Linux fake-server reproduction with drains at 200/350 ms and another collaboration starting at 500 ms closed the app-server at approximately 700 ms with a 200 ms idle timeout, before that collaboration's 1200 ms completion; runAppServerTurn() correctly waited. Keep one resettable inference timer per turn.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 1860c00: settleFinalAnswer cancels the turn's previous timer before arming a new one, so repeated drains share one grace period like scheduleInferredCompletion(). Test: collaborations drain at 100 and 200 ms and a third starts at 400 ms; without the fix the first timer releases the turn at ~350 ms and the app-server closes while the third is running, with the fix it stays up until that collaboration completes.

Comment on lines +104 to +107

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 Wait for active subagents before settling the parent turn

For a disconnected caller whose main thread emits final_answer while a subagent is still active, this predicate can retire the parent turn before that subagent finishes; scheduleInferredCompletion() in lib/codex.mjs:378-389 explicitly waits for activeSubagentTurns as well. A parent collaboration starting after the 250-ms timer is then unprotected because the parent has already been removed from both activeTurns and finalAnswered. The last subagent's completion consequently makes the broker idle despite that pending collaboration. On Linux with a 200-ms idle timeout, I reproduced shutdown at approximately 900 ms for a collaboration running from 400 to 1500 ms, while runAppServerTurn() with the same events correctly waited and succeeded. Keep the parent's guard until its active subagents drain, then start the inference grace period.

Useful? React with 👍 / 👎.

finalAnswered.delete(turnKey);
activeTurns.delete(turnKey);
updateIdleTimer();
}, 250);
timer.unref();
settleTimers.set(turnKey, timer);
}

function clearSocketOwnership(socket) {
if (activeRequestSocket === socket) {
Expand All @@ -82,6 +124,48 @@ async function main() {
}

function routeNotification(message) {
// A disconnected caller must not make its running turn eligible for shutdown.
const turnKey = `${message.params?.threadId}:${message.params?.turn?.id ?? message.params?.turnId}`;
const threadId = message.params?.threadId;
if (message.method === "turn/started") {
activeTurns.add(turnKey);
pendingCollaborations.delete(threadId);
}
if (message.method === "turn/completed") {
activeTurns.delete(turnKey);
finalAnswered.delete(turnKey);
cancelSettle(turnKey);
// Collaborations belong to the turn that started them; an interrupted turn may never
// have seen them complete, and a resumed thread must not inherit them.
pendingCollaborations.delete(threadId);
}
// lib/codex.mjs completes a turn from its final answer when turn/completed never
// arrives, once the thread's collaborations drain. Mirror that so such a turn does not
// pin the broker, and an unfinished collaboration is not cut off by idle expiry.
const item = message.params?.item;
if (item?.type === "collabAgentToolCall") {
const pending = pendingCollaborations.get(threadId) ?? new Set();
if (message.method === "item/started" || item.status === "inProgress") {
pending.add(item.id);
} else if (message.method === "item/completed") {
pending.delete(item.id);
}
if (pending.size) {
pendingCollaborations.set(threadId, pending);
} else {
pendingCollaborations.delete(threadId);
}
if (message.method === "item/completed") {
for (const [key, owner] of finalAnswered) {
if (owner === threadId) settleFinalAnswer(key, owner);
}
}
}
if (message.method === "item/completed" && item?.type === "agentMessage" && item.phase === "final_answer") {
finalAnswered.set(turnKey, threadId);
settleFinalAnswer(turnKey, threadId);
}
updateIdleTimer();
const target = activeRequestSocket ?? activeStreamSocket;
if (!target) {
return;
Expand All @@ -100,23 +184,33 @@ async function main() {
}

async function shutdown(server) {
if (shuttingDown) return;
shuttingDown = true;
clearTimeout(idleTimer);
Comment thread
pecord-ent marked this conversation as resolved.
Resolved
// Stop admitting clients before the app-server close, which can take seconds.
// A client admitted while draining would initialize fine, then fail its first request.
const closed = new Promise((resolve) => server.close(resolve));
for (const socket of sockets) {
socket.end();
}
await appClient.close().catch(() => {});
await new Promise((resolve) => server.close(resolve));
if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) {
fs.unlinkSync(listenTarget.path);
await closed;
// A replacement broker's teardown may already have removed these.
if (listenTarget.kind === "unix") {
fs.rmSync(listenTarget.path, { force: true });
}
if (pidFile && fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
if (pidFile) {
fs.rmSync(pidFile, { force: true });
}
// The saved pid would otherwise outlive this process, and SessionEnd signals it.
clearBrokerSessionIfCurrent(cwd, endpoint);
}

appClient.setNotificationHandler(routeNotification);

const server = net.createServer((socket) => {
sockets.add(socket);
updateIdleTimer();
socket.setEncoding("utf8");
let buffer = "";

Expand Down Expand Up @@ -181,25 +275,19 @@ async function main() {
continue;
}

if (allowInterruptDuringActiveStream) {
try {
const result = await appClient.request(message.method, message.params ?? {});
send(socket, { id: message.id, result });
} catch (error) {
send(socket, {
id: message.id,
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
});
}
continue;
}

const isStreaming = STREAMING_METHODS.has(message.method);
activeRequestSocket = socket;
if (!allowInterruptDuringActiveStream) {
activeRequestSocket = socket;
}
pendingRequests += 1;

try {
const result = await appClient.request(message.method, message.params ?? {});
send(socket, { id: message.id, result });
// Interrupts share request bookkeeping without taking stream ownership.
if (allowInterruptDuringActiveStream) {
continue;
}
if (isStreaming) {
activeStreamSocket = socket;
activeStreamThreadIds = buildStreamThreadIds(message.method, message.params ?? {}, result);
Expand All @@ -212,24 +300,32 @@ async function main() {
id: message.id,
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
});
if (allowInterruptDuringActiveStream) {
continue;
}
if (activeRequestSocket === socket) {
activeRequestSocket = null;
}
if (activeStreamSocket === socket && !isStreaming) {
activeStreamSocket = null;
}
} finally {
pendingRequests -= 1;
updateIdleTimer();
}
}
});

socket.on("close", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
updateIdleTimer();
});

socket.on("error", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
updateIdleTimer();
});
});

Expand All @@ -243,7 +339,7 @@ async function main() {
process.exit(0);
});

server.listen(listenTarget.path);
server.listen(listenTarget.path, updateIdleTimer);
}

main().catch((error) => {
Expand Down
6 changes: 4 additions & 2 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import process from "node:process";
import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
import { ensureBrokerSession, isBrokerEndpointReady, loadBrokerSession } from "./broker-lifecycle.mjs";
import { terminateProcessTree } from "./process.mjs";

const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
Expand Down Expand Up @@ -338,7 +338,9 @@ export class CodexAppServerClient {
if (!options.disableBroker) {
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
if (!brokerEndpoint && options.reuseExistingBroker) {
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
// A broker that expired while idle leaves its saved endpoint behind.
const saved = loadBrokerSession(cwd)?.endpoint ?? null;
brokerEndpoint = saved && (await isBrokerEndpointReady(saved)) ? saved : null;
}
if (!brokerEndpoint && !options.reuseExistingBroker) {
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
Expand Down
26 changes: 16 additions & 10 deletions plugins/codex/scripts/lib/broker-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,18 @@ export function saveBrokerSession(cwd, session) {
}

export function clearBrokerSession(cwd) {
const stateFile = resolveBrokerStateFile(cwd);
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
// An exiting broker and a SessionEnd cleanup can both clear the same state.
fs.rmSync(resolveBrokerStateFile(cwd), { force: true });
}

export function clearBrokerSessionIfCurrent(cwd, endpoint) {
// A replacement may have published its own state since this broker started.
if (loadBrokerSession(cwd)?.endpoint === endpoint) {
clearBrokerSession(cwd);
Comment on lines +102 to +103

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 Coordinate saved-session ownership checks with deletion

When a command replaces an idle broker while it is draining, the replacement can publish its state between loadBrokerSession() and clearBrokerSession(). The exiting broker then deletes the replacement's broker.json despite the endpoint comparison. Pausing the real detached broker immediately before this removal reproduced a null saved session while the replacement remained alive. Subsequent ensureBrokerSession() calls start another app-server instead of reusing it, and the normal lazy-broker SessionEnd path loses the saved reference needed to stop the replacement. Serialize conditional deletion with state publication, or use an ownership-safe state layout.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not changing this one. The window is the gap between a synchronous loadBrokerSession and the rmSync in clearBrokerSessionIfCurrent, and it only matters when a replacement publishes inside it. The result is a lost broker.json, so the next ensureBrokerSession starts one extra broker, and the unreferenced one exits after its 60 s idle timeout. Closing the window needs an atomic take-and-restore (rename, compare, relink) or a locked state layout, which this PR dropped from scope in 5301a69. Happy to take it as a follow-up if you want it.

}
}

async function isBrokerEndpointReady(endpoint) {
export async function isBrokerEndpointReady(endpoint) {
if (!endpoint) {
return false;
}
Expand Down Expand Up @@ -179,19 +184,20 @@ export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessi
}
}

if (pidFile && fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
// A draining broker removes its own pid file and socket concurrently with a replacement.
if (pidFile) {
fs.rmSync(pidFile, { force: true });
}

if (logFile && fs.existsSync(logFile)) {
fs.unlinkSync(logFile);
if (logFile) {
fs.rmSync(logFile, { force: true });
}

if (endpoint) {
try {
const target = parseBrokerEndpoint(endpoint);
if (target.kind === "unix" && fs.existsSync(target.path)) {
fs.unlinkSync(target.path);
if (target.kind === "unix") {
fs.rmSync(target.path, { force: true });
}
} catch {
// Ignore malformed or already-removed broker endpoints during teardown.
Expand Down
Loading