mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
fix: harden heartbeat and adapter runtime workflows
This commit is contained in:
parent
548721248e
commit
c566a9236c
48 changed files with 14922 additions and 600 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue