mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
[codex] Clean up terminal-result adapter process groups (#4129)
## Thinking Path > - Paperclip runs local adapter processes for agents and streams their output into heartbeat runs > - Some adapters can emit a terminal result before all descendant processes have exited > - If those descendants keep running, a heartbeat can appear complete while the process group remains alive > - Claude local runs need a bounded cleanup path after terminal JSON output is observed and the child exits > - This pull request adds terminal-result cleanup support to adapter process utilities and wires it into the Claude local adapter > - The benefit is fewer stranded adapter process groups after successful terminal results ## What Changed - Added terminal-result cleanup options to `runChildProcess`. - Tracked child exit plus terminal output before signaling lingering process groups. - Added Claude local adapter configuration for terminal result cleanup grace time. - Added process cleanup tests covering terminal-output cleanup and noisy non-terminal runs. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts` - Result: 9 tests passed. ## Risks - Medium risk: this changes adapter child-process cleanup behavior. - The cleanup only arms after terminal result detection and child exit, and it is covered by process-group tests. > 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 coding agent based on GPT-5, tool-enabled local shell and GitHub workflow, exact runtime context window not exposed in this session. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why it is not applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
56b3120971
commit
c7c1ca0c78
3 changed files with 195 additions and 2 deletions
|
|
@ -16,6 +16,11 @@ export interface RunProcessResult {
|
|||
startedAt: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalResultCleanupOptions {
|
||||
hasTerminalResult: (output: { stdout: string; stderr: string }) => boolean;
|
||||
graceMs?: number;
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
|
|
@ -29,6 +34,10 @@ interface SpawnTarget {
|
|||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "exit",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
|
|
@ -60,6 +69,7 @@ function signalRunningProcess(
|
|||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../skills",
|
||||
|
|
@ -1237,6 +1247,7 @@ export async function runChildProcess(
|
|||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
terminalResultCleanup?: TerminalResultCleanupOptions;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
|
|
@ -1286,11 +1297,61 @@ export async function runChildProcess(
|
|||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
let childExited = false;
|
||||
let terminalResultSeen = false;
|
||||
let terminalCleanupStarted = false;
|
||||
let terminalCleanupTimer: NodeJS.Timeout | null = null;
|
||||
let terminalCleanupKillTimer: NodeJS.Timeout | null = null;
|
||||
let terminalResultStdoutScanOffset = 0;
|
||||
let terminalResultStderrScanOffset = 0;
|
||||
|
||||
const clearTerminalCleanupTimers = () => {
|
||||
if (terminalCleanupTimer) clearTimeout(terminalCleanupTimer);
|
||||
if (terminalCleanupKillTimer) clearTimeout(terminalCleanupKillTimer);
|
||||
terminalCleanupTimer = null;
|
||||
terminalCleanupKillTimer = null;
|
||||
};
|
||||
|
||||
const maybeArmTerminalResultCleanup = () => {
|
||||
const terminalCleanup = opts.terminalResultCleanup;
|
||||
if (!terminalCleanup || terminalCleanupStarted || timedOut) return;
|
||||
if (!terminalResultSeen) {
|
||||
const stdoutStart = Math.max(0, terminalResultStdoutScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
|
||||
const stderrStart = Math.max(0, terminalResultStderrScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
|
||||
const scanOutput = {
|
||||
stdout: stdout.slice(stdoutStart),
|
||||
stderr: stderr.slice(stderrStart),
|
||||
};
|
||||
terminalResultStdoutScanOffset = stdout.length;
|
||||
terminalResultStderrScanOffset = stderr.length;
|
||||
if (scanOutput.stdout.length === 0 && scanOutput.stderr.length === 0) return;
|
||||
try {
|
||||
terminalResultSeen = terminalCleanup.hasTerminalResult(scanOutput);
|
||||
} catch (err) {
|
||||
onLogError(err, runId, "failed to inspect terminal adapter output");
|
||||
}
|
||||
}
|
||||
if (!terminalResultSeen || !childExited) return;
|
||||
|
||||
if (terminalCleanupTimer) return;
|
||||
const graceMs = Math.max(0, terminalCleanup.graceMs ?? 5_000);
|
||||
terminalCleanupTimer = setTimeout(() => {
|
||||
terminalCleanupTimer = null;
|
||||
if (terminalCleanupStarted || timedOut) return;
|
||||
terminalCleanupStarted = true;
|
||||
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
||||
terminalCleanupKillTimer = setTimeout(() => {
|
||||
terminalCleanupKillTimer = null;
|
||||
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, graceMs);
|
||||
};
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
clearTerminalCleanupTimers();
|
||||
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
||||
setTimeout(() => {
|
||||
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
||||
|
|
@ -1304,10 +1365,14 @@ export async function runChildProcess(
|
|||
readable.pause();
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
maybeArmTerminalResultCleanup();
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"))
|
||||
.finally(() => resumeReadable(readable));
|
||||
.finally(() => {
|
||||
maybeArmTerminalResultCleanup();
|
||||
resumeReadable(readable);
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
|
|
@ -1316,10 +1381,14 @@ export async function runChildProcess(
|
|||
readable.pause();
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
maybeArmTerminalResultCleanup();
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"))
|
||||
.finally(() => resumeReadable(readable));
|
||||
.finally(() => {
|
||||
maybeArmTerminalResultCleanup();
|
||||
resumeReadable(readable);
|
||||
});
|
||||
});
|
||||
|
||||
const stdin = child.stdin;
|
||||
|
|
@ -1333,6 +1402,7 @@ export async function runChildProcess(
|
|||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
clearTerminalCleanupTimers();
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
|
|
@ -1343,8 +1413,14 @@ export async function runChildProcess(
|
|||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("exit", () => {
|
||||
childExited = true;
|
||||
maybeArmTerminalResultCleanup();
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
clearTerminalCleanupTimers();
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue