← Files Codex Process JobsARCHIVED FILE
scripts/notifier.mjs
28 KB · Sep 30, 2026 · 23:13 UTC
#!/usr/bin/env node
import fs from "node:fs";
import process from "node:process";
import { spawn } from "node:child_process";
import { isCliEntry } from "./cli-entry.mjs";
import { enqueueCodexNotification } from "./codex-queue.mjs";
import {
cliLiveInjectionEnabled,
startCliAppServerNotificationTurn,
} from "./cli-app-server-ipc.mjs";
import { startDesktopNotificationTurn } from "./desktop-ipc.mjs";
import {
RUNTIME_DISPLAY_NAME,
RUNTIME_PLUGIN_NAME,
RUNTIME_PLUGIN_VERSION,
} from "./plugin-identity.mjs";
import { COMPLETION_MODES, readPreferences } from "./preferences.mjs";
import { resolveOwnerRolloutFile, sanitizeThreadId } from "./session.mjs";
import { listJobs, nowIso, readJob, TERMINAL_STATUSES, updateJob } from "./state.mjs";
export { resolveOwnerRolloutFile } from "./session.mjs";
const DEFAULT_TURN_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_ATTEMPTS = 12;
const DEFAULT_RETRY_DELAY_MS = 5_000;
const DEFAULT_IDLE_SETTLE_MS = 1_500;
const DEFAULT_IDLE_WATCH_MS = 60 * 60_000;
const DEFAULT_IDLE_WATCH_POLL_MS = 5_000;
const MAX_LIFECYCLE_TAIL_BYTES = 8 * 1024 * 1024;
const MAX_APP_SERVER_PROTOCOL_LINE_BYTES = 1024 * 1024;
const MAX_APP_SERVER_STDERR_BYTES = 64 * 1024;
const MAX_NOTIFICATION_BATCH = 20;
const MAX_PRIVATE_IPC_FALLBACK_REASON_BYTES = 4096;
const INSPECT_SURFACES = new Set(["app", "remote", "vscode"]);
// Codex 0.149+ can render a queued completion turn in an already-open TUI.
// The queue prompt itself stays concise, while its trusted hook boundary carries
// the full inspection contract. Legacy CLI fallbacks use that same contract on
// the next visible user turn.
const HOOK_INSPECT_SURFACES = new Set(["app", "remote", "vscode", "cli"]);
const PRIVATE_IPC_SURFACES = new Set(["app", "vscode"]);
function parsePositiveInteger(value, fallback, maximum) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, maximum);
}
function resolveCompletionMode(job, env, autoInspectSurfaces) {
const override = String(env.CODEX_PROCESS_JOBS_COMPLETION_MODE ?? "").trim().toLowerCase();
if (override && !COMPLETION_MODES.has(override)) return "report";
let configured = override;
if (!configured) {
try {
configured = readPreferences(env).completionMode;
} catch {
return "report";
}
}
if (configured === "inspect" || configured === "report") return configured;
return autoInspectSurfaces.has(job.ownerSurface) ? "inspect" : "report";
}
export function completionMode(job, env = process.env) {
return resolveCompletionMode(job, env, INSPECT_SURFACES);
}
export function hookCompletionMode(job, env = process.env) {
return resolveCompletionMode(job, env, HOOK_INSPECT_SURFACES);
}
function normalizeNotificationJobs(jobOrJobs) {
const jobs = Array.isArray(jobOrJobs) ? jobOrJobs : [jobOrJobs];
if (jobs.length === 0 || jobs.length > MAX_NOTIFICATION_BATCH) {
throw new Error(`Notification batch must contain 1 to ${MAX_NOTIFICATION_BATCH} jobs.`);
}
for (const job of jobs) {
const id = String(job?.id ?? "");
if (!/^[a-z0-9][a-z0-9-]{2,80}$/.test(id)) throw new Error("Invalid job id for notification relay.");
const status = String(job.status ?? "");
if (!TERMINAL_STATUSES.has(status)) throw new Error(`Job ${id} is not terminal.`);
}
return jobs;
}
function batchInstructionKey(job, env) {
return job.goalMode ? "goal" : completionMode(job, env);
}
export function buildNotificationPrompt(jobOrJobs, env = process.env) {
const jobs = normalizeNotificationJobs(jobOrJobs);
const instructionKey = batchInstructionKey(jobs[0], env);
if (jobs.some((job) => batchInstructionKey(job, env) !== instructionKey)) {
throw new Error("Notification batch mixes incompatible completion instructions.");
}
const lines = jobs.map((job) => {
const outcome = job.status === "completed" ? "finished successfully" : `finished with status ${job.status}`;
const exitCode = Number.isInteger(job.exitCode) ? ` with exit code ${job.exitCode}` : "";
return `\`${job.id}\` ${outcome}${exitCode}.`;
});
if (jobs.length === 1) return `CPJ background job ${lines[0]}`;
return ["CPJ background jobs finished.", ...lines].join("\n");
}
export function buildNotificationInput(jobOrJobs, env = process.env) {
const jobs = normalizeNotificationJobs(jobOrJobs);
return [{ type: "text", text: buildNotificationPrompt(jobs, env) }];
}
export function readLatestTaskLifecycle(file) {
const stat = fs.statSync(file);
const length = Math.min(stat.size, MAX_LIFECYCLE_TAIL_BYTES);
if (length === 0) return null;
const buffer = Buffer.alloc(length);
const descriptor = fs.openSync(file, "r");
try {
fs.readSync(descriptor, buffer, 0, length, stat.size - length);
} finally {
fs.closeSync(descriptor);
}
const lines = buffer.toString("utf8").split("\n");
if (stat.size > length) lines.shift();
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index].trim();
if (!line) continue;
try {
const event = JSON.parse(line);
const lifecycle = event?.type === "event_msg" ? event?.payload?.type : null;
if (lifecycle === "task_started" || lifecycle === "task_complete") {
return {
type: lifecycle,
turnId: event.payload?.turn_id ?? null,
timestamp: event.timestamp ?? null,
};
}
} catch {}
}
return null;
}
export async function waitForOwnerIdle(job, env = process.env) {
if (env.CODEX_PROCESS_JOBS_SKIP_SESSION_IDLE_CHECK === "1") {
return { idle: true, reason: "session lifecycle check disabled for isolated testing" };
}
const rolloutFile = resolveOwnerRolloutFile(job.ownerThreadId, env);
if (!rolloutFile) return { idle: false, reason: "owning Codex session transcript was not found" };
const first = readLatestTaskLifecycle(rolloutFile);
if (first?.type !== "task_complete") {
return { idle: false, reason: `latest owning-thread lifecycle is ${first?.type ?? "unknown"}` };
}
const settleMs = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_IDLE_SETTLE_MS,
DEFAULT_IDLE_SETTLE_MS,
30_000
);
await new Promise((resolve) => setTimeout(resolve, settleMs));
const second = readLatestTaskLifecycle(rolloutFile);
if (second?.type !== "task_complete" || second.turnId !== first.turnId) {
return { idle: false, reason: `owning Codex thread changed during the ${settleMs}ms idle-settle window` };
}
return {
idle: true,
reason: `owning Codex thread is settled after ${second.turnId ?? "its latest turn"}`,
turnId: second.turnId ?? null,
};
}
function relayError(message, { accepted = false, retryWhenIdle = false } = {}) {
const error = new Error(message);
error.turnAccepted = accepted;
error.retryWhenIdle = retryWhenIdle;
return error;
}
function boundedIpcFallbackReason(value) {
const message = value instanceof Error ? value.message : String(value ?? "");
if (!message) return null;
return Buffer.from(message, "utf8")
.subarray(0, MAX_PRIVATE_IPC_FALLBACK_REASON_BYTES)
.toString("utf8");
}
function attachDeliveryDiagnostics(error, diagnostics) {
if (!error || typeof error !== "object") return error;
for (const [key, value] of Object.entries(diagnostics)) {
if (value) error[key] = value;
}
return error;
}
export async function waitForNotificationTurnComplete(
job,
turnId,
timeoutMs,
env = process.env,
transport = "desktop-ipc",
) {
const rolloutFile = resolveOwnerRolloutFile(job.ownerThreadId, env);
if (!rolloutFile) throw relayError("Owning Codex session transcript was not found after private IPC accepted the turn.", { accepted: true });
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const lifecycle = readLatestTaskLifecycle(rolloutFile);
if (lifecycle?.type === "task_complete" && lifecycle.turnId === turnId) {
return { threadId: sanitizeThreadId(job.ownerThreadId), turnId, status: "completed", transport };
}
await delay(100);
}
throw relayError(`Private IPC notification turn timed out after ${timeoutMs}ms (turn ${turnId}).`, { accepted: true });
}
async function deliverAppServerNotificationTurn(job, threadId, input, timeoutMs, env) {
const codex = env.CODEX_PROCESS_JOBS_CODEX_BIN || "codex";
return await new Promise((resolve, reject) => {
const child = spawn(codex, ["app-server"], {
cwd: job.cwd,
env: { ...env, CODEX_PROCESS_JOBS_NOTIFICATION_RELAY: "1" },
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
let settled = false;
let accepted = false;
let turnId = null;
const stdoutLine = Buffer.allocUnsafe(MAX_APP_SERVER_PROTOCOL_LINE_BYTES);
let stdoutLineLength = 0;
const stderrBuffer = Buffer.allocUnsafe(MAX_APP_SERVER_STDERR_BYTES);
let stderrLength = 0;
const timeout = setTimeout(() => {
finish(reject, relayError(
`Notification turn timed out after ${timeoutMs}ms${turnId ? ` (turn ${turnId})` : ""}.`,
{ accepted }
));
}, timeoutMs);
function send(message) {
child.stdin.write(`${JSON.stringify(message)}\n`);
}
function cleanup() {
clearTimeout(timeout);
child.stdout.off("data", onStdoutData);
child.stderr.off("data", onStderrData);
child.stdin.end();
if (child.exitCode == null && child.signalCode == null) {
child.kill("SIGTERM");
const forceKill = setTimeout(() => {
if (child.exitCode == null && child.signalCode == null) child.kill("SIGKILL");
}, 1_000);
forceKill.unref();
child.once("exit", () => clearTimeout(forceKill));
}
}
function finish(handler, value) {
if (settled) return;
settled = true;
cleanup();
handler(value);
}
function onStderrData(chunk) {
if (stderrLength + chunk.length > MAX_APP_SERVER_STDERR_BYTES) {
finish(reject, relayError(
`Codex app-server stderr exceeded ${MAX_APP_SERVER_STDERR_BYTES} bytes.`,
{ accepted },
));
return;
}
chunk.copy(stderrBuffer, stderrLength);
stderrLength += chunk.length;
}
function handleStdoutLine(line) {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.id === 1) {
if (message.error) {
finish(reject, relayError(`Codex app-server initialize failed: ${JSON.stringify(message.error)}`));
return;
}
send({ method: "initialized", params: {} });
send({ id: 2, method: "thread/resume", params: { threadId } });
return;
}
if (message.id === 2) {
if (message.error) {
const serialized = JSON.stringify(message.error);
finish(reject, relayError(
`Unable to resume owning Codex thread: ${serialized}`,
{ retryWhenIdle: /already has an active writer/i.test(serialized) },
));
return;
}
const threadStatus = message.result?.thread?.status?.type ?? "unknown";
if (threadStatus !== "idle") {
finish(reject, relayError(`Owning Codex thread is ${threadStatus}; notification will retry when it is idle.`, { retryWhenIdle: true }));
return;
}
send({
id: 3,
method: "turn/start",
params: {
threadId,
input,
},
});
return;
}
if (message.id === 3) {
if (message.error) {
finish(reject, relayError(`Unable to start notification turn: ${JSON.stringify(message.error)}`));
return;
}
accepted = true;
turnId = message.result?.turn?.id ?? null;
return;
}
if (message.method === "turn/completed" && message.params?.threadId === threadId) {
const turn = message.params?.turn ?? {};
if (!accepted || turnId == null || turn.id !== turnId) return;
if (turn.status === "completed") {
finish(resolve, { threadId, turnId, status: turn.status, transport: "app-server" });
} else {
finish(reject, relayError(
`Notification turn ended with status ${turn.status ?? "unknown"}.`,
{ accepted: true }
));
}
}
}
function onStdoutData(chunk) {
let offset = 0;
while (!settled && offset < chunk.length) {
const newline = chunk.indexOf(0x0a, offset);
const end = newline === -1 ? chunk.length : newline;
const segmentLength = end - offset;
if (stdoutLineLength + segmentLength > MAX_APP_SERVER_PROTOCOL_LINE_BYTES) {
finish(reject, relayError(
`Codex app-server protocol line exceeded ${MAX_APP_SERVER_PROTOCOL_LINE_BYTES} bytes.`,
{ accepted },
));
return;
}
chunk.copy(stdoutLine, stdoutLineLength, offset, end);
stdoutLineLength += segmentLength;
if (newline === -1) return;
handleStdoutLine(stdoutLine.subarray(0, stdoutLineLength).toString("utf8"));
stdoutLineLength = 0;
offset = newline + 1;
}
}
child.stderr.on("data", onStderrData);
child.stdout.on("data", onStdoutData);
child.on("error", (error) => {
finish(reject, relayError(`Unable to start Codex app-server: ${error.message}`, { accepted }));
});
child.on("exit", (code, signal) => {
if (settled) return;
const stderr = stderrBuffer.subarray(0, stderrLength).toString("utf8").trim();
const detail = stderr ? `: ${stderr}` : "";
finish(reject, relayError(`Codex app-server exited early (code=${code}, signal=${signal})${detail}`, { accepted }));
});
child.stdin.on("error", (error) => {
finish(reject, relayError(`Unable to write to Codex app-server: ${error.message}`, { accepted }));
});
send({
id: 1,
method: "initialize",
params: {
clientInfo: {
name: RUNTIME_PLUGIN_NAME,
title: RUNTIME_DISPLAY_NAME,
version: RUNTIME_PLUGIN_VERSION,
},
capabilities: { experimentalApi: true },
},
});
});
}
export async function deliverNotificationTurn(jobOrJobs, env = process.env) {
const jobs = normalizeNotificationJobs(jobOrJobs);
const job = jobs[0];
const threadId = sanitizeThreadId(job.ownerThreadId);
const input = buildNotificationInput(jobs, env);
const timeoutMs = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_TURN_TIMEOUT_MS,
DEFAULT_TURN_TIMEOUT_MS,
10 * 60_000
);
let codexQueueFallbackReason = null;
async function tryCodexQueue(forceQueueMode = false) {
try {
const queued = await enqueueCodexNotification(input, threadId, timeoutMs, env, {
forceQueueMode,
onUnavailable: (reason) => {
codexQueueFallbackReason = boundedIpcFallbackReason(reason);
},
});
return queued;
} catch (error) {
if (error?.turnAccepted) throw error;
codexQueueFallbackReason = boundedIpcFallbackReason(error);
return null;
}
}
if (job.ownerSurface === "cli") {
const queued = await tryCodexQueue();
if (queued) return queued;
}
const idle = await waitForOwnerIdle(job, env);
if (!idle.idle) {
throw attachDeliveryDiagnostics(
relayError(`Owning Codex thread is not safely idle: ${idle.reason}.`, { retryWhenIdle: true }),
{ codexQueueFallbackReason },
);
}
if (PRIVATE_IPC_SURFACES.has(job.ownerSurface)) {
const queued = await tryCodexQueue(true);
if (queued) return queued;
}
let cliLiveInjectionFallbackReason = null;
if (job.ownerSurface === "cli" && cliLiveInjectionEnabled(env)) {
try {
const accepted = await startCliAppServerNotificationTurn(
job,
input,
threadId,
timeoutMs,
env,
{
onUnavailable: (reason) => {
cliLiveInjectionFallbackReason = boundedIpcFallbackReason(reason);
},
beforeStart: async () => {
const rolloutFile = resolveOwnerRolloutFile(job.ownerThreadId, env);
const latest = rolloutFile ? readLatestTaskLifecycle(rolloutFile) : null;
if (latest?.type !== "task_complete" || latest.turnId !== idle.turnId) {
throw relayError(
"Owning Codex thread changed before CLI live injection could start.",
{ retryWhenIdle: true },
);
}
},
},
);
if (accepted) {
return await waitForNotificationTurnComplete(
job,
accepted.turnId,
timeoutMs,
env,
accepted.transport,
);
}
} catch (error) {
if (error?.turnAccepted || error?.retryWhenIdle) {
throw attachDeliveryDiagnostics(error, { codexQueueFallbackReason });
}
cliLiveInjectionFallbackReason = boundedIpcFallbackReason(error);
}
}
let privateIpcFallbackReason = null;
try {
const accepted = await startDesktopNotificationTurn(job, input, threadId, timeoutMs, env, {
onUnavailable: (reason) => {
if (PRIVATE_IPC_SURFACES.has(job.ownerSurface)) {
privateIpcFallbackReason = boundedIpcFallbackReason(reason);
}
},
beforeStart: async () => {
const rolloutFile = resolveOwnerRolloutFile(job.ownerThreadId, env);
const latest = rolloutFile ? readLatestTaskLifecycle(rolloutFile) : null;
if (
latest?.type !== "task_complete"
|| latest.turnId !== idle.turnId
) {
throw relayError("Owning Codex thread changed before private IPC delivery could start.", { retryWhenIdle: true });
}
},
});
if (accepted) {
return await waitForNotificationTurnComplete(
job,
accepted.turnId,
timeoutMs,
env,
accepted.transport,
);
}
} catch (error) {
if (error?.turnAccepted || error?.retryWhenIdle) {
throw attachDeliveryDiagnostics(error, {
codexQueueFallbackReason,
cliLiveInjectionFallbackReason,
});
}
privateIpcFallbackReason = boundedIpcFallbackReason(error);
}
try {
const delivered = await deliverAppServerNotificationTurn(job, threadId, input, timeoutMs, env);
return {
...delivered,
...(codexQueueFallbackReason ? { codexQueueFallbackReason } : {}),
...(privateIpcFallbackReason ? { privateIpcFallbackReason } : {}),
...(cliLiveInjectionFallbackReason ? { cliLiveInjectionFallbackReason } : {}),
};
} catch (error) {
throw attachDeliveryDiagnostics(error, {
codexQueueFallbackReason,
privateIpcFallbackReason,
cliLiveInjectionFallbackReason,
});
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function notificationSuppressed(job) {
return Boolean(job.resultViewedAt) || [
"disabled",
"unavailable",
"suppressed",
"accepted",
"delivered",
"fallback_notified",
].includes(job.notification?.status);
}
function relayInFlight(job) {
return job.notification?.status === "delivering";
}
export async function runNotifier(jobId, env = process.env) {
let job = readJob(jobId, env);
if (!TERMINAL_STATUSES.has(job.status)) throw new Error(`Job ${job.id} is not terminal.`);
if (notificationSuppressed(job) || relayInFlight(job)) return job;
if (!job.ownerThreadId) {
return await updateJob(job.id, (current) => ({
...current,
notification: {
...(current.notification ?? {}),
status: "unavailable",
errorMessage: "No owning persistent Codex thread id was captured.",
},
}), env);
}
const maxAttempts = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
100
);
const retryDelayMs = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_RETRY_DELAY_MS,
DEFAULT_RETRY_DELAY_MS,
60_000
);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const outcome = await attemptNotificationBatch(job.id, attempt, {
retryOnFailure: attempt < maxAttempts,
watchOnBusy: attempt === maxAttempts,
}, env);
if (outcome.done) return readJob(job.id, env);
if (outcome.watch) {
const watched = await waitForOwnerIdleWatch(job.id, env);
if (!watched.ready) {
await markNotificationBatchFailed(outcome.batchIds, outcome.message || watched.reason, env);
return readJob(job.id, env);
}
await attemptNotificationBatch(job.id, maxAttempts + 1, {
retryOnFailure: false,
watchOnBusy: false,
}, env);
await markNotificationBatchFailed(
outcome.batchIds,
"Idle-watch final delivery did not retain every original batch claim; hook fallback remains available.",
env,
);
return readJob(job.id, env);
}
await delay(Math.min(30_000, retryDelayMs * attempt));
}
return readJob(job.id, env);
}
async function claimNotificationBatch(jobId, attempt, env) {
const seed = readJob(jobId, env);
const instructionKey = batchInstructionKey(seed, env);
const batchId = `${jobId}:${attempt}:${Date.now().toString(36)}`;
const claimed = [];
async function claim(candidateId, { seedJob = false } = {}) {
let didClaim = false;
const updated = await updateJob(candidateId, (current) => {
const allowedStatus = seedJob
? ["pending", "failed"].includes(current.notification?.status)
: current.notification?.status === "pending";
if (
!allowedStatus
|| !TERMINAL_STATUSES.has(current.status)
|| current.ownerThreadId !== seed.ownerThreadId
|| batchInstructionKey(current, env) !== instructionKey
|| notificationSuppressed(current)
|| relayInFlight(current)
) return current;
didClaim = true;
return {
...current,
notification: {
...(current.notification ?? {}),
status: "delivering",
attempts: attempt,
deliveryBatchId: batchId,
lastAttemptAt: nowIso(),
errorMessage: null,
},
};
}, env);
if (didClaim) claimed.push(updated);
}
await claim(seed.id, { seedJob: true });
if (claimed.length === 0) return { batchId, jobs: [] };
const siblings = listJobs(env)
.filter((candidate) => candidate.id !== seed.id)
.filter((candidate) => candidate.ownerThreadId === seed.ownerThreadId)
.filter((candidate) => TERMINAL_STATUSES.has(candidate.status))
.filter((candidate) => candidate.notification?.status === "pending")
.filter((candidate) => batchInstructionKey(candidate, env) === instructionKey)
.slice(0, MAX_NOTIFICATION_BATCH - 1);
for (const sibling of siblings) {
try {
await claim(sibling.id);
} catch {}
}
return { batchId, jobs: claimed };
}
async function finalizeNotificationBatch(batchId, jobs, mutateNotification, env) {
for (const job of jobs) {
try {
await updateJob(job.id, (current) => {
if (
current.notification?.status !== "delivering"
|| current.notification?.deliveryBatchId !== batchId
) return current;
const notification = mutateNotification(current.notification);
delete notification.deliveryBatchId;
return { ...current, notification };
}, env);
} catch {}
}
}
async function attemptNotificationBatch(jobId, attempt, options, env) {
const claimed = await claimNotificationBatch(jobId, attempt, env);
if (claimed.jobs.length === 0) return { done: true, watch: false, batchIds: [] };
try {
const delivered = await deliverNotificationTurn(claimed.jobs, env);
const accepted = delivered.status === "accepted";
await finalizeNotificationBatch(claimed.batchId, claimed.jobs, (notification) => ({
...notification,
status: accepted ? "accepted" : "delivered",
acceptedAt: accepted ? nowIso() : notification.acceptedAt ?? null,
deliveredAt: accepted ? notification.deliveredAt ?? null : nowIso(),
threadId: delivered.threadId,
turnId: delivered.turnId,
transport: delivered.transport,
codexQueueFallbackReason: delivered.codexQueueFallbackReason ?? null,
privateIpcFallbackReason: delivered.privateIpcFallbackReason ?? null,
cliLiveInjectionFallbackReason: delivered.cliLiveInjectionFallbackReason ?? null,
relayPid: null,
errorMessage: null,
}), env);
return { done: true, watch: false, batchIds: claimed.jobs.map((item) => item.id) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const accepted = Boolean(error?.turnAccepted);
const watch = !accepted && options.watchOnBusy && Boolean(error?.retryWhenIdle);
const retry = !accepted && (options.retryOnFailure || watch);
await finalizeNotificationBatch(claimed.batchId, claimed.jobs, (notification) => ({
...notification,
status: accepted ? "accepted" : retry ? "pending" : "failed",
acceptedAt: accepted ? nowIso() : notification.acceptedAt ?? null,
threadId: error?.threadId ?? notification.threadId ?? null,
transport: error?.transport ?? notification.transport ?? null,
codexQueueFallbackReason: error?.codexQueueFallbackReason ?? notification.codexQueueFallbackReason ?? null,
privateIpcFallbackReason: error?.privateIpcFallbackReason ?? notification.privateIpcFallbackReason ?? null,
cliLiveInjectionFallbackReason: error?.cliLiveInjectionFallbackReason ?? notification.cliLiveInjectionFallbackReason ?? null,
errorMessage: message,
failedAt: !accepted && !retry ? nowIso() : null,
idleWatchStartedAt: watch ? nowIso() : notification.idleWatchStartedAt ?? null,
relayPid: accepted || !retry ? null : notification.relayPid ?? null,
}), env);
const current = readJob(jobId, env);
if (notificationSuppressed(current) || relayInFlight(current)) {
return { done: true, watch: false, batchIds: claimed.jobs.map((item) => item.id), message };
}
return {
done: accepted || !retry,
watch,
batchIds: claimed.jobs.map((item) => item.id),
message,
};
}
}
export async function waitForOwnerIdleWatch(jobId, env = process.env) {
const watchMs = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_IDLE_WATCH_MS,
DEFAULT_IDLE_WATCH_MS,
24 * 60 * 60_000
);
const pollMs = parsePositiveInteger(
env.CODEX_PROCESS_JOBS_NOTIFY_IDLE_WATCH_POLL_MS,
DEFAULT_IDLE_WATCH_POLL_MS,
60_000
);
const deadline = Date.now() + watchMs;
while (Date.now() < deadline) {
const job = readJob(jobId, env);
if (notificationSuppressed(job) || relayInFlight(job) || job.notification?.status !== "pending") {
return { ready: false, reason: `notification became ${job.notification?.status ?? "unavailable"}` };
}
if (env.CODEX_PROCESS_JOBS_SKIP_SESSION_IDLE_CHECK === "1") return { ready: true, reason: "idle check disabled" };
const rolloutFile = resolveOwnerRolloutFile(job.ownerThreadId, env);
const lifecycle = rolloutFile ? readLatestTaskLifecycle(rolloutFile) : null;
if (lifecycle?.type === "task_complete") return { ready: true, reason: "owning thread reached an idle boundary" };
await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())));
}
return { ready: false, reason: `idle-watch ceiling expired after ${watchMs}ms` };
}
async function markNotificationBatchFailed(jobIds, message, env) {
for (const id of jobIds) {
try {
await updateJob(id, (current) => {
if (current.notification?.status !== "pending") return current;
return {
...current,
notification: {
...(current.notification ?? {}),
status: "failed",
failedAt: nowIso(),
relayPid: null,
errorMessage: message,
},
};
}, env);
} catch {}
}
}
function parseJobId(argv) {
const index = argv.indexOf("--job-id");
if (index < 0 || !argv[index + 1]) throw new Error("notifier requires --job-id <id>");
return argv[index + 1];
}
async function main() {
await runNotifier(parseJobId(process.argv.slice(2)));
}
if (isCliEntry(import.meta.url)) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});
}
SHA-256: e37799b2b91fd83f1b31f746665f520fed58b5cde95ec95ede270535773654a4