Merge pull request #3354 from cryppadotta/pap-1331-runtime-workflows

fix: harden heartbeat and adapter runtime workflows
This commit is contained in:
Dotta
2026-04-11 06:31:28 -05:00
committed by GitHub
50 changed files with 14923 additions and 623 deletions
@@ -2,6 +2,24 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { runChildProcess } from "./server-utils.js";
function isPidAlive(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForPidExit(pid: number, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isPidAlive(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return !isPidAlive(pid);
}
describe("runChildProcess", () => {
it("waits for onSpawn before sending stdin to the child", async () => {
const spawnDelayMs = 150;
@@ -35,4 +53,36 @@ describe("runChildProcess", () => {
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
});
it.skipIf(process.platform === "win32")("kills descendant processes on timeout via the process group", async () => {
let descendantPid: number | null = null;
const result = await runChildProcess(
randomUUID(),
process.execPath,
[
"-e",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"process.stdout.write(String(child.pid));",
"setInterval(() => {}, 1000);",
].join(" "),
],
{
cwd: process.cwd(),
env: {},
timeoutSec: 1,
graceSec: 1,
onLog: async () => {},
onSpawn: async () => {},
},
);
descendantPid = Number.parseInt(result.stdout.trim(), 10);
expect(result.timedOut).toBe(true);
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
});
});
+30 -7
View File
@@ -19,6 +19,7 @@ export interface RunProcessResult {
interface RunningProcess {
child: ChildProcess;
graceSec: number;
processGroupId: number | null;
}
interface SpawnTarget {
@@ -34,6 +35,28 @@ type ChildProcessWithEvents = ChildProcess & {
): ChildProcess;
};
function resolveProcessGroupId(child: ChildProcess) {
if (process.platform === "win32") return null;
return typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
}
function signalRunningProcess(
running: Pick<RunningProcess, "child" | "processGroupId">,
signal: NodeJS.Signals,
) {
if (process.platform !== "win32" && running.processGroupId && running.processGroupId > 0) {
try {
process.kill(-running.processGroupId, signal);
return;
} catch {
// Fall back to the direct child signal if group signaling fails.
}
}
if (!running.child.killed) {
running.child.kill(signal);
}
}
export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
@@ -1034,7 +1057,7 @@ export async function runChildProcess(
graceSec: number;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onLogError?: (err: unknown, runId: string, message: string) => void;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
stdin?: string;
},
): Promise<RunProcessResult> {
@@ -1064,19 +1087,21 @@ export async function runChildProcess(
const child = spawn(target.command, target.args, {
cwd: opts.cwd,
env: mergedEnv,
detached: process.platform !== "win32",
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
const startedAt = new Date().toISOString();
const processGroupId = resolveProcessGroupId(child);
const spawnPersistPromise =
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
? opts.onSpawn({ pid: child.pid, processGroupId, startedAt }).catch((err) => {
onLogError(err, runId, "failed to record child process metadata");
})
: Promise.resolve();
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
runningProcesses.set(runId, { child, graceSec: opts.graceSec, processGroupId });
let timedOut = false;
let stdout = "";
@@ -1087,11 +1112,9 @@ export async function runChildProcess(
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
signalRunningProcess({ child, processGroupId }, "SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
signalRunningProcess({ child, processGroupId }, "SIGKILL");
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
+1 -1
View File
@@ -120,7 +120,7 @@ export interface AdapterExecutionContext {
context: Record<string, unknown>;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
authToken?: string;
}
@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
@@ -33,35 +32,10 @@ import {
} from "./parse.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
/**
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
* them as proper registered skills.
*/
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredNames = new Set(
resolveClaudeDesiredSkillNames(
config,
availableEntries,
),
);
for (const entry of availableEntries) {
if (!desiredNames.has(entry.key)) continue;
await fs.symlink(
entry.source,
path.join(target, entry.runtimeName),
);
}
return tmp;
}
interface ClaudeExecutionInput {
runId: string;
agent: AdapterExecutionContext["agent"];
@@ -361,30 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
),
);
const billingType = resolveClaudeBillingType(effectiveEnv);
const skillsDir = await buildSkillsDir(config);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
let effectiveInstructionsFilePath: string | undefined;
let preparedInstructionsFile = false;
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
if (resumeSessionId || !instructionsFilePath) return undefined;
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
preparedInstructionsFile = true;
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
// When instructionsFilePath is configured, build a stable content-addressed
// file that includes both the file content and the path directive, so we only
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
let combinedInstructionsContents: string | null = null;
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
const pathDirective =
@@ -392,20 +349,50 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Resolve any relative file references from ${instructionsFileDir}. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
const combinedPath = path.join(skillsDir, "agent-instructions.md");
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
effectiveInstructionsFilePath = combinedPath;
combinedInstructionsContents = instructionsContent + pathDirective;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
effectiveInstructionsFilePath = undefined;
}
}
const promptBundle = await prepareClaudePromptBundle({
companyId: agent.companyId,
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
instructionsContents: combinedInstructionsContents,
onLog,
});
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
return effectiveInstructionsFilePath;
};
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
const hasMatchingPromptBundle =
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
const canResumeSession =
runtimeSessionId.length > 0 &&
hasMatchingPromptBundle &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (
runtimeSessionId &&
runtimeSessionCwd.length > 0 &&
path.resolve(runtimeSessionCwd) !== path.resolve(cwd)
) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
);
}
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
@@ -460,7 +447,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (attemptInstructionsFilePath && !resumeSessionId) {
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
}
args.push("--add-dir", skillsDir);
args.push("--add-dir", promptBundle.addDir);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
@@ -482,14 +469,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath;
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
const commandNotes =
attemptInstructionsFilePath && !resumeSessionId
? [
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
]
: [];
const commandNotes: string[] = [];
if (!resumeSessionId) {
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
}
if (attemptInstructionsFilePath && !resumeSessionId) {
commandNotes.push(
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
);
}
if (onMeta) {
await onMeta({
adapterType: "claude_local",
@@ -586,6 +576,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? ({
sessionId: resolvedSessionId,
cwd,
promptBundleKey: promptBundle.bundleKey,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
@@ -618,25 +609,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
};
try {
const initial = await runAttempt(sessionId ?? null);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
initial.parsed &&
isClaudeUnknownSessionError(initial.parsed)
) {
await onLog(
"stdout",
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
const initial = await runAttempt(sessionId ?? null);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
initial.parsed &&
isClaudeUnknownSessionError(initial.parsed)
) {
await onLog(
"stdout",
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
}
@@ -36,12 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const promptBundleKey =
readNonEmptyString(record.promptBundleKey) ??
readNonEmptyString(record.prompt_bundle_key);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
@@ -55,12 +59,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const promptBundleKey =
readNonEmptyString(params.promptBundleKey) ??
readNonEmptyString(params.prompt_bundle_key);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
@@ -0,0 +1,172 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash, type Hash } from "node:crypto";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
type SkillEntry = PaperclipSkillEntry;
export interface ClaudePromptBundle {
bundleKey: string;
rootDir: string;
addDir: string;
instructionsFilePath: string | null;
}
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function resolveManagedClaudePromptCacheRoot(
env: NodeJS.ProcessEnv,
companyId: string,
): string {
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
return path.resolve(
paperclipHome,
"instances",
instanceId,
"companies",
companyId,
"claude-prompt-cache",
);
}
async function hashPathContents(
candidate: string,
hash: Hash,
relativePath: string,
seenDirectories: Set<string>,
): Promise<void> {
const stat = await fs.lstat(candidate);
if (stat.isSymbolicLink()) {
hash.update(`symlink:${relativePath}\n`);
const resolved = await fs.realpath(candidate).catch(() => null);
if (!resolved) {
hash.update("missing\n");
return;
}
await hashPathContents(resolved, hash, relativePath, seenDirectories);
return;
}
if (stat.isDirectory()) {
const realDir = await fs.realpath(candidate).catch(() => candidate);
hash.update(`dir:${relativePath}\n`);
if (seenDirectories.has(realDir)) {
hash.update("loop\n");
return;
}
seenDirectories.add(realDir);
const entries = await fs.readdir(candidate, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
}
return;
}
if (stat.isFile()) {
hash.update(`file:${relativePath}\n`);
hash.update(await fs.readFile(candidate));
hash.update("\n");
return;
}
hash.update(`other:${relativePath}:${stat.mode}\n`);
}
async function buildClaudePromptBundleKey(input: {
skills: SkillEntry[];
instructionsContents: string | null;
}): Promise<string> {
const hash = createHash("sha256");
hash.update("paperclip-claude-prompt-bundle:v1\n");
if (input.instructionsContents) {
hash.update("instructions\n");
hash.update(input.instructionsContents);
hash.update("\n");
} else {
hash.update("instructions:none\n");
}
const sortedSkills = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName));
for (const entry of sortedSkills) {
hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
await hashPathContents(entry.source, hash, entry.runtimeName, new Set<string>());
}
return hash.digest("hex");
}
async function ensureReadableFile(targetPath: string, contents: string): Promise<void> {
try {
await fs.access(targetPath, fsConstants.R_OK);
return;
} catch {
// Fall through and materialize the file.
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(tempPath, contents, "utf8");
await fs.rename(tempPath, targetPath);
} catch (err) {
const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false);
if (!targetReadable) {
throw err;
}
} finally {
await fs.rm(tempPath, { force: true }).catch(() => {});
}
}
export async function prepareClaudePromptBundle(input: {
companyId: string;
skills: SkillEntry[];
instructionsContents: string | null;
onLog: AdapterExecutionContext["onLog"];
}): Promise<ClaudePromptBundle> {
const { companyId, skills, instructionsContents, onLog } = input;
const bundleKey = await buildClaudePromptBundleKey({
skills,
instructionsContents,
});
const rootDir = path.join(resolveManagedClaudePromptCacheRoot(process.env, companyId), bundleKey);
const skillsHome = path.join(rootDir, ".claude", "skills");
await fs.mkdir(skillsHome, { recursive: true });
for (const entry of skills) {
const target = path.join(skillsHome, entry.runtimeName);
try {
await ensurePaperclipSkillSymlink(entry.source, target);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
const instructionsFilePath = instructionsContents
? path.join(rootDir, "agent-instructions.md")
: null;
if (instructionsFilePath && instructionsContents) {
await ensureReadableFile(instructionsFilePath, instructionsContents);
}
return {
bundleKey,
rootDir,
addDir: rootDir,
instructionsFilePath,
};
}
@@ -47,7 +47,7 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be mounted into the ephemeral Claude skill directory on the next run."
? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
@@ -27,6 +27,39 @@ describe("parseCodexJsonl", () => {
errorMessage: "resume failed",
});
});
it("uses the last agent message as the summary when commentary updates precede the final answer", () => {
const stdout = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "reasoning", text: "Checking the heartbeat procedure" },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Im checking out the issue and reading the docs now." },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Fixed the issue and verified the targeted tests pass." },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
].join("\n");
expect(parseCodexJsonl(stdout)).toEqual({
sessionId: "thread_123",
summary: "Fixed the issue and verified the targeted tests pass.",
usage: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 4,
},
errorMessage: null,
});
});
});
describe("isCodexUnknownSessionError", () => {
@@ -2,7 +2,7 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter
export function parseCodexJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let finalMessage: string | null = null;
let errorMessage: string | null = null;
const usage = {
inputTokens: 0,
@@ -33,7 +33,7 @@ export function parseCodexJsonl(stdout: string) {
const item = parseObject(event.item);
if (asString(item.type, "") === "agent_message") {
const text = asString(item.text, "");
if (text) messages.push(text);
if (text) finalMessage = text;
}
continue;
}
@@ -55,7 +55,7 @@ export function parseCodexJsonl(stdout: string) {
return {
sessionId,
summary: messages.join("\n\n").trim(),
summary: finalMessage?.trim() ?? "",
usage,
errorMessage,
};
@@ -0,0 +1 @@
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_group_id" integer;--> statement-breakpoint
File diff suppressed because it is too large Load Diff
@@ -386,6 +386,13 @@
"when": 1775750400000,
"tag": "0054_draft_routines",
"breakpoints": true
},
{
"idx": 55,
"version": "7",
"when": 1775825256196,
"tag": "0055_kind_weapon_omega",
"breakpoints": true
}
]
}
}
+1
View File
@@ -32,6 +32,7 @@ export const heartbeatRuns = pgTable(
errorCode: text("error_code"),
externalRunId: text("external_run_id"),
processPid: integer("process_pid"),
processGroupId: integer("process_group_id"),
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
onDelete: "set null",
+35 -21
View File
@@ -6,7 +6,10 @@ import type {
TelemetryState,
} from "./types.js";
const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest";
const DEFAULT_ENDPOINTS = [
"https://telemetry.paperclip.ing/ingest",
"https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest",
] as const;
const BATCH_SIZE = 50;
const SEND_TIMEOUT_MS = 5_000;
@@ -44,29 +47,35 @@ export class TelemetryClient {
const events = this.queue.splice(0);
const state = this.getState();
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
const endpoints = this.resolveEndpoints();
const app = this.config.app ?? "paperclip";
const schemaVersion = this.config.schemaVersion ?? "1";
const body = JSON.stringify({
app,
schemaVersion,
installId: state.installId,
version: this.version,
events,
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
try {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app,
schemaVersion,
installId: state.installId,
version: this.version,
events,
}),
signal: controller.signal,
});
} catch {
// Fire-and-forget: silent failure, no retries
} finally {
clearTimeout(timer);
for (const endpoint of endpoints) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: controller.signal,
});
if (response.ok) {
return;
}
} catch {
// Try the next built-in endpoint before dropping the batch.
} finally {
clearTimeout(timer);
}
}
}
@@ -102,4 +111,9 @@ export class TelemetryClient {
}
return this.state;
}
private resolveEndpoints(): readonly string[] {
const configured = this.config.endpoint?.trim();
return configured ? [configured] : DEFAULT_ENDPOINTS;
}
}
+1
View File
@@ -34,6 +34,7 @@ export interface HeartbeatRun {
errorCode: string | null;
externalRunId: string | null;
processPid: number | null;
processGroupId?: number | null;
processStartedAt: Date | null;
retryOfRunId: string | null;
processLossRetryCount: number;
@@ -19,16 +19,19 @@ describe("adapter session codecs", () => {
const parsed = claudeSessionCodec.deserialize({
session_id: "claude-session-1",
folder: "/tmp/workspace",
prompt_bundle_key: "bundle-1",
});
expect(parsed).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
promptBundleKey: "bundle-1",
});
const serialized = claudeSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
promptBundleKey: "bundle-1",
});
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
});
@@ -298,15 +298,6 @@ describe("agent instructions bundle routes", () => {
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
command: "codex --profile engineer",
}),
}),
expect.any(Object),
);
expect(res.body.adapterConfig).toMatchObject({
command: "codex --profile engineer",
});
@@ -0,0 +1,117 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
getRunIssueSummary: vi.fn(),
getActiveRunIssueSummaryForAgent: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => ({}),
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(),
secretService: () => ({}),
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
findActiveServerAdapter: vi.fn(),
requireServerAdapter: vi.fn(),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("agent live run routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
executionRunId: "run-1",
assigneeAgentId: "agent-1",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(null);
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
name: "Builder",
adapterType: "codex_local",
});
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-1",
status: "running",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: new Date("2026-04-10T09:30:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-04-10T09:29:59.000Z"),
agentId: "agent-1",
issueId: "issue-1",
});
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
});
it("returns a compact active run payload for issue polling", async () => {
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
expect(res.body).toEqual({
id: "run-1",
status: "running",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: "2026-04-10T09:30:00.000Z",
finishedAt: null,
createdAt: "2026-04-10T09:29:59.000Z",
agentId: "agent-1",
issueId: "issue-1",
agentName: "Builder",
adapterType: "codex_local",
});
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
});
@@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -88,32 +90,30 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
}
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
function createDbStub() {
return {
@@ -131,11 +131,7 @@ function createDbStub() {
};
}
async function createApp(actor: Record<string, unknown>) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
import("../routes/agents.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -149,8 +145,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("agent permission routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
@@ -197,7 +191,7 @@ describe("agent permission routes", () => {
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -233,7 +227,7 @@ describe("agent permission routes", () => {
});
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -255,7 +249,7 @@ describe("agent permission routes", () => {
},
});
expect(res.status).toBe(201);
expect([200, 201]).toContain(res.status);
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
@@ -270,7 +264,7 @@ describe("agent permission routes", () => {
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -321,7 +315,7 @@ describe("agent permission routes", () => {
},
]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -342,7 +336,7 @@ describe("agent permission routes", () => {
permissions: { canCreateAgents: true },
});
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@@ -377,7 +371,7 @@ describe("agent permission routes", () => {
},
]);
const app = await createApp({
const app = createApp({
type: "agent",
agentId,
companyId,
@@ -408,7 +402,7 @@ describe("agent permission routes", () => {
status: "running",
});
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
@@ -57,7 +57,7 @@ const mockAdapter = vi.hoisted(() => ({
syncSkills: vi.fn(),
}));
function registerRouteMocks() {
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
@@ -149,7 +149,7 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.resolveByReference.mockResolvedValue({
@@ -238,9 +238,6 @@ describe("agent skill routes", () => {
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
expect.objectContaining({
adapterType: "claude_local",
@@ -266,9 +263,6 @@ describe("agent skill routes", () => {
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
});
it("keeps runtime materialization for persistent skill adapters", async () => {
@@ -286,9 +280,6 @@ describe("agent skill routes", () => {
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: true,
});
});
it("skips runtime materialization when syncing Claude skills", async () => {
@@ -299,9 +290,6 @@ describe("agent skill routes", () => {
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
expect(mockAdapter.syncSkills).toHaveBeenCalled();
});
@@ -313,7 +301,6 @@ describe("agent skill routes", () => {
.send({ desiredSkills: ["paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockAgentService.update).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
@@ -339,7 +326,6 @@ describe("agent skill routes", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
@@ -367,7 +353,7 @@ describe("agent skill routes", () => {
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@@ -403,7 +389,7 @@ describe("agent skill routes", () => {
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@@ -430,7 +416,7 @@ describe("agent skill routes", () => {
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@@ -458,7 +444,6 @@ describe("agent skill routes", () => {
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalRoutes } from "../routes/approvals.js";
import { errorHandler } from "../middleware/index.js";
const mockApprovalService = vi.hoisted(() => ({
list: vi.fn(),
@@ -39,7 +37,11 @@ vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
}));
function createApp(actorOverrides: Record<string, unknown> = {}) {
async function createApp(actorOverrides: Record<string, unknown> = {}) {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -58,7 +60,11 @@ function createApp(actorOverrides: Record<string, unknown> = {}) {
return app;
}
function createAgentApp() {
async function createAgentApp() {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -78,7 +84,8 @@ function createAgentApp() {
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.resetAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
@@ -105,7 +112,7 @@ describe("approval routes idempotent retries", () => {
applied: false,
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-1/approve")
.send({});
@@ -134,7 +141,7 @@ describe("approval routes idempotent retries", () => {
applied: false,
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-1/reject")
.send({});
@@ -151,7 +158,7 @@ describe("approval routes idempotent retries", () => {
payload: {},
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-2/approve")
.send({});
@@ -168,7 +175,7 @@ describe("approval routes idempotent retries", () => {
payload: {},
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-3/request-revision")
.send({ decisionNote: "Need changes" });
@@ -192,7 +199,7 @@ describe("approval routes idempotent retries", () => {
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
});
const res = await request(createAgentApp())
const res = await request(await createAgentApp())
.post("/api/companies/company-1/approvals")
.send({
type: "request_board_approval",
+278 -42
View File
@@ -7,13 +7,23 @@ import { execute } from "@paperclipai/adapter-claude-local/server";
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const argv = process.argv.slice(2);
const addDirIndex = argv.indexOf("--add-dir");
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
const payload = {
argv: process.argv.slice(2),
argv,
prompt: fs.readFileSync(0, "utf8"),
addDir,
instructionsFilePath,
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
appendedSystemPromptFilePath,
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
@@ -29,6 +39,18 @@ console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", res
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
prompt: string;
addDir: string | null;
instructionsFilePath: string | null;
instructionsContents: string | null;
skillEntries: string[];
claudeConfigDir: string | null;
appendedSystemPromptFilePath?: string | null;
appendedSystemPromptFileContents?: string | null;
};
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
@@ -232,47 +254,6 @@ describe("claude execute", () => {
}
});
/**
* Regression test for unnecessary file I/O on resumed sessions (Greptile P2).
*
* The combined agent-instructions.md temp file must NOT be written when
* resuming, since the instructions are already baked into the session cache.
*/
it("does not write agent-instructions temp file on a resumed session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-"));
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
const instructionsFile = path.join(root, "instructions.md");
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
try {
await execute({
runId: "run-io-resume",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
env: {},
promptTemplate: "Do work.",
instructionsFilePath: instructionsFile,
},
context: {},
authToken: "tok",
onLog: async () => {},
onMeta: async () => {},
});
// The skills dir lives under HOME/.paperclip/skills — verify no combined
// agent-instructions.md was written anywhere under root on a resume.
const allFiles = await fs.readdir(root, { recursive: true });
const tempInstructionsWritten = (allFiles as string[]).some((f) =>
f.includes("agent-instructions.md"),
);
expect(tempInstructionsWritten).toBe(false);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
@@ -406,4 +387,259 @@ describe("claude execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath1 = path.join(root, "capture-1.json");
const capturePath2 = path.join(root, "capture-2.json");
const instructionsPath = path.join(root, "AGENTS.md");
const paperclipHome = path.join(root, "paperclip-home");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
await writeFakeClaudeCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
try {
const first = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(first.exitCode).toBe(0);
expect(first.errorMessage).toBeNull();
expect(first.sessionParams).toMatchObject({
sessionId: "claude-session-1",
cwd: workspace,
});
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
const second = await execute({
runId: "run-2",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_commented",
wakeCommentId: "comment-2",
paperclipWake: {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-874",
title: "chat-speed issues",
status: "in_progress",
priority: "medium",
},
commentIds: ["comment-2"],
latestCommentId: "comment-2",
comments: [
{
id: "comment-2",
issueId: "issue-1",
body: "Second comment",
bodyTruncated: false,
createdAt: "2026-03-28T14:35:10.000Z",
author: { type: "user", id: "user-1" },
},
],
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(second.exitCode).toBe(0);
expect(second.errorMessage).toBeNull();
const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
const expectedRoot = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"claude-prompt-cache",
);
expect(capture1.addDir).toBeTruthy();
expect(capture1.addDir).toBe(capture2.addDir);
expect(capture1.instructionsFilePath).toBeTruthy();
expect(capture2.instructionsFilePath ?? null).toBeNull();
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsContents).toContain("You are managed instructions.");
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
expect(capture1.skillEntries).toContain("paperclip");
expect(capture2.argv).toContain("--resume");
expect(capture2.argv).toContain("claude-session-1");
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath1 = path.join(root, "capture-before.json");
const capturePath2 = path.join(root, "capture-after.json");
const instructionsPath = path.join(root, "AGENTS.md");
const paperclipHome = path.join(root, "paperclip-home");
const logs: string[] = [];
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
await writeFakeClaudeCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
try {
const first = await execute({
runId: "run-before",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
const second = await execute({
runId: "run-after",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
});
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(second.errorMessage).toBeNull();
const before = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
const after = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
expect(before.instructionsFilePath).not.toBe(after.instructionsFilePath);
expect(after.argv).not.toContain("--resume");
expect(after.prompt).toContain("Follow the paperclip heartbeat.");
expect(logs.join("")).toContain("will not be resumed with");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
await fs.rm(root, { recursive: true, force: true });
}
}, 15_000);
});
+26 -32
View File
@@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
@@ -25,16 +27,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
function createApp(actor: any) {
const app = express();
@@ -43,28 +43,22 @@ function createApp(actor: any) {
req.actor = actor;
next();
});
return import("../routes/access.js").then(({ accessRoutes }) =>
import("../middleware/index.js").then(({ errorHandler }) => {
app.use(
"/api",
accessRoutes({} as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
})
app.use(
"/api",
accessRoutes({} as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
});
it("creates a CLI auth challenge with approval metadata", async () => {
@@ -77,7 +71,7 @@ describe("cli auth routes", () => {
pendingBoardToken: "pcp_board_token",
});
const app = await createApp({ type: "none", source: "none" });
const app = createApp({ type: "none", source: "none" });
const res = await request(app)
.post("/api/cli-auth/challenges")
.send({
@@ -113,7 +107,7 @@ describe("cli auth routes", () => {
approvedByUser: null,
});
const app = await createApp({ type: "none", source: "none" });
const app = createApp({ type: "none", source: "none" });
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(res.status).toBe(200);
@@ -139,7 +133,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -179,7 +173,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
@@ -206,7 +200,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "admin-2",
keyId: "board-key-3",
@@ -39,17 +39,15 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-03-19T02:00:00.000Z");
@@ -90,7 +88,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
});
@@ -20,23 +20,21 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
@@ -57,8 +55,7 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
imported: [],
+35 -46
View File
@@ -71,21 +71,19 @@ const mockBudgetService = vi.hoisted(() => ({
resolveIncident: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.doMock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
}
vi.mock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
async function createApp() {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
@@ -119,10 +117,14 @@ async function createAppWithActor(actor: any) {
return app;
}
async function loadCostParsers() {
const { parseCostDateRange, parseCostLimit } = await import("../routes/costs.js");
return { parseCostDateRange, parseCostLimit };
}
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -140,30 +142,25 @@ beforeEach(() => {
});
describe("cost routes", () => {
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/summary")
.query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" });
expect(res.status).toBe(200);
it("accepts valid ISO date strings", async () => {
const { parseCostDateRange } = await loadCostParsers();
expect(parseCostDateRange({
from: "2026-01-01T00:00:00.000Z",
to: "2026-01-31T23:59:59.999Z",
})).toEqual({
from: new Date("2026-01-01T00:00:00.000Z"),
to: new Date("2026-01-31T23:59:59.999Z"),
});
});
it("returns 400 for an invalid 'from' date string", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/summary")
.query({ from: "not-a-date" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid 'from' date/i);
const { parseCostDateRange } = await loadCostParsers();
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
});
it("returns 400 for an invalid 'to' date string", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/summary")
.query({ to: "banana" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid 'to' date/i);
const { parseCostDateRange } = await loadCostParsers();
expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i);
});
it("returns finance summary rows for valid requests", async () => {
@@ -176,21 +173,13 @@ describe("cost routes", () => {
});
it("returns 400 for invalid finance event list limits", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/finance-events")
.query({ limit: "0" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid 'limit'/i);
const { parseCostLimit } = await loadCostParsers();
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
});
it("accepts valid finance event list limits", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/finance-events")
.query({ limit: "25" });
expect(res.status).toBe(200);
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
const { parseCostLimit } = await loadCostParsers();
expect(parseCostLimit({ limit: "25" })).toBe(25);
});
it("rejects company budget updates for board users outside the company", async () => {
@@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat list tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("heartbeat list", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-list-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("returns runs even when the linked db schema lacks processGroupId", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: randomUUID() },
});
const originalDescriptor = Object.getOwnPropertyDescriptor(heartbeatRuns, "processGroupId");
Object.defineProperty(heartbeatRuns, "processGroupId", {
value: undefined,
configurable: true,
writable: true,
});
try {
const runs = await heartbeatService(db).list(companyId, agentId, 5);
expect(runs).toHaveLength(1);
expect(runs[0]?.id).toBe(runId);
expect(runs[0]?.processGroupId ?? null).toBeNull();
} finally {
if (originalDescriptor) {
Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor);
} else {
delete (heartbeatRuns as Record<string, unknown>).processGroupId;
}
}
});
});
@@ -49,10 +49,70 @@ function spawnAliveProcess() {
});
}
function isPidAlive(pid: number | null | undefined) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForPidExit(pid: number, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isPidAlive(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return !isPidAlive(pid);
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
[
"-e",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"process.stdout.write(String(child.pid));",
"setTimeout(() => process.exit(0), 25);",
].join(" "),
],
{
detached: true,
stdio: ["ignore", "pipe", "ignore"],
},
);
let stdout = "";
leader.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
await new Promise<void>((resolve, reject) => {
leader.once("error", reject);
leader.once("exit", () => resolve());
});
const descendantPid = Number.parseInt(stdout.trim(), 10);
if (!Number.isInteger(descendantPid) || descendantPid <= 0) {
throw new Error(`Failed to capture orphaned descendant pid from detached process group: ${stdout}`);
}
return {
processPid: leader.pid ?? null,
processGroupId: leader.pid ?? null,
descendantPid,
};
}
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const childProcesses = new Set<ChildProcess>();
const cleanupPids = new Set<number>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
@@ -66,6 +126,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
child.kill("SIGKILL");
}
childProcesses.clear();
for (const pid of cleanupPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore already-dead cleanup targets.
}
}
cleanupPids.clear();
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
@@ -79,6 +147,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
child.kill("SIGKILL");
}
childProcesses.clear();
for (const pid of cleanupPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore already-dead cleanup targets.
}
}
cleanupPids.clear();
runningProcesses.clear();
await tempDb?.cleanup();
});
@@ -88,6 +164,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
agentStatus?: "paused" | "idle" | "running";
runStatus?: "running" | "queued" | "failed";
processPid?: number | null;
processGroupId?: number | null;
processLossRetryCount?: number;
includeIssue?: boolean;
runErrorCode?: string | null;
@@ -143,6 +220,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
wakeupRequestId,
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
processPid: input?.processPid ?? null,
processGroupId: input?.processGroupId ?? null,
processLossRetryCount: input?.processLossRetryCount ?? 0,
errorCode: input?.runErrorCode ?? null,
error: input?.runError ?? null,
@@ -228,6 +306,45 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(issue?.checkoutRunId).toBe(runId);
});
it.skipIf(process.platform === "win32")("reaps orphaned descendant process groups when the parent pid is already gone", async () => {
const orphan = await spawnOrphanedProcessGroup();
cleanupPids.add(orphan.descendantPid);
expect(isPidAlive(orphan.descendantPid)).toBe(true);
const { agentId, runId, issueId } = await seedRunFixture({
processPid: orphan.processPid,
processGroupId: orphan.processGroupId,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(1);
expect(result.runIds).toEqual([runId]);
expect(await waitForPidExit(orphan.descendantPid, 2_000)).toBe(true);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const failedRun = runs.find((row) => row.id === runId);
expect(failedRun?.status).toBe("failed");
expect(failedRun?.errorCode).toBe("process_lost");
expect(failedRun?.error).toContain("descendant process group");
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.status).toBe("queued");
const issue = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
});
it("does not queue a second retry after the first process-loss retry was already used", async () => {
const { agentId, runId, issueId } = await seedRunFixture({
processPid: 999_999_999,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
summarizeHeartbeatRunResultJson,
buildHeartbeatRunIssueComment,
mergeHeartbeatRunResultJson,
} from "../services/heartbeat-run-summary.js";
describe("summarizeHeartbeatRunResultJson", () => {
@@ -55,3 +56,35 @@ describe("buildHeartbeatRunIssueComment", () => {
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
});
});
describe("mergeHeartbeatRunResultJson", () => {
it("adds adapter summaries into stored result json for comment posting", () => {
const merged = mergeHeartbeatRunResultJson(
{ stdout: "raw stdout", stderr: "" },
"## Summary\n\n1. first thing\n2. second thing",
);
expect(merged).toEqual({
stdout: "raw stdout",
stderr: "",
summary: "## Summary\n\n1. first thing\n2. second thing",
});
expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing");
});
it("creates a result payload when only a summary exists", () => {
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" });
});
it("does not overwrite an explicit summary already returned by the adapter", () => {
expect(
mergeHeartbeatRunResultJson(
{ summary: "adapter result", stdout: "raw stdout" },
"fallback summary",
),
).toEqual({
summary: "adapter result",
stdout: "raw stdout",
});
});
});
@@ -11,12 +11,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
async function createApp(actor: any) {
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
@@ -37,8 +35,7 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
@@ -1,8 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@@ -170,7 +170,7 @@ describe("issue activity event routes", () => {
}),
}),
);
});
}, 15_000);
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({
@@ -38,48 +38,49 @@ const mockTx = vi.hoisted(() => ({
const mockDb = vi.hoisted(() => ({
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
function createApp() {
const app = express();
@@ -134,7 +135,7 @@ function makeIssue(status: "todo" | "done") {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockIssueService.getById.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.update.mockReset();
@@ -151,6 +152,11 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
mockFeedbackService.saveIssueVote.mockReset();
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockRoutineService.syncRunStatusForIssue.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDb.transaction.mockReset();
@@ -163,6 +169,21 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
@@ -21,56 +21,60 @@ const mockIssueService = vi.hoisted(() => ({
const mockFeedbackExportService = vi.hoisted(() => ({
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
async function createApp(actor: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@@ -91,13 +95,27 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue feedback trace routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
attempted: 1,
sent: 1,
failed: 0,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {
@@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -16,41 +18,39 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function makeIssue(status: "todo" | "done") {
return {
@@ -65,11 +65,7 @@ function makeIssue(status: "todo" | "done") {
};
}
async function createApp(actor: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -83,8 +79,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue telemetry routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
@@ -104,7 +98,7 @@ describe("issue telemetry routes", () => {
adapterType: "codex_local",
});
const app = await createApp({
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@@ -123,7 +117,7 @@ describe("issue telemetry routes", () => {
}, 10_000);
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
+56
View File
@@ -0,0 +1,56 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { llmRoutes } from "../routes/llms.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockListServerAdapters = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
}));
vi.mock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", llmRoutes({} as never));
app.use(errorHandler);
return app;
}
describe("llm routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListServerAdapters.mockReturnValue([
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
]);
});
it("documents timer heartbeats as opt-in for new hires", async () => {
const app = createApp({
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: true,
});
const res = await request(app).get("/api/llms/agent-configuration.txt");
expect(res.status).toBe(200);
expect(res.text).toContain("Timer heartbeats are opt-in for new hires.");
expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false");
});
});
@@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
@@ -33,16 +35,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const createdInvite = {
@@ -99,11 +99,7 @@ function createDbStub() {
};
}
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -125,9 +121,7 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
mockLogActivity.mockResolvedValue(undefined);
@@ -140,7 +134,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "engineer",
});
const app = await createApp(
const app = createApp(
{
type: "agent",
agentId: "agent-1",
@@ -165,7 +159,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "ceo",
});
const app = await createApp(
const app = createApp(
{
type: "agent",
agentId: "agent-1",
@@ -193,7 +187,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",
@@ -215,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",
@@ -238,12 +232,12 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
allowedJoinTypes: "agent",
}),
);
});
}, 15_000);
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
const unknownHostname = "blocked-host.invalid";
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
const app = express();
app.use(
privateHostnameGuard({
@@ -24,33 +24,37 @@ function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHo
}
describe("privateHostnameGuard", () => {
beforeEach(() => {
vi.resetModules();
});
it("allows requests when disabled", async () => {
const app = createApp({ enabled: false });
const app = await createApp({ enabled: false });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("allows loopback hostnames", async () => {
const app = createApp({ enabled: true });
const app = await createApp({ enabled: true });
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
expect(res.status).toBe(200);
});
it("allows explicitly configured hostnames", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("blocks unknown hostnames with remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
+17 -17
View File
@@ -21,21 +21,23 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
}
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
@@ -97,6 +99,8 @@ function buildProject(overrides: Record<string, unknown> = {}) {
describe("project env routes", () => {
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
@@ -160,10 +164,6 @@ describe("project env routes", () => {
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockProjectService.update).toHaveBeenCalledWith(
"project-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => {
expect(fetch).not.toHaveBeenCalled();
});
it("falls back to the api gateway ingest url when the default hostname fails", async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new TypeError("getaddrinfo ENOTFOUND telemetry.paperclip.ing"))
.mockResolvedValueOnce({ ok: true });
const client = makeClient({ endpoint: undefined });
client.track("install.started");
await client.flush();
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://telemetry.paperclip.ing/ingest");
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toBe("https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest");
});
it("startPeriodicFlush is idempotent", () => {
const client = makeClient();
client.startPeriodicFlush(1000);
+1 -1
View File
@@ -13,7 +13,7 @@ type BuildInvocationEnvForLogsOptions = {
resolvedCommandEnvKey?: string;
};
export const runningProcesses: Map<string, { child: ChildProcess; graceSec: number }> =
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;
+4 -5
View File
@@ -2441,15 +2441,14 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, issue.companyId);
let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
if (run && run.status !== "queued" && run.status !== "running") {
run = null;
}
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
const candidateRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
const candidateContext = asRecord(candidateRun?.contextSnapshot);
const candidateIssueId = asNonEmptyString(candidateContext?.issueId);
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
if (candidateRun && candidateIssueId === issue.id) {
run = candidateRun;
}
@@ -2466,7 +2465,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
...run,
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,
+31 -31
View File
@@ -21,6 +21,26 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
import { badRequest } from "../errors.js";
export function parseCostDateRange(query: Record<string, unknown>) {
const fromRaw = query.from as string | undefined;
const toRaw = query.to as string | undefined;
const from = fromRaw ? new Date(fromRaw) : undefined;
const to = toRaw ? new Date(toRaw) : undefined;
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
return (from || to) ? { from, to } : undefined;
}
export function parseCostLimit(query: Record<string, unknown>) {
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
if (raw == null || raw === "") return 100;
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
}
export function costRoutes(db: Db) {
const router = Router();
const heartbeat = heartbeatService(db);
@@ -92,30 +112,10 @@ export function costRoutes(db: Db) {
res.status(201).json(event);
});
function parseDateRange(query: Record<string, unknown>) {
const fromRaw = query.from as string | undefined;
const toRaw = query.to as string | undefined;
const from = fromRaw ? new Date(fromRaw) : undefined;
const to = toRaw ? new Date(toRaw) : undefined;
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
return (from || to) ? { from, to } : undefined;
}
function parseLimit(query: Record<string, unknown>) {
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
if (raw == null || raw === "") return 100;
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
}
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const summary = await costs.summary(companyId, range);
res.json(summary);
});
@@ -123,7 +123,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await costs.byAgent(companyId, range);
res.json(rows);
});
@@ -131,7 +131,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await costs.byAgentModel(companyId, range);
res.json(rows);
});
@@ -139,7 +139,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await costs.byProvider(companyId, range);
res.json(rows);
});
@@ -147,7 +147,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await costs.byBiller(companyId, range);
res.json(rows);
});
@@ -155,7 +155,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const summary = await finance.summary(companyId, range);
res.json(summary);
});
@@ -163,7 +163,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await finance.byBiller(companyId, range);
res.json(rows);
});
@@ -171,7 +171,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await finance.byKind(companyId, range);
res.json(rows);
});
@@ -179,8 +179,8 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const limit = parseLimit(req.query);
const range = parseCostDateRange(req.query);
const limit = parseCostLimit(req.query);
const rows = await finance.list(companyId, range, limit);
res.json(rows);
});
@@ -242,7 +242,7 @@ export function costRoutes(db: Db) {
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const range = parseCostDateRange(req.query);
const rows = await costs.byProject(companyId, range);
res.json(rows);
});
+1
View File
@@ -45,6 +45,7 @@ export function llmRoutes(db: Db) {
"Notes:",
"- Sensitive values are redacted in configuration read APIs.",
"- New hires may be created in pending_approval state depending on company settings.",
"- Timer heartbeats are opt-in for new hires. Leave runtimeConfig.heartbeat.enabled false unless the role truly needs scheduled work or the user explicitly asked for it.",
"",
];
res.type("text/plain").send(lines.join("\n"));
@@ -13,6 +13,34 @@ function readCommentText(value: unknown) {
return trimmed.length > 0 ? trimmed : null;
}
export function mergeHeartbeatRunResultJson(
resultJson: Record<string, unknown> | null | undefined,
summary: string | null | undefined,
): Record<string, unknown> | null {
const normalizedSummary = readCommentText(summary);
const baseResult =
resultJson && typeof resultJson === "object" && !Array.isArray(resultJson)
? resultJson
: null;
if (!baseResult) {
return normalizedSummary ? { summary: normalizedSummary } : null;
}
if (!normalizedSummary) {
return baseResult;
}
if (readCommentText(baseResult.summary)) {
return baseResult;
}
return {
...baseResult,
summary: normalizedSummary,
};
}
export function summarizeHeartbeatRunResultJson(
resultJson: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
+139 -18
View File
@@ -32,7 +32,11 @@ import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
import {
buildHeartbeatRunIssueComment,
mergeHeartbeatRunResultJson,
summarizeHeartbeatRunResultJson,
} from "./heartbeat-run-summary.js";
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
@@ -47,6 +51,7 @@ import {
import { issueService } from "./issues.js";
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { workspaceOperationService } from "./workspace-operations.js";
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
@@ -261,6 +266,9 @@ async function ensureManagedProjectWorkspace(input: {
}
}
const heartbeatRunProcessGroupIdColumn =
heartbeatRuns.processGroupId ?? sql<number | null>`NULL`.as("processGroupId");
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
@@ -288,6 +296,7 @@ const heartbeatRunListColumns = {
errorCode: heartbeatRuns.errorCode,
externalRunId: heartbeatRuns.externalRunId,
processPid: heartbeatRuns.processPid,
processGroupId: heartbeatRunProcessGroupIdColumn,
processStartedAt: heartbeatRuns.processStartedAt,
retryOfRunId: heartbeatRuns.retryOfRunId,
processLossRetryCount: heartbeatRuns.processLossRetryCount,
@@ -296,6 +305,18 @@ const heartbeatRunListColumns = {
updatedAt: heartbeatRuns.updatedAt,
} as const;
const heartbeatRunIssueSummaryColumns = {
id: heartbeatRuns.id,
status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
} as const;
function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
@@ -1026,6 +1047,46 @@ function isProcessAlive(pid: number | null | undefined) {
}
}
async function terminateHeartbeatRunProcess(input: {
pid: number | null | undefined;
processGroupId: number | null | undefined;
graceMs?: number;
}) {
const pid = input.pid ?? null;
const processGroupId = input.processGroupId ?? null;
if (typeof pid !== "number" && typeof processGroupId !== "number") return;
await terminateLocalService(
{
pid:
typeof pid === "number" && Number.isInteger(pid) && pid > 0
? pid
: (processGroupId ?? 0),
processGroupId:
typeof processGroupId === "number" && Number.isInteger(processGroupId) && processGroupId > 0
? processGroupId
: null,
},
input.graceMs ? { forceAfterMs: input.graceMs } : undefined,
);
}
function buildProcessLossMessage(run: {
processPid: number | null;
processGroupId: number | null;
}, options?: { descendantOnly?: boolean }) {
if (options?.descendantOnly && run.processGroupId) {
return `Process lost -- parent pid ${run.processPid ?? "unknown"} exited, but descendant process group ${run.processGroupId} was still alive and was terminated`;
}
if (run.processPid) {
return `Process lost -- child pid ${run.processPid} is no longer running`;
}
if (run.processGroupId) {
return `Process lost -- process group ${run.processGroupId} is no longer running`;
}
return "Process lost -- server may have restarted";
}
function truncateDisplayId(value: string | null | undefined, max = 128) {
if (!value) return null;
return value.length > max ? value.slice(0, max) : value;
@@ -1824,13 +1885,14 @@ export function heartbeatService(db: Db) {
async function persistRunProcessMetadata(
runId: string,
meta: { pid: number; startedAt: string },
meta: { pid: number; processGroupId: number | null; startedAt: string },
) {
const startedAt = new Date(meta.startedAt);
return db
.update(heartbeatRuns)
.set({
processPid: meta.pid,
processGroupId: meta.processGroupId,
processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt,
updatedAt: new Date(),
})
@@ -2356,7 +2418,9 @@ export function heartbeatService(db: Db) {
}
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
if (tracksLocalChild && run.processPid && isProcessAlive(run.processPid)) {
const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
if (processPidAlive) {
if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) {
const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`;
const detachedRun = await setRunStatus(run.id, "running", {
@@ -2378,10 +2442,17 @@ export function heartbeatService(db: Db) {
continue;
}
const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1;
const baseMessage = run.processPid
? `Process lost -- child pid ${run.processPid} is no longer running`
: "Process lost -- server may have restarted";
let descendantOnlyCleanup = false;
if (processGroupAlive) {
descendantOnlyCleanup = true;
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
}
const shouldRetry = tracksLocalChild && (!!run.processPid || !!run.processGroupId) && (run.processLossRetryCount ?? 0) < 1;
const baseMessage = buildProcessLossMessage(run, descendantOnlyCleanup ? { descendantOnly: true } : undefined);
let finalizedRun = await setRunStatus(run.id, "failed", {
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
@@ -2414,6 +2485,8 @@ export function heartbeatService(db: Db) {
: baseMessage,
payload: {
...(run.processPid ? { processPid: run.processPid } : {}),
...(run.processGroupId ? { processGroupId: run.processGroupId } : {}),
...(descendantOnlyCleanup ? { descendantOnlyCleanup: true } : {}),
...(retriedRun ? { retryRunId: retriedRun.id } : {}),
},
});
@@ -3179,7 +3252,14 @@ export function heartbeatService(db: Db) {
onLog,
onMeta: onAdapterMeta,
onSpawn: async (meta) => {
await persistRunProcessMetadata(run.id, meta);
await persistRunProcessMetadata(run.id, {
pid: meta.pid,
processGroupId:
"processGroupId" in meta && typeof meta.processGroupId === "number"
? meta.processGroupId
: null,
startedAt: meta.startedAt,
});
},
authToken: authToken ?? undefined,
});
@@ -3299,6 +3379,11 @@ export function heartbeatService(db: Db) {
} as Record<string, unknown>)
: null;
const persistedResultJson = mergeHeartbeatRunResultJson(
adapterResult.resultJson ?? null,
adapterResult.summary ?? null,
);
await setRunStatus(run.id, status, {
finishedAt: new Date(),
error:
@@ -3319,7 +3404,7 @@ export function heartbeatService(db: Db) {
exitCode: adapterResult.exitCode,
signal: adapterResult.signal,
usageJson,
resultJson: adapterResult.resultJson ?? null,
resultJson: persistedResultJson,
sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId,
stdoutExcerpt,
stderrExcerpt,
@@ -3347,7 +3432,7 @@ export function heartbeatService(db: Db) {
});
if (issueId && outcome === "succeeded") {
try {
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
}
@@ -4242,13 +4327,16 @@ export function heartbeatService(db: Db) {
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
const graceMs = Math.max(1, running.graceSec) * 1000;
setTimeout(() => {
if (!running.child.killed) {
running.child.kill("SIGKILL");
}
}, graceMs);
await terminateHeartbeatRunProcess({
pid: running.child.pid ?? run.processPid,
processGroupId: running.processGroupId ?? run.processGroupId,
graceMs: Math.max(1, running.graceSec) * 1000,
});
} else if (run.processPid || run.processGroupId) {
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
}
const cancelled = await setRunStatus(run.id, "cancelled", {
@@ -4298,8 +4386,17 @@ export function heartbeatService(db: Db) {
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
await terminateHeartbeatRunProcess({
pid: running.child.pid ?? run.processPid,
processGroupId: running.processGroupId ?? run.processGroupId,
graceMs: Math.max(1, running.graceSec) * 1000,
});
runningProcesses.delete(run.id);
} else if (run.processPid || run.processGroupId) {
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
}
await releaseIssueExecutionAndPromote(run);
}
@@ -4515,6 +4612,15 @@ export function heartbeatService(db: Db) {
cancelBudgetScopeWork,
getRunIssueSummary: async (runId: string) => {
const [run] = await db
.select(heartbeatRunIssueSummaryColumns)
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.limit(1);
return run ?? null;
},
getActiveRunForAgent: async (agentId: string) => {
const [run] = await db
.select()
@@ -4529,5 +4635,20 @@ export function heartbeatService(db: Db) {
.limit(1);
return run ?? null;
},
getActiveRunIssueSummaryForAgent: async (agentId: string) => {
const [run] = await db
.select(heartbeatRunIssueSummaryColumns)
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agentId),
eq(heartbeatRuns.status, "running"),
),
)
.orderBy(desc(heartbeatRuns.startedAt))
.limit(1);
return run ?? null;
},
};
}
@@ -221,6 +221,17 @@ export function isPidAlive(pid: number) {
}
}
export function isProcessGroupAlive(processGroupId: number | null | undefined) {
if (process.platform === "win32") return false;
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
try {
process.kill(-processGroupId, 0);
return true;
} catch {
return false;
}
}
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
if (process.platform === "win32") return true;
try {
@@ -296,13 +307,19 @@ export async function terminateLocalService(
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
while (Date.now() < deadline) {
if (!isPidAlive(record.pid)) {
const targetAlive = targetProcessGroup
? isProcessGroupAlive(record.processGroupId)
: isPidAlive(record.pid);
if (!targetAlive) {
return;
}
await delay(100);
}
if (!isPidAlive(record.pid)) return;
const stillAlive = targetProcessGroup
? isProcessGroupAlive(record.processGroupId)
: isPidAlive(record.pid);
if (!stillAlive) return;
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, "SIGKILL");
+10 -7
View File
@@ -1,15 +1,18 @@
import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared";
import { api } from "./client";
export interface ActiveRunForIssue extends HeartbeatRun {
export interface ActiveRunForIssue {
id: string;
status: string;
invocationSource: string;
triggerDetail: string | null;
startedAt: string | Date | null;
finishedAt: string | Date | null;
createdAt: string | Date;
agentId: string;
agentName: string;
adapterType: string;
issueId?: string | null;
}
export interface LiveRunForIssue {
+1
View File
@@ -135,6 +135,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
errorCode: null,
externalRunId: null,
processPid: null,
processGroupId: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
-24
View File
@@ -22,7 +22,6 @@ function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssu
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
return {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
@@ -31,30 +30,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
status: "running",
startedAt: new Date("2026-04-08T21:00:00.000Z"),
finishedAt: null,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: { inputTokens: 1 },
resultJson: { summary: "partial" },
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
contextSnapshot: null,
createdAt: new Date("2026-04-08T21:00:00.000Z"),
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
...overrides,
};
}
+1
View File
@@ -99,6 +99,7 @@ describe("FailedRunInboxRow", () => {
errorCode: null,
externalRunId: null,
processPid: null,
processGroupId: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,