fix(codex): preserve silent active builds (#10153)

## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI agents and their work.
> - Local adapters are responsible for observing agent subprocesses and
terminating runs that are genuinely stuck.
> - The Codex local adapter currently treats output bytes as its only
liveness signal for the 30-minute inactivity monitor.
> - Healthy compile and test loops can consume CPU and perform disk I/O
for longer than that without producing terminal output.
> - Terminating those runs loses valid work, while removing the monitor
entirely would allow truly wedged processes to run indefinitely.
> - This pull request adds a Linux process-group activity probe that
recognizes meaningful CPU, disk I/O, and child-process churn while
retaining the existing timeout for idle processes.
> - The benefit is that long silent builds can finish without weakening
the adapter's hung-run safety net.

## Linked Issues or Issue Description

No public GitHub issue was found in the duplicate/related search.

**What happened**

A healthy Codex local run executing a long compile/test loop could be
terminated at the default 30-minute output-inactivity threshold when the
child process emitted no stdout or stderr.

**Expected behavior**

The inactivity monitor should keep a silent run alive while its process
group is doing meaningful work, but should still terminate a process
group that is alive and idle.

**Steps to reproduce**

1. Run Codex local with the default `outputInactivityTimeoutMs`.
2. Have the agent start a compile or test command that consumes CPU or
disk I/O without terminal output for longer than the threshold.
3. Observe the adapter terminate the otherwise healthy process group as
output-inactive.

**Affected version / deployment mode**

Observed on a local-process Paperclip deployment using the Codex local
adapter with the 30-minute default inactivity monitor.

## What Changed

- Added a Linux `/proc` process-group sampler that tracks meaningful CPU
tick growth, disk I/O growth, and child-process membership changes.
- Reset the existing Codex inactivity timer when that sampler observes
real process work, while leaving remote and non-Linux behavior
unchanged.
- Added diagnostics for the number of process-activity resets and
documented the expanded liveness semantics.
- Added unit coverage for process-activity timer resets and subprocess
regressions for both a long silent CPU build and a genuinely wedged
child.

## Verification

- `pnpm --filter @paperclipai/adapter-codex-local typecheck`
- `pnpm exec vitest run
packages/adapters/codex-local/src/server/process-activity-monitor.test.ts
packages/adapters/codex-local/src/server/output-inactivity-monitor.test.ts
packages/adapters/codex-local/src/server/output-inactivity-monitor.integration.test.ts`
- Focused result: 23 tests passed, including a silent CPU-bound
subprocess that runs four times beyond the simulated inactivity window
and an idle subprocess that is still terminated.

## Risks

- Low risk and Linux-scoped: the new probe reads `/proc` every 15
seconds only while a monitored local Codex child is running.
- The CPU threshold requires sustained work rather than any single
scheduler tick, reducing the risk that a nearly idle event loop is
treated as productive.
- If `/proc` sampling is unavailable or fails, the adapter falls back to
the existing output-only behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with model `gpt-5.6-sol`, high reasoning effort,
terminal/tool execution, code editing, and test execution. The runtime
did not expose a context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-24 15:27:24 -05:00 committed by GitHub
parent e4662b1f9d
commit b3b07ae44e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 352 additions and 9 deletions

View File

@ -95,7 +95,7 @@ Core fields:
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
- outputInactivityTimeoutMs (number | null, optional): inactivity monitor around the codex child. Resets whenever the child emits stdout or stderr bytes, including non-JSON progress from long-running verification commands. Defaults to 30 * 60_000 ms when unset or non-positive. Set to \`null\` to disable the monitor entirely (only do this for known-slow tasks; the platform-level 1h silent-run safety net still applies). On fire, the adapter sends SIGTERM to the process group, waits 5s, then SIGKILL, and surfaces the run as failed with errorMessage "monitor: no codex output for {N}m {S}s".
- outputInactivityTimeoutMs (number | null, optional): inactivity monitor around the codex child. Resets whenever the child emits stdout/stderr bytes or, on Linux, its process group shows meaningful CPU, disk I/O, or child-process churn during a silent build. Defaults to 30 * 60_000 ms when unset or non-positive. Set to \`null\` to disable the monitor entirely (only do this for known-slow tasks; the platform-level 1h silent-run safety net still applies). On fire, the adapter sends SIGTERM to the process group, waits 5s, then SIGKILL, and surfaces the run as failed with errorMessage "monitor: no codex activity (output or process) for {N}m {S}s".
- agentCommand (string, optional): ACP server command override used only when engine="acp"; defaults to the package-local codex-acp binary
- mode (string, optional): ACP session mode when engine="acp"; persistent or oneshot
- nonInteractivePermissions (string, optional): ACP non-interactive permission fallback when engine="acp"; deny or fail

View File

@ -87,6 +87,11 @@ import {
formatOutputInactivityMonitorErrorMessage,
resolveCodexInactivityTimeout,
} from "./output-inactivity-monitor.js";
import {
CODEX_PROCESS_ACTIVITY_POLL_INTERVAL_MS,
createCodexProcessActivityMonitor,
type CodexProcessActivityMonitorHandle,
} from "./process-activity-monitor.js";
import {
createCodexAcpExecutor,
formatCodexAcpFallbackMessage,
@ -1051,6 +1056,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
let killTarget: { pid: number | null; processGroupId: number | null } | null = null;
let sigkillTimer: ReturnType<typeof setTimeout> | null = null;
let monitorLogPromise: Promise<unknown> | null = null;
const processActivityMonitor: { current: CodexProcessActivityMonitorHandle | null } = { current: null };
const resolvedMonitorTimeoutMs = monitorResolution.mode === "disabled" ? null : monitorResolution.timeoutMs;
const monitor =
monitorResolution.mode === "disabled"
@ -1068,7 +1075,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] adapter.invoke ${message}; ` +
`timeoutMs=${monitorResolution.timeoutMs} elapsedSinceLastEventMs=${monitorElapsedMs} ` +
`outputChunkCount=${state.outputChunkCount} outputBytes=${state.outputBytes} ` +
`parsedEvents=${state.parsedEventCount} (timeout=${timeoutSecLabel}s elapsed=${elapsedSec}s); ` +
`parsedEvents=${state.parsedEventCount} processActivityCount=${state.processActivityCount} ` +
`(timeout=${timeoutSecLabel}s elapsed=${elapsedSec}s); ` +
`terminating codex child via SIGTERM (5s grace, then SIGKILL).\n`;
// Issue the log without awaiting on the kill hot path, but capture
// the promise so the surrounding try/finally can await flush before
@ -1094,6 +1102,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wrappedOnSpawn = async (meta: { pid: number; processGroupId: number | null; startedAt: string }) => {
killTarget = { pid: meta.pid ?? null, processGroupId: meta.processGroupId };
if (monitor && resolvedMonitorTimeoutMs !== null && !executionTargetIsRemote) {
processActivityMonitor.current = createCodexProcessActivityMonitor({
pid: meta.pid,
processGroupId: meta.processGroupId,
intervalMs: Math.min(
CODEX_PROCESS_ACTIVITY_POLL_INTERVAL_MS,
Math.max(1_000, Math.floor(resolvedMonitorTimeoutMs / 4)),
),
onActivity: () => monitor.noteProcessActivity(),
});
}
if (onSpawn) {
await onSpawn(meta);
}
@ -1139,6 +1158,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: { fired: false as const },
};
} finally {
processActivityMonitor.current?.stop();
monitor?.stop();
if (sigkillTimer) {
clearTimeout(sigkillTimer);

View File

@ -5,6 +5,7 @@ import {
createCodexOutputInactivityMonitor,
formatOutputInactivityMonitorErrorMessage,
} from "./output-inactivity-monitor.js";
import { createCodexProcessActivityMonitor } from "./process-activity-monitor.js";
const FAKE_CODEX_SCRIPT = `
process.stdout.write(JSON.stringify({ type: "thread.started", thread_id: "abc" }) + "\\n");
@ -15,6 +16,56 @@ setInterval(() => {}, 60_000);
`;
describe("codex inactivity monitor (integration: real subprocess)", () => {
it.skipIf(process.platform !== "linux")(
"allows a long silent build while the child process group is consuming CPU",
async () => {
const runId = `monitor-active-build-${Date.now()}`;
const timeoutMs = 500;
const processActivityMonitor: {
current: ReturnType<typeof createCodexProcessActivityMonitor> | null;
} = { current: null };
let monitorFired = false;
const monitor = createCodexOutputInactivityMonitor({
timeoutMs,
onFire: () => {
monitorFired = true;
},
});
try {
const proc = await runChildProcess(
runId,
process.execPath,
["-e", "const end = Date.now() + 2_000; while (Date.now() < end) {}"],
{
cwd: process.cwd(),
env: process.env as Record<string, string>,
timeoutSec: 5,
graceSec: 1,
onSpawn: async (meta) => {
processActivityMonitor.current = createCodexProcessActivityMonitor({
pid: meta.pid,
processGroupId: meta.processGroupId,
intervalMs: 50,
onActivity: () => monitor.noteProcessActivity(),
});
},
onLog: async (stream, chunk) => monitor.noteOutputChunk(stream, chunk),
},
);
expect(proc.exitCode).toBe(0);
expect(proc.timedOut).toBe(false);
expect(monitorFired).toBe(false);
expect(monitor.state().processActivityCount).toBeGreaterThan(0);
} finally {
processActivityMonitor.current?.stop();
monitor.stop();
}
},
10_000,
);
it(
"kills a codex child that goes silent after one event and surfaces a monitor failure",
async () => {
@ -25,6 +76,9 @@ describe("codex inactivity monitor (integration: real subprocess)", () => {
let monitorFired = false;
let terminationSignal: NodeJS.Signals | null = null;
let sigkillTimer: ReturnType<typeof setTimeout> | null = null;
const processActivityMonitor: {
current: ReturnType<typeof createCodexProcessActivityMonitor> | null;
} = { current: null };
let elapsedMs = 0;
const kill = (signal: NodeJS.Signals) => {
@ -69,6 +123,12 @@ describe("codex inactivity monitor (integration: real subprocess)", () => {
graceSec: 1,
onSpawn: async (meta) => {
killTarget = { pid: meta.pid, processGroupId: meta.processGroupId };
processActivityMonitor.current = createCodexProcessActivityMonitor({
pid: meta.pid,
processGroupId: meta.processGroupId,
intervalMs: 25,
onActivity: () => monitor.noteProcessActivity(),
});
},
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
@ -84,11 +144,12 @@ describe("codex inactivity monitor (integration: real subprocess)", () => {
// The errorMessage shape mirrors the AdapterExecutionResult that
// execute.ts will produce for this case.
expect(formatOutputInactivityMonitorErrorMessage(elapsedMs)).toMatch(
/^monitor: no codex output for \d+m \d+s$/,
/^monitor: no codex activity \(output or process\) for \d+m \d+s$/,
);
// We should have observed exactly one parsed JSONL event before silence.
expect(monitor.state().parsedEventCount).toBe(1);
} finally {
processActivityMonitor.current?.stop();
monitor.stop();
if (sigkillTimer) clearTimeout(sigkillTimer);
}

View File

@ -99,10 +99,16 @@ describe("resolveCodexInactivityTimeout", () => {
describe("formatOutputInactivityMonitorErrorMessage", () => {
it("formats minutes and seconds", () => {
expect(formatOutputInactivityMonitorErrorMessage(0)).toBe("monitor: no codex output for 0m 0s");
expect(formatOutputInactivityMonitorErrorMessage(7 * 60 * 1000)).toBe("monitor: no codex output for 7m 0s");
expect(formatOutputInactivityMonitorErrorMessage(7 * 60 * 1000 + 12_000)).toBe("monitor: no codex output for 7m 12s");
expect(formatOutputInactivityMonitorErrorMessage(45_000)).toBe("monitor: no codex output for 0m 45s");
expect(formatOutputInactivityMonitorErrorMessage(0)).toBe("monitor: no codex activity (output or process) for 0m 0s");
expect(formatOutputInactivityMonitorErrorMessage(7 * 60 * 1000)).toBe(
"monitor: no codex activity (output or process) for 7m 0s",
);
expect(formatOutputInactivityMonitorErrorMessage(7 * 60 * 1000 + 12_000)).toBe(
"monitor: no codex activity (output or process) for 7m 12s",
);
expect(formatOutputInactivityMonitorErrorMessage(45_000)).toBe(
"monitor: no codex activity (output or process) for 0m 45s",
);
});
});
@ -186,6 +192,29 @@ describe("createCodexOutputInactivityMonitor (acceptance criteria 1: fires)", ()
expect(fireCount).toBe(1);
monitor.stop();
});
it("resets on process activity without output", () => {
const clock = new FakeClock();
let fireCount = 0;
const monitor = createCodexOutputInactivityMonitor({
timeoutMs: 1_000,
now: () => clock.now(),
setTimer: (cb, ms) => clock.setTimer(cb, ms),
clearTimer: (handle) => clock.clearTimer(handle),
onFire: () => {
fireCount += 1;
},
});
clock.advance(900);
monitor.noteProcessActivity();
expect(monitor.state().processActivityCount).toBe(1);
clock.advance(999);
expect(fireCount).toBe(0);
clock.advance(1);
expect(fireCount).toBe(1);
monitor.stop();
});
});
describe("createCodexOutputInactivityMonitor (acceptance criteria 2: does not fire)", () => {

View File

@ -34,6 +34,7 @@ export interface CodexOutputInactivityMonitorState {
outputChunkCount: number;
outputBytes: number;
parsedEventCount: number;
processActivityCount: number;
}
export interface CodexOutputInactivityMonitorOptions {
@ -51,6 +52,7 @@ export interface CodexOutputInactivityMonitorOptions {
export interface CodexOutputInactivityMonitorHandle {
noteOutputChunk(stream: "stdout" | "stderr", chunk: string): void;
noteProcessActivity(): void;
/** Returns the current state without stopping the timer. */
state(): CodexOutputInactivityMonitorState;
/** Cancels any pending timer and returns the final state. */
@ -85,6 +87,7 @@ export function createCodexOutputInactivityMonitor(
outputChunkCount: 0,
outputBytes: 0,
parsedEventCount: 0,
processActivityCount: 0,
};
let timerHandle: unknown = null;
let stopped = false;
@ -120,6 +123,12 @@ export function createCodexOutputInactivityMonitor(
state.lastEventAt = now();
arm();
},
noteProcessActivity() {
if (stopped || state.fired) return;
state.processActivityCount += 1;
state.lastEventAt = now();
arm();
},
state() {
return { ...state };
},
@ -136,11 +145,11 @@ export function createCodexOutputInactivityMonitor(
/**
* Format the inactivity monitor error message in the canonical
* `monitor: no codex output for {N}m {S}s` shape consumed by NEE-81.
* `monitor: no codex activity (output or process) for {N}m {S}s` shape consumed by NEE-81.
*/
export function formatOutputInactivityMonitorErrorMessage(elapsedMs: number): string {
const total = Math.max(0, Math.round(elapsedMs / 1000));
const minutes = Math.floor(total / 60);
const seconds = total - minutes * 60;
return `monitor: no codex output for ${minutes}m ${seconds}s`;
return `monitor: no codex activity (output or process) for ${minutes}m ${seconds}s`;
}

View File

@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import {
createCodexProcessActivityMonitor,
type CodexProcessActivitySnapshot,
} from "./process-activity-monitor.js";
class PollHarness {
private callback: (() => void) | null = null;
setTimer = (callback: () => void) => {
this.callback = callback;
return callback;
};
clearTimer = () => {
this.callback = null;
};
async poll(): Promise<void> {
await vi.waitFor(() => expect(this.callback).not.toBeNull());
const callback = this.callback;
this.callback = null;
callback?.();
}
}
function snapshot(cpuTicks: number, ioBytes: number, processIds = "100"): CodexProcessActivitySnapshot {
return { cpuTicks, ioBytes, processIds };
}
describe("createCodexProcessActivityMonitor", () => {
it("requires a baseline and ignores sub-threshold CPU changes", async () => {
const samples = [snapshot(100, 1_000), snapshot(114, 1_000)];
const harness = new PollHarness();
const onActivity = vi.fn();
const monitor = createCodexProcessActivityMonitor({
pid: 100,
processGroupId: 100,
intervalMs: 15_000,
sample: async () => samples.shift() ?? null,
setTimer: harness.setTimer,
clearTimer: harness.clearTimer,
onActivity,
});
await harness.poll();
await vi.waitFor(() => expect(onActivity).not.toHaveBeenCalled());
monitor.stop();
});
it.each([
["CPU growth", snapshot(115, 1_000)],
["I/O growth", snapshot(100, 1_001)],
["process-group churn", snapshot(100, 1_000, "100,101")],
])("reports %s as process activity", async (_label, activeSnapshot) => {
const samples = [snapshot(100, 1_000), activeSnapshot];
const harness = new PollHarness();
const onActivity = vi.fn();
const monitor = createCodexProcessActivityMonitor({
pid: 100,
processGroupId: 100,
intervalMs: 15_000,
sample: async () => samples.shift() ?? null,
setTimer: harness.setTimer,
clearTimer: harness.clearTimer,
onActivity,
});
await harness.poll();
await vi.waitFor(() => expect(onActivity).toHaveBeenCalledTimes(1));
monitor.stop();
});
it("resets its comparison baseline after an unavailable sample", async () => {
const samples = [snapshot(100, 1_000), null, snapshot(200, 2_000), snapshot(215, 2_000)];
const harness = new PollHarness();
const onActivity = vi.fn();
const monitor = createCodexProcessActivityMonitor({
pid: 100,
processGroupId: 100,
intervalMs: 15_000,
sample: async () => samples.shift() ?? null,
setTimer: harness.setTimer,
clearTimer: harness.clearTimer,
onActivity,
});
await harness.poll();
await harness.poll();
await vi.waitFor(() => expect(onActivity).not.toHaveBeenCalled());
await harness.poll();
await vi.waitFor(() => expect(onActivity).toHaveBeenCalledTimes(1));
monitor.stop();
});
});

View File

@ -0,0 +1,129 @@
import fs from "node:fs/promises";
export const CODEX_PROCESS_ACTIVITY_POLL_INTERVAL_MS = 15_000;
export interface CodexProcessActivitySnapshot {
cpuTicks: number;
ioBytes: number;
processIds: string;
}
export interface CodexProcessActivityMonitorOptions {
pid: number;
processGroupId: number | null;
onActivity: () => void;
intervalMs?: number;
sample?: () => Promise<CodexProcessActivitySnapshot | null>;
setTimer?: (cb: () => void, ms: number) => unknown;
clearTimer?: (handle: unknown) => void;
}
export interface CodexProcessActivityMonitorHandle {
stop(): void;
}
function parseProcStat(stat: string): { processGroupId: number; cpuTicks: number } | null {
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) return null;
const fields = stat.slice(commandEnd + 2).trim().split(/\s+/);
const processGroupId = Number(fields[2]);
const userTicks = Number(fields[11]);
const systemTicks = Number(fields[12]);
if (![processGroupId, userTicks, systemTicks].every(Number.isFinite)) return null;
return { processGroupId, cpuTicks: userTicks + systemTicks };
}
function parseProcIo(io: string): number {
let bytes = 0;
for (const line of io.split("\n")) {
const match = /^(?:read_bytes|write_bytes):\s+(\d+)$/.exec(line.trim());
if (match) bytes += Number(match[1]);
}
return bytes;
}
export async function sampleCodexProcessActivity(
pid: number,
processGroupId: number | null,
): Promise<CodexProcessActivitySnapshot | null> {
if (process.platform !== "linux") return null;
const targetProcessGroupId = processGroupId && processGroupId > 0 ? processGroupId : null;
const entries = targetProcessGroupId ? await fs.readdir("/proc") : [String(pid)];
const processIds: number[] = [];
let cpuTicks = 0;
let ioBytes = 0;
await Promise.all(
entries.map(async (entry) => {
if (!/^\d+$/.test(entry)) return;
try {
const parsed = parseProcStat(await fs.readFile(`/proc/${entry}/stat`, "utf8"));
if (!parsed) return;
if (targetProcessGroupId !== null && parsed.processGroupId !== targetProcessGroupId) return;
if (targetProcessGroupId === null && Number(entry) !== pid) return;
const io = await fs.readFile(`/proc/${entry}/io`, "utf8").catch(() => "");
processIds.push(Number(entry));
cpuTicks += parsed.cpuTicks;
ioBytes += parseProcIo(io);
} catch {
// Processes can exit between listing /proc and reading their stat file.
}
}),
);
if (processIds.length === 0) return null;
processIds.sort((left, right) => left - right);
return { cpuTicks, ioBytes, processIds: processIds.join(",") };
}
export function createCodexProcessActivityMonitor(
options: CodexProcessActivityMonitorOptions,
): CodexProcessActivityMonitorHandle {
const intervalMs = options.intervalMs ?? CODEX_PROCESS_ACTIVITY_POLL_INTERVAL_MS;
const sample = options.sample ?? (() => sampleCodexProcessActivity(options.pid, options.processGroupId));
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
const minimumCpuTickDelta = Math.max(1, Math.floor(intervalMs / 1_000));
let previous: CodexProcessActivitySnapshot | null = null;
let timer: unknown = null;
let stopped = false;
const schedule = () => {
if (stopped) return;
timer = setTimer(() => {
void poll();
}, intervalMs);
if (typeof (timer as { unref?: () => void }).unref === "function") {
(timer as { unref: () => void }).unref();
}
};
const poll = async () => {
if (stopped) return;
const current = await sample().catch(() => null);
if (stopped) return;
if (
current &&
previous &&
(current.cpuTicks - previous.cpuTicks >= minimumCpuTickDelta ||
current.ioBytes > previous.ioBytes ||
current.processIds !== previous.processIds)
) {
options.onActivity();
}
previous = current;
schedule();
};
void poll();
return {
stop() {
stopped = true;
if (timer != null) {
clearTimer(timer);
timer = null;
}
},
};
}