Files
PaperClipAI/server/src/adapters/utils.ts
T
machjesusmotoandZephyr 0e21a27301 fix: forward onSpawn to hermes and process adapters for PID persistence (#8722)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The adapter layer (hermes-local, process adapters) delegates agent
execution to child processes via `runChildProcess()`
> - `runChildProcess()` accepts an `onSpawn` callback to report child
PID and process group info, but the hermes and process adapters were not
forwarding `ctx.onSpawn` to this call
> - Without PID persistence, the orphan reaper cannot distinguish live
runs from abandoned processes, causing false-positive reaps and 5-minute
timeout errors for active runs
> - This pull request adds `onSpawn: ctx.onSpawn` to both adapter call
sites and declares the option in the `runChildProcess` wrapper type
> - The benefit is that the orphan reaper can now correctly track live
child processes, eliminating false-positive reaps

## Linked Issues or Issue Description

Fixes #8723

Fixes false-positive orphan reaps in hermes-local and process adapters
by forwarding the `onSpawn` callback to `runChildProcess()`. All other
adapters (claude-local, codex-local, cursor-local, gemini-local,
grok-local, opencode-local, pi-local) already forward `ctx.onSpawn` —
these two were the only ones missing it.

## What Changed

- `server/src/adapters/utils.ts`: Added `onSpawn?` to the
`runChildProcess()` options type so callers can forward the callback
- `server/src/adapters/process/execute.ts`: Forward `ctx.onSpawn` to
`runChildProcess()`
- `packages/adapters/hermes/src/server/execute.ts`: Forward
`ctx.onSpawn` to `runChildProcess()`

## Verification

- `pnpm -r typecheck` passes across all packages
- Confirmed all other adapters already forward `ctx.onSpawn` (12 grep
matches across 9 adapter files)
- The 3-line diff is additive only — no existing behavior is changed,
only a previously-ignored callback is now forwarded

## Risks

Low risk. This is a 3-line additive change. The `onSpawn` parameter is
optional (`?`) so existing callers are unaffected. The callback is
already well-established across all other adapters.

## Model Used

Hermes Agent (by Nous Research) — xiaomi/mimo-v2.5-pro via OpenRouter,
with tool use (file editing, git, GitHub API).

## 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
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass (typecheck passes)
- [x] I have added or updated tests where applicable (N/A — type-level
fix only, no behavioral change)
- [x] I have updated relevant documentation to reflect my changes (N/A —
internal fix)
- [x] I have considered and documented any risks above

---------

Co-authored-by: Zephyr <zephyr@motoyuki.dev>
2026-07-13 12:33:08 -05:00

96 lines
3.8 KiB
TypeScript

// Re-export everything from the shared adapter-utils/server-utils package.
// This file is kept as a convenience shim so existing in-tree
// imports (process/, http/, heartbeat.ts) don't need rewriting.
import type { ChildProcess } from "node:child_process";
import { logger } from "../middleware/logger.js";
import * as serverUtils from "@paperclipai/adapter-utils/server-utils";
export type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
type BuildInvocationEnvForLogsOptions = {
runtimeEnv?: NodeJS.ProcessEnv | Record<string, string>;
includeRuntimeKeys?: string[];
resolvedCommand?: string | null;
resolvedCommandEnvKey?: string;
};
export const runningProcesses: Map<string, { child: ChildProcess; graceSec: number; processGroupId: number | null }> =
serverUtils.runningProcesses;
export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES;
export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES;
export const parseObject = serverUtils.parseObject;
export const asString = serverUtils.asString;
export const asNumber = serverUtils.asNumber;
export const asBoolean = serverUtils.asBoolean;
export const asStringArray = serverUtils.asStringArray;
export const parseJson = serverUtils.parseJson;
export const appendWithCap = serverUtils.appendWithCap;
export const appendWithByteCap = serverUtils.appendWithByteCap;
export const resolvePathValue = serverUtils.resolvePathValue;
export const renderTemplate = serverUtils.renderTemplate;
export const redactEnvForLogs = serverUtils.redactEnvForLogs;
export const buildPaperclipEnv = serverUtils.buildPaperclipEnv;
export const defaultPathForPlatform = serverUtils.defaultPathForPlatform;
export const ensurePathInEnv = serverUtils.ensurePathInEnv;
export const ensureAbsoluteDirectory = serverUtils.ensureAbsoluteDirectory;
export const ensureCommandResolvable = serverUtils.ensureCommandResolvable;
export const resolveCommandForLogs = serverUtils.resolveCommandForLogs;
export function buildInvocationEnvForLogs(
env: Record<string, string>,
options: BuildInvocationEnvForLogsOptions = {},
): Record<string, string> {
const maybeBuildInvocationEnvForLogs = (
serverUtils as typeof serverUtils & {
buildInvocationEnvForLogs?: (
env: Record<string, string>,
options?: BuildInvocationEnvForLogsOptions,
) => Record<string, string>;
}
).buildInvocationEnvForLogs;
if (typeof maybeBuildInvocationEnvForLogs === "function") {
return maybeBuildInvocationEnvForLogs(env, options);
}
const merged: Record<string, string> = { ...env };
const runtimeEnv = options.runtimeEnv ?? {};
for (const key of options.includeRuntimeKeys ?? []) {
if (key in merged) continue;
const value = runtimeEnv[key];
if (typeof value !== "string" || value.length === 0) continue;
merged[key] = value;
}
const resolvedCommand = options.resolvedCommand?.trim();
if (resolvedCommand) {
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] =
serverUtils.redactCommandTextForLogs(resolvedCommand);
}
return redactEnvForLogs(merged);
}
// Re-export runChildProcess with the server's pino logger wired in.
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
const _runChildProcess = serverUtils.runChildProcess;
export async function runChildProcess(
runId: string,
command: string,
args: string[],
opts: {
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
},
): Promise<RunProcessResult> {
return _runChildProcess(runId, command, args, {
...opts,
onLogError: (err, id, msg) => logger.warn({ err, runId: id }, msg),
});
}