How the OpenAI Codex Companion Plugin Handles Job State Persistence Across Background Executions
The Codex Companion plugin persists job state using a deterministic, file-based state directory with JSON job records and log files that survive process restarts and background runs.
The openai/codex-plugin-cc repository implements a robust persistence layer so that long-running or background jobs retain their status, output, and metadata even when the host process terminates. This article breaks down the exact mechanisms used to achieve job state persistence across background executions in the Codex plugin architecture.
State Directory Resolution and Isolation
Every workspace gets an isolated state location through the resolveStateDir() function in plugins/codex/scripts/lib/state.mjs. The implementation:
- Generates a deterministic directory name from the workspace root using a slug plus hash
- Prioritizes the
CLAUDE_PLUGIN_DATAenvironment variable when available - Falls back to
os.tmpdir()/codex-companionfor ephemeral environments
This ensures that container restarts or process crashes don't lose state when CLAUDE_PLUGIN_DATA points to a mounted volume.
// From plugins/codex/scripts/lib/state.mjs (lines 29-44)
import { createHash } from "crypto";
import { tmpdir } from "os";
import { join } from "path";
export function resolveStateDir(workspaceRoot) {
const slug = workspaceRoot.replace(/[^a-z0-9]/gi, "-").toLowerCase();
const hash = createHash("sha256")
.update(workspaceRoot)
.digest("hex")
.slice(0, 12);
const baseDir = process.env.CLAUDE_PLUGIN_DATA || join(tmpdir(), "codex-companion");
return join(baseDir, `${slug}-${hash}`);
}
File Layout: State Schema and Job Records
The state directory follows a strict hierarchy defined in state.mjs (lines 10-14):
| Path | Purpose |
|---|---|
state.json |
Overall plugin state, config, and job index |
jobs/<jobId>.json |
Individual job metadata and execution results |
jobs/<jobId>.log |
Optional streaming log output |
This separation allows quick scanning of all jobs via state.json while keeping large log data in separate files.
Loading and Saving State With Automatic Pruning
The loadState(cwd) and saveState(cwd, state) functions handle persistence semantics in plugins/codex/scripts/lib/state.mjs.
Load operation (lines 58-75):
- Reads
state.jsonif present - Falls back to a default schema with empty
jobsarray - Merges persisted configuration with runtime defaults
Save operation (lines 92-115):
- Prunes job list to most recent 50 jobs (
MAX_JOBS) - Writes atomically to
state.json - Cleans orphaned
<jobId>.jsonand<jobId>.logfiles no longer referenced
// Pruning logic from saveState (state.mjs, lines 98-105)
const MAX_JOBS = 50;
if (state.jobs.length > MAX_JOBS) {
const toRemove = state.jobs.slice(0, state.jobs.length - MAX_JOBS);
for (const job of toRemove) {
await rm(join(jobsDir, `${job.id}.json`), { force: true });
await rm(join(jobsDir, `${job.id}.log`), { force: true });
}
state.jobs = state.jobs.slice(-MAX_JOBS);
}
Job Lifecycle Tracking With Immediate Persistence
Two key functions ensure state consistency throughout execution:
upsertJob() — Atomic State Updates
Located in state.mjs (lines 29-47), this merges partial job data into the in-memory list and immediately calls saveState(). Every status change hits disk synchronously.
runTrackedJob() — Wrapped Execution With Automatic Persistence
Implemented in plugins/codex/scripts/lib/tracked-jobs.mjs (lines 42-79), this higher-level API:
- Writes a "running" record before invoking the runner function
- Captures the execution result (exit status, payload, rendered output)
- Updates the job JSON file and master state list
- Appends final output to the dedicated log file
// Usage pattern from tracked-jobs.mjs
export async function runTrackedJob(job, runner, options = {}) {
const { logFile } = options;
// Mark as running
await upsertJob(job.workspaceRoot, {
id: job.id,
status: "running",
startedAt: new Date().toISOString()
});
let result;
try {
result = await runner();
} catch (error) {
// Persist failure state
await upsertJob(job.workspaceRoot, {
id: job.id,
status: "failed",
error: error.message,
completedAt: new Date().toISOString()
});
throw error;
}
// Persist success with full result payload
await upsertJob(job.workspaceRoot, {
id: job.id,
status: "completed",
result,
completedAt: new Date().toISOString()
});
if (logFile) {
await appendLogBlock(logFile, result.rendered);
}
return result;
}
Log File Handling for Background Output
The createJobLogFile(), appendLogLine(), and appendLogBlock() utilities in tracked-jobs.mjs (lines 51-59) provide streaming log persistence:
- Log files are created empty with a starter timestamp line
- Progress updates append incrementally during execution
- Final rendered output is written as a structured block
This design ensures that even if the process crashes mid-execution, partial logs survive and are inspectable on restart.
Environment-Driven Persistence Configuration
The plugin supports two deployment modes via environment variables:
| Variable | Behavior |
|---|---|
CLAUDE_PLUGIN_DATA set |
State placed under this path; survives container restarts |
CLAUDE_PLUGIN_DATA unset |
State in os.tmpdir(); may be cleaned by OS |
Production deployments should always define CLAUDE_PLUGIN_DATA pointing to a persistent volume mount.
Complete Working Example
// Full workflow demonstrating persistence across restarts
import {
generateJobId,
upsertJob,
loadState,
resolveStateDir
} from "./plugins/codex/scripts/lib/state.mjs";
import {
runTrackedJob,
createJobLogFile
} from "./plugins/codex/scripts/lib/tracked-jobs.mjs";
const cwd = process.cwd();
// Step 1: Create and queue a job
const jobId = generateJobId();
await upsertJob(cwd, {
id: jobId,
title: "Background analysis",
status: "queued"
});
// Step 2: Prepare log file
const logFile = createJobLogFile(cwd, jobId, "Analysis");
// Step 3: Execute with automatic persistence
const job = {
id: jobId,
workspaceRoot: cwd,
title: "Background analysis",
logFile
};
async function analysisRunner() {
// Simulate long-running work
return {
exitStatus: 0,
payload: { filesAnalyzed: 42 },
rendered: "Analysis complete: 42 files processed",
threadId: null,
turnId: null,
summary: "Analysis succeeded"
};
}
await runTrackedJob(job, analysisRunner, { logFile });
// Step 4: Simulate process restart — reload state
const recovered = loadState(cwd);
const myJob = recovered.jobs.find(j => j.id === jobId);
console.log(myJob.status); // "completed"
console.log(myJob.result.summary); // "Analysis succeeded"
Summary
- Deterministic paths via
resolveStateDir()isolate workspace state and support environment-driven persistence - JSON-based records in
state.jsonandjobs/<jobId>.jsonprovide structured, queryable job metadata - Automatic pruning to 50 jobs prevents unbounded disk growth while cleaning orphaned files
- Synchronous persistence on every status change via
upsertJob()guarantees consistency - Wrapped execution in
runTrackedJob()handles lifecycle transitions and log coordination - Environment variable
CLAUDE_PLUGIN_DATAcontrols whether state survives container restarts
Frequently Asked Questions
How does the Codex plugin ensure job state survives a process crash?
The plugin calls saveState() synchronously after every status change via upsertJob(). Before any runner function executes, runTrackedJob() writes a "running" record. If the process crashes, the on-disk state reflects the last completed operation, and logs up to that point remain in jobs/<jobId>.log.
What happens when the job limit of 50 is exceeded?
The saveState() function automatically prunes the oldest jobs from state.json and deletes their corresponding .json and .log files from the jobs/ subdirectory. This keeps storage bounded while preserving the most recent activity.
Can I configure where job state is stored?
Yes. Set the CLAUDE_PLUGIN_DATA environment variable to any directory path. The plugin will place all state files under that location instead of the temporary directory, making data persistent across container restarts and system reboots.
How do I retrieve job results after restarting my application?
Call loadState(cwd) with the workspace root directory. This returns the full state object including the jobs array, where each job contains its current status, result payload, and completion timestamp. Log content is available by reading jobs/<jobId>.log directly.
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 →