How the Codex Plugin Handles Process Termination and Cleanup When Cancelling Jobs

The Codex plugin orchestrates a five-step shutdown sequence—job resolution, graceful turn interruption, process tree termination, state persistence, and result reporting—to ensure reliable cleanup when cancelling jobs.

When you trigger /codex:cancel, the openai/codex-plugin-cc repository executes a cross-platform termination pipeline that prioritizes graceful shutdowns while guaranteeing the underlying OS process is killed. This article breaks down the exact mechanism implemented in the plugin's source code.


Job Resolution: Finding the Target to Cancel

The cancellation flow begins in resolveCancelableJob (plugins/codex/scripts/lib/job-control.mjs, lines 81-107).

This function performs two critical tasks:

  • Filters for active jobs — Only jobs with queued or running status are considered.
  • Resolves the target — If you provide a job reference (e.g., task-live), it matches against that ID. If omitted, it selects the sole active job belonging to your current Claude session.

If no matching job exists, the function throws a descriptive error: "No active Codex jobs to cancel for this session."


Graceful Turn Interruption Before Process Killing

Before terminating the OS process, the plugin attempts to abort the running Codex turn through the app-server protocol.

The interruptAppServerTurn function (plugins/codex/scripts/lib/codex.mjs, lines 60-88) executes this logic:

  1. Validates that threadId and turnId exist on the job record.
  2. Verifies the Codex app-server is reachable.
  3. Opens a client connection and sends a turn/interrupt request.
  4. Returns a payload with two boolean flags: attempted and succeeded.

This step is optional — if the turn cannot be interrupted (server unreachable, IDs missing, or network failure), the plugin proceeds directly to process termination. The interrupt attempt is purely opportunistic to reduce orphaned work on the Codex backend.


Cross-Platform Process Tree Termination

The terminateProcessTree function (plugins/codex/scripts/lib/process.mjs, lines 57-118) implements platform-specific process killing:

Windows Implementation

// Simplified logic from process.mjs
spawn("taskkill", ["/PID", pid, "/T", "/F"]);
// Falls back to process.kill(pid) if "process not found"
  • Uses taskkill /PID <pid> /T /F to forcefully terminate the process and its children (/T flag).
  • If taskkill reports the process no longer exists, falls back to Node's process.kill(pid).

POSIX Implementation

// Simplified logic from process.mjs
try {
  process.kill(-pid, "SIGTERM"); // Negative PID = process group
} catch (err) {
  if (err.code === "ESRCH") {
    process.kill(pid, "SIGTERM"); // Retry single PID
  }
}
  • First attempts to kill the process group via kill(-pid, "SIGTERM").
  • If the group doesn't exist (ESRCH error), retries with the single PID.
  • Returns whether a signal was successfully delivered.

This dual-path approach ensures reliable termination regardless of how the Codex worker was spawned.


State Persistence and Audit Logging

After process termination, the plugin performs three cleanup operations:

Operation Function File
Write cancellation log appendLogLine(job.logFile, "Cancelled by user.") plugins/codex/scripts/lib/tracked-jobs.mjs (lines 36-44)
Update job record writeJobFile plugins/codex/scripts/lib/state.mjs
Refresh in-memory index upsertJob plugins/codex/scripts/lib/state.mjs

The job record update nullifies status, phase, and pid, adds a cancelledAt timestamp, and sets errorMessage to "Cancelled by user.". This ensures /codex:status immediately reflects the cancelled state.


Result Payload Structure

The CLI returns a JSON payload (or human-readable equivalent) summarizing the operation:

{
  "jobId": "task-live",
  "status": "cancelled",
  "title": "Run task live",
  "turnInterruptAttempted": true,
  "turnInterrupted": true
}

The turnInterruptAttempted and turnInterrupted fields explicitly communicate whether the graceful turn abort succeeded, distinguishing between "process killed but turn may continue on server" versus full cleanup.


Command-Line Usage Examples

Cancel the most recent job in your session:

node plugins/codex/scripts/codex-companion.mjs cancel --json

Cancel a specific job by ID:

node plugins/codex/scripts/codex-companion.mjs cancel task-live --json

Programmatic cancellation from another Node script:

import { spawnSync } from "child_process";

function cancelJob(jobId) {
  const result = spawnSync(
    "node",
    ["plugins/codex/scripts/codex-companion.mjs", "cancel", jobId, "--json"],
    { encoding: "utf8" }
  );
  if (result.status !== 0) throw new Error(result.stderr);
  return JSON.parse(result.stdout);
}

const payload = cancelJob("task-live");
console.log(`Job ${payload.jobId} was ${payload.status}`);
// Output: Job task-live was cancelled

Key Source Files and Entry Points

File Responsibility
plugins/codex/scripts/codex-companion.mjs CLI entry point; handleCancel at lines 63-87
plugins/codex/scripts/lib/job-control.mjs resolveCancelableJob — job lookup logic
plugins/codex/scripts/lib/codex.mjs interruptAppServerTurn — app-server protocol
plugins/codex/scripts/lib/process.mjs terminateProcessTree — cross-platform killing
plugins/codex/scripts/lib/tracked-jobs.mjs Logging helpers
plugins/codex/scripts/lib/state.mjs Persistent state read/write and index updates

Summary

  • Job resolution filters for active jobs and validates the cancellation target via resolveCancelableJob.
  • Graceful interruption attempts to abort the Codex turn through interruptAppServerTurn before killing the process.
  • Cross-platform termination in terminateProcessTree uses taskkill on Windows and process-group SIGTERM on POSIX, with fallback strategies.
  • State cleanup nullifies the job's PID, timestamps the cancellation, and persists to disk.
  • Explicit result reporting distinguishes between "process killed" and "turn interrupted" so callers understand cleanup completeness.

Frequently Asked Questions

What happens if the Codex app-server is unreachable during cancellation?

The plugin proceeds with process termination regardless. The turnInterruptAttempted field in the response will be true (attempt was made) but turnInterrupted will be false. The OS process is still killed, ensuring no orphaned worker remains.

How does the plugin handle already-dead processes?

Both Windows and POSIX implementations include fallback logic. On Windows, if taskkill reports "process not found", the code falls back to process.kill(pid). On POSIX, if killing the process group fails with ESRCH (no such process), it retries with the single PID.

Can I cancel a job from a different Claude session?

No. When no job ID is specified, resolveCancelableJob restricts the search to jobs belonging to your current session. To cancel another session's job, you must explicitly provide the job reference.

Is the turn interrupt guaranteed to stop work on the Codex backend?

No. The turn interrupt is best-effort. It requires the app-server to be reachable and responsive. The plugin does not wait or retry; it immediately proceeds to process termination after the single interrupt attempt.

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 →