Skip to content
Open
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
1 change: 1 addition & 0 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ function enqueueBackgroundTask(cwd, job, request) {
...job,
status: "queued",
phase: "queued",
background: true,
pid: child.pid ?? null,
logFile,
request
Expand Down
40 changes: 35 additions & 5 deletions plugins/codex/scripts/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@ function cleanupSessionJobs(cwd, sessionId) {
}

const state = loadState(workspaceRoot);
const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId);
if (removedJobs.length === 0) {
const sessionJobs = state.jobs.filter((job) => job.sessionId === sessionId);
if (sessionJobs.length === 0) {
return;
}

for (const job of removedJobs) {
for (const job of sessionJobs) {
// Background jobs are explicitly dispatched to outlive the session that
// started them. Leave them running and leave their state entry intact so
// any session in the workspace can still poll for status/results.
if (job.background) {
continue;
}
const stillRunning = job.status === "queued" || job.status === "running";
if (!stillRunning) {
continue;
Expand All @@ -69,10 +75,25 @@ function cleanupSessionJobs(cwd, sessionId) {

saveState(workspaceRoot, {
...state,
jobs: state.jobs.filter((job) => job.sessionId !== sessionId)
jobs: state.jobs.filter((job) => job.sessionId !== sessionId || job.background)
});
}

function hasActiveBackgroundJobs(cwd) {
if (!cwd) {
return false;
}
const workspaceRoot = resolveWorkspaceRoot(cwd);
const stateFile = resolveStateFile(workspaceRoot);
if (!fs.existsSync(stateFile)) {
return false;
}
const state = loadState(workspaceRoot);
return state.jobs.some(
(job) => job.background && (job.status === "queued" || job.status === "running")
);
}

function handleSessionStart(input) {
appendEnvVar(SESSION_ID_ENV, input.session_id);
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
Expand All @@ -95,11 +116,20 @@ async function handleSessionEnd(input) {
const sessionDir = brokerSession?.sessionDir ?? null;
const pid = brokerSession?.pid ?? null;

cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]);

// Detached background workers depend on the broker for codex app-server
// calls. If any background jobs are still active in this workspace, leave
// the broker running — a later SessionEnd (or the workers themselves) will
// tear it down when nothing depends on it anymore.
if (hasActiveBackgroundJobs(cwd)) {
return;
Comment on lines +125 to +126
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 Tear down the broker after background workers finish

When a SessionEnd occurs while a background job is still queued/running, this early return skips sendBrokerShutdown, teardownBrokerSession, and clearBrokerSession; I checked the worker path (handleTaskWorker) and the only other teardown call is broker replacement in ensureBrokerSession, so the comment’s “workers themselves” cleanup does not exist. In the common subagent case this means that after the background task completes, if no later SessionEnd runs for this workspace, the broker process/socket/temp dir and broker.json are left behind indefinitely.

Useful? React with 👍 / 👎.

}

if (brokerEndpoint) {
await sendBrokerShutdown(brokerEndpoint);
}

cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]);
teardownBrokerSession({
endpoint: brokerEndpoint,
pidFile,
Expand Down
132 changes: 132 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,138 @@ test("session end fully cleans up jobs for the ending session", async (t) => {
assert.equal(otherJob.logFile, otherSessionLog);
});

test("session end preserves background jobs and their broker so workers survive their dispatching session", async (t) => {
const repo = makeTempDir();
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const stateDir = resolveStateDir(repo);
const jobsDir = path.join(stateDir, "jobs");
fs.mkdirSync(jobsDir, { recursive: true });

const backgroundLog = path.join(jobsDir, "background.log");
const foregroundLog = path.join(jobsDir, "foreground.log");
const backgroundJobFile = path.join(jobsDir, "task-background.json");
const foregroundJobFile = path.join(jobsDir, "review-foreground.json");
fs.writeFileSync(backgroundLog, "background\n", "utf8");
fs.writeFileSync(foregroundLog, "foreground\n", "utf8");
fs.writeFileSync(backgroundJobFile, JSON.stringify({ id: "task-background" }, null, 2), "utf8");
fs.writeFileSync(foregroundJobFile, JSON.stringify({ id: "review-foreground" }, null, 2), "utf8");

const backgroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
cwd: repo,
detached: true,
stdio: "ignore"
});
backgroundSleeper.unref();
const foregroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
cwd: repo,
detached: true,
stdio: "ignore"
});
foregroundSleeper.unref();

t.after(() => {
for (const proc of [backgroundSleeper, foregroundSleeper]) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
try {
process.kill(proc.pid, "SIGTERM");
} catch {
// Ignore missing process.
}
}
}
});

fs.writeFileSync(
path.join(stateDir, "state.json"),
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs: [
{
id: "task-background",
status: "running",
title: "Codex Task",
sessionId: "sess-current",
background: true,
pid: backgroundSleeper.pid,
logFile: backgroundLog,
createdAt: "2026-03-18T15:30:00.000Z",
updatedAt: "2026-03-18T15:31:00.000Z"
},
{
id: "review-foreground",
status: "running",
title: "Codex Review",
sessionId: "sess-current",
pid: foregroundSleeper.pid,
logFile: foregroundLog,
createdAt: "2026-03-18T15:32:00.000Z",
updatedAt: "2026-03-18T15:33:00.000Z"
}
]
},
null,
2
)}\n`,
"utf8"
);

const result = run("node", [SESSION_HOOK, "SessionEnd"], {
cwd: repo,
env: {
...process.env,
CODEX_COMPANION_SESSION_ID: "sess-current"
},
input: JSON.stringify({
hook_event_name: "SessionEnd",
session_id: "sess-current",
cwd: repo
})
});

assert.equal(result.status, 0, result.stderr);

// Foreground job killed + pruned from state.
await waitFor(() => {
try {
process.kill(foregroundSleeper.pid, 0);
return false;
} catch (error) {
return error?.code === "ESRCH";
}
});

// Background job still alive — its worker outlives the session that started it.
assert.equal(
(() => {
try {
process.kill(backgroundSleeper.pid, 0);
return true;
} catch {
return false;
}
})(),
true,
"background job worker should not be terminated by SessionEnd"
);

const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
assert.deepEqual(
state.jobs.map((job) => job.id),
["task-background"],
"background job stays in state so later sessions can poll it"
);
assert.equal(fs.existsSync(backgroundJobFile), true, "background job file preserved");
assert.equal(fs.existsSync(backgroundLog), true, "background log preserved");
});

test("stop hook runs a stop-time review task and blocks on findings when the review gate is enabled", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
Expand Down