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_DATA environment variable when available
  • Falls back to os.tmpdir()/codex-companion for 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.json if present
  • Falls back to a default schema with empty jobs array
  • 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>.json and <jobId>.log files 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:

  1. Writes a "running" record before invoking the runner function
  2. Captures the execution result (exit status, payload, rendered output)
  3. Updates the job JSON file and master state list
  4. 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.json and jobs/<jobId>.json provide 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_DATA controls 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →