mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30: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
|
|
@ -4,6 +4,7 @@ import {
|
|||
appendWithByteCap,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
renderPaperclipWakePrompt,
|
||||
runningProcesses,
|
||||
runChildProcess,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "./server-utils.js";
|
||||
|
|
@ -26,6 +27,17 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
|||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = read();
|
||||
const match = value.match(pattern);
|
||||
if (match) return match;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return read().match(pattern);
|
||||
}
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("does not arm a timeout when timeoutSec is 0", async () => {
|
||||
const result = await runChildProcess(
|
||||
|
|
@ -110,6 +122,103 @@ describe("runChildProcess", () => {
|
|||
|
||||
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => {
|
||||
const result = await runChildProcess(
|
||||
randomUUID(),
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
[
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'ignore'] });",
|
||||
"process.stdout.write(`descendant:${child.pid}\\n`);",
|
||||
"process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);",
|
||||
"setTimeout(() => process.exit(0), 25);",
|
||||
].join(" "),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
timeoutSec: 0,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
terminalResultCleanup: {
|
||||
graceMs: 100,
|
||||
hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
|
||||
expect(await waitForPidExit(descendantPid, 2_000)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("does not clean up noisy runs that have no terminal output", async () => {
|
||||
const runId = randomUUID();
|
||||
let observed = "";
|
||||
const resultPromise = runChildProcess(
|
||||
runId,
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
[
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const child = spawn(process.execPath, ['-e', \"setInterval(() => process.stdout.write('noise\\\\n'), 50)\"], { stdio: ['ignore', 'inherit', 'ignore'] });",
|
||||
"process.stdout.write(`descendant:${child.pid}\\n`);",
|
||||
"setTimeout(() => process.exit(0), 25);",
|
||||
].join(" "),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
timeoutSec: 0,
|
||||
graceSec: 1,
|
||||
onLog: async (_stream, chunk) => {
|
||||
observed += chunk;
|
||||
},
|
||||
terminalResultCleanup: {
|
||||
graceMs: 50,
|
||||
hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const pidMatch = await waitForTextMatch(() => observed, /descendant:(\d+)/);
|
||||
const descendantPid = Number.parseInt(pidMatch?.[1] ?? "", 10);
|
||||
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
|
||||
|
||||
const race = await Promise.race([
|
||||
resultPromise.then(() => "settled" as const),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 300)),
|
||||
]);
|
||||
expect(race).toBe("pending");
|
||||
expect(isPidAlive(descendantPid)).toBe(true);
|
||||
|
||||
const running = runningProcesses.get(runId) as
|
||||
| { child: { kill(signal: NodeJS.Signals): boolean }; processGroupId: number | null }
|
||||
| undefined;
|
||||
try {
|
||||
if (running?.processGroupId) {
|
||||
process.kill(-running.processGroupId, "SIGKILL");
|
||||
} else {
|
||||
running?.child.kill("SIGKILL");
|
||||
}
|
||||
await resultPromise;
|
||||
} finally {
|
||||
runningProcesses.delete(runId);
|
||||
if (isPidAlive(descendantPid)) {
|
||||
try {
|
||||
process.kill(descendantPid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendWithByteCap", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue