How Job Status Tracking and Polling Works in the OpenAI Codex Plugin
The Codex plugin maintains a persistent job registry using JSON state files, enabling synchronous status checks via buildSingleJobSnapshot and asynchronous polling through waitForSingleJobSnapshot until jobs exit active states like "running" or "queued".
The openai/codex-plugin-cc repository implements a file-based job status tracking and polling system that persists execution metadata to disk. This architecture allows the CLI to query job states synchronously or wait asynchronously for completion while maintaining a chronological history of all background operations managed through state.json and individual job files.
The Persistent Job Registry
The system keeps a persistent job registry inside a temporary state directory. Each initiated job receives a JSON job file (e.g., job‑123.json) and a parallel log file (job‑123.log) that capture metadata and real-time output.
Job Files and State Management
Global job state lives in state.json and is managed by plugins/codex/scripts/lib/state.mjs. The upsertJob function guarantees that the job list remains in chronological order and automatically trims entries to the most recent MAX_JOBS limit:
// plugins/codex/scripts/lib/state.mjs
export function upsertJob(cwd, jobPatch) {
return updateState(cwd, (state) => {
const timestamp = nowIso();
const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
if (existingIndex === -1) {
state.jobs.unshift({ createdAt: timestamp, updatedAt: timestamp, ...jobPatch });
} else {
state.jobs[existingIndex] = { ...state.jobs[existingIndex], ...jobPatch, updatedAt: timestamp };
}
});
}
Real-time Progress Updates
During execution, createJobProgressUpdater (from plugins/codex/scripts/lib/tracked-jobs.mjs) calls upsertJob whenever the phase, thread ID, or turn ID changes. This merges patches into the in-memory state and persists the updated jobs array to disk:
// plugins/codex/scripts/lib/tracked-jobs.mjs
export function createJobProgressUpdater(workspaceRoot, jobId) {
// ...
upsertJob(workspaceRoot, patch); // ← updates the global job list
// ...
writeJobFile(workspaceRoot, jobId, {
...storedJob,
...patch
});
}
How the Status Command Works
The status sub-command reads the registry through buildSingleJobSnapshot defined in plugins/codex/scripts/lib/job-control.mjs. When a user requests a specific job (codex status <job-id>), the command constructs a snapshot containing the enriched job record and latest log entries.
// plugins/codex/scripts/codex-companion.mjs
async function handleStatus(argv) {
// ...
const snapshot = options.wait
? await waitForSingleJobSnapshot(cwd, reference, { ... })
: buildSingleJobSnapshot(cwd, reference);
// ...
}
Polling with the --wait Flag
When --wait is supplied, waitForSingleJobSnapshot repeatedly polls until the job leaves an active state or a timeout expires. It uses DEFAULT_STATUS_POLL_INTERVAL_MS (2 seconds) and DEFAULT_STATUS_WAIT_TIMEOUT_MS defaults, configurable via CLI flags:
// plugins/codex/scripts/lib/job-control.mjs
async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
const deadline = Date.now() + timeoutMs;
let snapshot = buildSingleJobSnapshot(cwd, reference);
while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) {
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
snapshot = buildSingleJobSnapshot(cwd, reference);
}
return {
...snapshot,
waitTimedOut: isActiveJobStatus(snapshot.job.status),
timeoutMs
};
}
The helper isActiveJobStatus returns true for "queued" or "running" states, signaling the poll loop to continue.
Job Snapshot Enrichment
Raw job records are augmented by enrichJob in plugins/codex/scripts/lib/job-control.mjs, which adds computed fields for display:
elapsed– time from start to current moment (or completion)duration– total runtime for finished jobsprogressPreview– the last few non-metadata log lines read from the log filephase– inferred from legacy status if not explicitly set
// plugins/codex/scripts/lib/job-control.mjs
export function enrichJob(job, options = {}) {
const enriched = {
...job,
kindLabel: getJobTypeLabel(job),
progressPreview: job.status === "queued" || job.status === "running" || job.status === "failed"
? readJobProgressPreview(job.logFile, maxProgressLines)
: [],
elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
// ...
};
return { ...enriched, phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview) };
}
Practical Examples
Starting a Tracked Job
codex task --background "Fix typo in README"
The companion script creates a job record via createCompanionJob → createJobRecord, initializes the log file, and spawns a detached worker. The worker updates status through runTrackedJob → createJobProgressUpdater.
Checking Current Status
codex status 2023-07-04-abc123
Returns a table displaying ID, kind, status, phase, elapsed time, and summary, built from the enriched snapshot.
Polling Until Completion
codex status 2023-07-04-abc123 --wait --timeout-ms 120000
Invokes waitForSingleJobSnapshot, polling every 2 seconds until the job finishes or the 2-minute timeout elapses. The command exits with waitTimedOut: true if the deadline is reached while the job remains active.
Programmatic API Usage
import { runTrackedJob } from "./plugins/codex/scripts/lib/tracked-jobs.mjs";
import { upsertJob } from "./plugins/codex/scripts/lib/state.mjs";
const job = {
id: "job-xyz",
title: "My task",
workspaceRoot: "/tmp/state",
// ...
};
await runTrackedJob(job, async () => {
// Your long-running work here
await upsertJob(job.workspaceRoot, { id: job.id, phase: "processing" });
return { exitStatus: 0, payload: {}, rendered: "Done!" };
});
Inside the runner, manual calls to upsertJob push intermediate status updates to the registry.
Key Implementation Files
plugins/codex/scripts/lib/tracked-jobs.mjs– Creates log files, progress reporters, and therunTrackedJobwrapper that manages status changes during execution.plugins/codex/scripts/lib/state.mjs– Persists the global job list tostate.json, implementsupsertJob,writeJobFile, and pruning logic.plugins/codex/scripts/lib/job-control.mjs– Builds enriched snapshots, infers job phases, and provides thewaitForSingleJobSnapshotpolling helper.plugins/codex/scripts/codex-companion.mjs– Entry point for sub-commands; handlesstatus,--waitflag processing, and output rendering.
Summary
- Persistent storage – Jobs are tracked via individual JSON files and a centralized
state.jsonthat survives process restarts. - Real-time updates –
createJobProgressUpdaterandupsertJobensure atomic updates to job status, phase, and timestamps. - Flexible querying –
buildSingleJobSnapshotprovides synchronous status checks, whilewaitForSingleJobSnapshotenables long-running polling with configurable timeouts. - Rich metadata – The
enrichJobfunction augments raw records with elapsed time, duration, and log previews for human-readable output. - Active state detection – Polling continues while
isActiveJobStatusreturns true for "queued" or "running" states.
Frequently Asked Questions
How does the Codex plugin store job status information?
The plugin persists job data to a temporary state directory on disk. Each job maintains a dedicated JSON file (e.g., job-123.json) and a log file (job-123.log), while a global state.json tracks the chronological list of all jobs. The upsertJob function in state.mjs manages atomic updates to this registry, ensuring data consistency across CLI invocations.
What is the default polling interval when using the --wait flag?
The default polling interval is 2 seconds (DEFAULT_STATUS_POLL_INTERVAL_MS), with a minimum enforced value of 100 milliseconds. The polling loop in waitForSingleJobSnapshot checks the current time against a deadline calculated from the --timeout-ms parameter (defaulting to DEFAULT_STATUS_WAIT_TIMEOUT_MS) and sleeps for the lesser of the interval or remaining time.
How does the system handle job history pruning?
As implemented in plugins/codex/scripts/lib/state.mjs, the upsertJob function maintains the jobs array in reverse chronological order (newest first) and automatically trims entries beyond the MAX_JOBS limit. This prevents unbounded growth of the state file while preserving recent job history for status queries.
Can I track job progress programmatically from Node.js?
Yes. Import runTrackedJob from tracked-jobs.mjs to wrap asynchronous work, which automatically handles status transitions. For manual updates, import upsertJob from state.mjs to push incremental patches (such as phase changes or progress indicators) to the persistent registry, enabling external monitoring tools to poll for updates via the standard status API.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →