forked from farhoodlabs/paperclip
Expand heartbeat service with process adapter improvements
Add richer process lifecycle management, output capture, and error handling to the heartbeat service. Improve run event recording and adapter state persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import {
|
||||
@@ -47,6 +49,16 @@ interface AdapterExecutionResult {
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
interface AdapterInvocationMeta {
|
||||
adapterType: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
commandArgs?: string[];
|
||||
env?: Record<string, string>;
|
||||
prompt?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WakeupOptions {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
@@ -62,6 +74,7 @@ const runningProcesses = new Map<string, RunningProcess>();
|
||||
const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
@@ -175,6 +188,85 @@ function parseCodexJsonl(stdout: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseClaudeStreamJson(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
let model = "";
|
||||
let finalResult: Record<string, unknown> | null = null;
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
if (type === "system" && asString(event.subtype, "") === "init") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
model = asString(event.model, model);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
const message = parseObject(event.message);
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const entry of content) {
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const block = entry as Record<string, unknown>;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text) assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
finalResult = event;
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd: null as number | null,
|
||||
usage: null as UsageSummary | null,
|
||||
summary: assistantTexts.join("\n\n").trim(),
|
||||
resultJson: null as Record<string, unknown> | null,
|
||||
};
|
||||
}
|
||||
|
||||
const usageObj = parseObject(finalResult.usage);
|
||||
const usage: UsageSummary = {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
const costRaw = finalResult.total_cost_usd;
|
||||
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
||||
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd,
|
||||
usage,
|
||||
summary,
|
||||
resultJson: finalResult,
|
||||
};
|
||||
}
|
||||
|
||||
function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
async function runChildProcess(
|
||||
runId: string,
|
||||
command: string,
|
||||
@@ -188,9 +280,10 @@ async function runChildProcess(
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
||||
const child = spawn(command, args, {
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -231,7 +324,13 @@ async function runChildProcess(
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
reject(err);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
@@ -260,6 +359,70 @@ function buildPaperclipEnv(agent: { id: string; companyId: string }): Record<str
|
||||
return vars;
|
||||
}
|
||||
|
||||
function defaultPathForPlatform() {
|
||||
if (process.platform === "win32") {
|
||||
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
||||
}
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
|
||||
function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
return { ...env, PATH: defaultPathForPlatform() };
|
||||
}
|
||||
|
||||
async function ensureAbsoluteDirectory(cwd: string) {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(cwd);
|
||||
} catch {
|
||||
throw new Error(`Working directory does not exist: "${cwd}"`);
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
try {
|
||||
await fs.access(absolute, fsConstants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const windowsExt = process.platform === "win32"
|
||||
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
||||
: [""];
|
||||
|
||||
for (const dir of dirs) {
|
||||
for (const ext of windowsExt) {
|
||||
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return;
|
||||
} catch {
|
||||
// continue scanning PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
export function heartbeatService(db: Db) {
|
||||
const runLogStore = getRunLogStore();
|
||||
|
||||
@@ -543,6 +706,7 @@ export function heartbeatService(db: Db) {
|
||||
agent: typeof agents.$inferSelect,
|
||||
config: Record<string, unknown>,
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const command = asString(config.command, "");
|
||||
if (!command) throw new Error("Process adapter missing command");
|
||||
@@ -558,6 +722,16 @@ export function heartbeatService(db: Db) {
|
||||
const timeoutSec = asNumber(config.timeoutSec, 900);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "process",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
@@ -606,26 +780,35 @@ export function heartbeatService(db: Db) {
|
||||
config: Record<string, unknown>,
|
||||
context: Record<string, unknown>,
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||
const command = asString(config.command, "claude");
|
||||
const model = asString(config.model, "");
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = asStringArray(config.extraArgs);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const sessionId = runtime.sessionId;
|
||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||
@@ -636,14 +819,26 @@ export function heartbeatService(db: Db) {
|
||||
context,
|
||||
});
|
||||
|
||||
const args = ["--print", prompt, "--output-format", "json"];
|
||||
const args = ["--print", prompt, "--output-format", "stream-json", "--verbose"];
|
||||
if (sessionId) args.push("--resume", sessionId);
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (model) args.push("--model", model);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const proc = await runChildProcess(runId, "claude", args, {
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args.map((value, idx) => (idx === 1 ? `<prompt ${prompt.length} chars>` : value)),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
@@ -660,7 +855,8 @@ export function heartbeatService(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseJson(proc.stdout);
|
||||
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
||||
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
||||
if (!parsed) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
@@ -677,12 +873,16 @@ export function heartbeatService(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
const usageObj = parseObject(parsed.usage);
|
||||
const usage: UsageSummary = {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
const usage =
|
||||
parsedStream.usage ??
|
||||
(() => {
|
||||
const usageObj = parseObject(parsed.usage);
|
||||
return {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
})();
|
||||
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
@@ -690,12 +890,14 @@ export function heartbeatService(db: Db) {
|
||||
timedOut: false,
|
||||
errorMessage: (proc.exitCode ?? 0) === 0 ? null : `Claude exited with code ${proc.exitCode ?? -1}`,
|
||||
usage,
|
||||
sessionId: asString(parsed.session_id, runtime.sessionId ?? "") || runtime.sessionId,
|
||||
sessionId:
|
||||
parsedStream.sessionId ??
|
||||
(asString(parsed.session_id, runtime.sessionId ?? "") || runtime.sessionId),
|
||||
provider: "anthropic",
|
||||
model: asString(parsed.model, model),
|
||||
costUsd: asNumber(parsed.total_cost_usd, 0),
|
||||
model: parsedStream.model || asString(parsed.model, model),
|
||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||
resultJson: parsed,
|
||||
summary: asString(parsed.result, ""),
|
||||
summary: parsedStream.summary || asString(parsed.result, ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -706,26 +908,35 @@ export function heartbeatService(db: Db) {
|
||||
config: Record<string, unknown>,
|
||||
context: Record<string, unknown>,
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, false);
|
||||
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = asStringArray(config.extraArgs);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const sessionId = runtime.sessionId;
|
||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||
@@ -744,7 +955,23 @@ export function heartbeatService(db: Db) {
|
||||
if (sessionId) args.push("resume", sessionId, prompt);
|
||||
else args.push(prompt);
|
||||
|
||||
const proc = await runChildProcess(runId, "codex", args, {
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (!sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||
if (sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
@@ -888,16 +1115,25 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const context = (run.contextSnapshot ?? {}) as Record<string, unknown>;
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
await appendRunEvent(currentRun, seq++, {
|
||||
eventType: "adapter.invoke",
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "adapter invocation",
|
||||
payload: meta as Record<string, unknown>,
|
||||
});
|
||||
};
|
||||
|
||||
let adapterResult: AdapterExecutionResult;
|
||||
if (agent.adapterType === "http") {
|
||||
adapterResult = await executeHttpRun(run.id, agent.id, config, context);
|
||||
} else if (agent.adapterType === "claude_local") {
|
||||
adapterResult = await executeClaudeLocalRun(run.id, agent, runtime, config, context, onLog);
|
||||
adapterResult = await executeClaudeLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta);
|
||||
} else if (agent.adapterType === "codex_local") {
|
||||
adapterResult = await executeCodexLocalRun(run.id, agent, runtime, config, context, onLog);
|
||||
adapterResult = await executeCodexLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta);
|
||||
} else {
|
||||
adapterResult = await executeProcessRun(run.id, agent, config, onLog);
|
||||
adapterResult = await executeProcessRun(run.id, agent, config, onLog, onAdapterMeta);
|
||||
}
|
||||
|
||||
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||
@@ -926,6 +1162,14 @@ export function heartbeatService(db: Db) {
|
||||
? "timed_out"
|
||||
: "failed";
|
||||
|
||||
const usageJson =
|
||||
adapterResult.usage || adapterResult.costUsd != null
|
||||
? ({
|
||||
...(adapterResult.usage ?? {}),
|
||||
...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
await setRunStatus(run.id, status, {
|
||||
finishedAt: new Date(),
|
||||
error:
|
||||
@@ -942,7 +1186,7 @@ export function heartbeatService(db: Db) {
|
||||
: null,
|
||||
exitCode: adapterResult.exitCode,
|
||||
signal: adapterResult.signal,
|
||||
usageJson: (adapterResult.usage ?? null) as Record<string, unknown> | null,
|
||||
usageJson,
|
||||
resultJson: adapterResult.resultJson ?? null,
|
||||
sessionIdAfter: adapterResult.sessionId ?? runtime.sessionId,
|
||||
stdoutExcerpt,
|
||||
|
||||
Reference in New Issue
Block a user