From 91e040a69687f667874869d77766b407353196c8 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:55:41 -0500 Subject: [PATCH 1/8] Batch inline comment wake payloads Co-Authored-By: Paperclip --- packages/adapter-utils/src/server-utils.ts | 146 ++++++ .../claude-local/src/server/execute.ts | 9 + .../codex-local/src/server/execute.ts | 9 + .../cursor-local/src/server/execute.ts | 9 + .../gemini-local/src/server/execute.ts | 7 + .../openclaw-gateway/src/server/execute.ts | 47 +- .../opencode-local/src/server/execute.ts | 7 + .../adapters/pi-local/src/server/execute.ts | 7 + .../src/__tests__/codex-local-execute.test.ts | 105 +++++ .../heartbeat-comment-wake-batching.test.ts | 418 ++++++++++++++++++ .../heartbeat-workspace-session.test.ts | 28 ++ .../openclaw-gateway-adapter.test.ts | 46 ++ server/src/services/heartbeat.ts | 216 ++++++++- skills/paperclip/SKILL.md | 4 + 14 files changed, 1049 insertions(+), 9 deletions(-) create mode 100644 server/src/__tests__/heartbeat-comment-wake-batching.test.ts diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4a5affdf..8c4ffbc5 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -193,6 +193,152 @@ export function joinPromptSections( .join(separator); } +type PaperclipWakeIssue = { + id: string | null; + identifier: string | null; + title: string | null; + status: string | null; + priority: string | null; +}; + +type PaperclipWakeComment = { + id: string | null; + issueId: string | null; + body: string; + bodyTruncated: boolean; + createdAt: string | null; + authorType: string | null; + authorId: string | null; +}; + +type PaperclipWakePayload = { + reason: string | null; + issue: PaperclipWakeIssue | null; + commentIds: string[]; + latestCommentId: string | null; + comments: PaperclipWakeComment[]; + requestedCount: number; + includedCount: number; + missingCount: number; + truncated: boolean; + fallbackFetchNeeded: boolean; +}; + +function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null { + const issue = parseObject(value); + const id = asString(issue.id, "").trim() || null; + const identifier = asString(issue.identifier, "").trim() || null; + const title = asString(issue.title, "").trim() || null; + const status = asString(issue.status, "").trim() || null; + const priority = asString(issue.priority, "").trim() || null; + if (!id && !identifier && !title) return null; + return { + id, + identifier, + title, + status, + priority, + }; +} + +function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | null { + const comment = parseObject(value); + const author = parseObject(comment.author); + const body = asString(comment.body, ""); + if (!body.trim()) return null; + return { + id: asString(comment.id, "").trim() || null, + issueId: asString(comment.issueId, "").trim() || null, + body, + bodyTruncated: asBoolean(comment.bodyTruncated, false), + createdAt: asString(comment.createdAt, "").trim() || null, + authorType: asString(author.type, "").trim() || null, + authorId: asString(author.id, "").trim() || null, + }; +} + +export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null { + const payload = parseObject(value); + const comments = Array.isArray(payload.comments) + ? payload.comments + .map((entry) => normalizePaperclipWakeComment(entry)) + .filter((entry): entry is PaperclipWakeComment => Boolean(entry)) + : []; + const commentWindow = parseObject(payload.commentWindow); + const commentIds = Array.isArray(payload.commentIds) + ? payload.commentIds + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()) + : []; + + if (comments.length === 0 && commentIds.length === 0) return null; + + return { + reason: asString(payload.reason, "").trim() || null, + issue: normalizePaperclipWakeIssue(payload.issue), + commentIds, + latestCommentId: asString(payload.latestCommentId, "").trim() || null, + comments, + requestedCount: asNumber(commentWindow.requestedCount, comments.length || commentIds.length), + includedCount: asNumber(commentWindow.includedCount, comments.length), + missingCount: asNumber(commentWindow.missingCount, 0), + truncated: asBoolean(payload.truncated, false), + fallbackFetchNeeded: asBoolean(payload.fallbackFetchNeeded, false), + }; +} + +export function stringifyPaperclipWakePayload(value: unknown): string | null { + const normalized = normalizePaperclipWakePayload(value); + if (!normalized) return null; + return JSON.stringify(normalized); +} + +export function renderPaperclipWakePrompt(value: unknown): string { + const normalized = normalizePaperclipWakePayload(value); + if (!normalized) return ""; + + const lines = [ + "## Paperclip Wake Payload", + "", + "Use this inline wake data first before refetching the issue thread.", + "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; + + if (normalized.issue?.status) { + lines.push(`- issue status: ${normalized.issue.status}`); + } + if (normalized.issue?.priority) { + lines.push(`- issue priority: ${normalized.issue.priority}`); + } + if (normalized.missingCount > 0) { + lines.push(`- omitted comments: ${normalized.missingCount}`); + } + + lines.push("", "New comments in order:"); + + for (const [index, comment] of normalized.comments.entries()) { + const authorLabel = comment.authorId + ? `${comment.authorType ?? "unknown"} ${comment.authorId}` + : comment.authorType ?? "unknown"; + lines.push( + `${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`, + comment.body, + ); + if (comment.bodyTruncated) { + lines.push("[comment body truncated]"); + } + lines.push(""); + } + + return lines.join("\n").trim(); +} + export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a44d0957..072cdf4c 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -20,6 +20,8 @@ import { ensurePathInEnv, resolveCommandForLogs, renderTemplate, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { @@ -170,6 +172,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; @@ -189,6 +192,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -403,15 +409,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); const promptMetrics = { promptChars: prompt.length, bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, sessionHandoffChars: sessionHandoffNote.length, heartbeatPromptChars: renderedPrompt.length, }; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 63d2dc95..63a31a1b 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -18,6 +18,8 @@ import { resolveCommandForLogs, resolvePaperclipDesiredSkillNames, renderTemplate, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -313,6 +315,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } @@ -331,6 +334,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -465,10 +471,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); @@ -476,6 +484,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } @@ -237,6 +240,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -357,11 +363,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, paperclipEnvNote, renderedPrompt, @@ -370,6 +378,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -300,12 +304,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const apiAccessNote = renderApiAccessNote(env); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, paperclipEnvNote, apiAccessNote, @@ -315,6 +321,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise): string { +function buildWakeText( + payload: WakePayload, + paperclipEnv: Record, + structuredWakePrompt: string, +): string { const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; const orderedKeys = [ "PAPERCLIP_RUN_ID", @@ -404,6 +415,12 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; } +function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJson: string): string { + const sections = [ + structuredWakePrompt.trim(), + "Structured wake payload JSON:", + "```json", + structuredWakeJson, + "```", + ].filter((entry) => entry.trim().length > 0); + return sections.join("\n"); +} + function buildStandardPaperclipPayload( ctx: AdapterExecutionContext, wakePayload: WakePayload, @@ -447,6 +475,10 @@ function buildStandardPaperclipPayload( approvalStatus: wakePayload.approvalStatus, apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null, }; + const structuredWake = parseObject(ctx.context.paperclipWake); + if (Object.keys(structuredWake).length > 0) { + standardPaperclip.wake = structuredWake; + } if (workspace) { standardPaperclip.workspace = workspace; @@ -1053,7 +1085,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -276,10 +280,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); @@ -287,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; @@ -184,6 +187,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -303,9 +307,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const userPrompt = joinPromptSections([ renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedHeartbeatPrompt, ]); @@ -313,6 +319,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise key.startsWith("PAPERCLIP_")) .sort(), @@ -32,6 +33,7 @@ type CapturePayload = { argv: string[]; prompt: string; codexHome: string | null; + paperclipWakePayloadJson: string | null; paperclipEnvKeys: string[]; }; @@ -259,6 +261,109 @@ describe("codex execute", () => { } }); + it("injects structured Paperclip wake payloads into env and prompt", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-wake", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + 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-1", "comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-1", + issueId: "issue-1", + body: "First comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:00.000Z", + author: { type: "user", id: "user-1" }, + }, + { + 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: 2, + includedCount: 2, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON"); + expect(capture.paperclipWakePayloadJson).not.toBeNull(); + expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({ + reason: "issue_commented", + latestCommentId: "comment-2", + commentIds: ["comment-1", "comment-2"], + }); + expect(capture.prompt).toContain("## Paperclip Wake Payload"); + expect(capture.prompt).toContain("First comment"); + expect(capture.prompt).toContain("Second comment"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts new file mode 100644 index 00000000..ac205b7b --- /dev/null +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -0,0 +1,418 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { createServer } from "node:http"; +import { and, eq } from "drizzle-orm"; +import { WebSocketServer } from "ws"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + agentWakeupRequests, + applyPendingMigrations, + companies, + createDb, + ensurePostgresDatabase, + heartbeatRuns, + issueComments, + issues, +} from "@paperclipai/db"; +import { heartbeatService } from "../services/heartbeat.ts"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-comment-wake-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, instance, dataDir }; +} + +async function waitFor(condition: () => boolean | Promise, timeoutMs = 10_000, intervalMs = 50) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await condition()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error("Timed out waiting for condition"); +} + +async function createControlledGatewayServer() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + const agentPayloads: Array> = []; + let firstWaitRelease: (() => void) | null = null; + let firstWaitGate = new Promise((resolve) => { + firstWaitRelease = resolve; + }); + let waitCount = 0; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", async (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayloads.push((frame.params ?? {}) as Record); + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : `run-${agentPayloads.length}`; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + waitCount += 1; + if (waitCount === 1) { + await firstWaitGate; + } + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayloads: () => agentPayloads, + releaseFirstWait: () => { + firstWaitRelease?.(); + firstWaitRelease = null; + firstWaitGate = Promise.resolve(); + }, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +describe("heartbeat comment wake batching", () => { + let db!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Batch wake comments", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "First comment", + }) + .returning() + .then((rows) => rows[0]); + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const comment2 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Second comment", + }) + .returning() + .then((rows) => rows[0]); + const comment3 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Third comment", + }) + .returning() + .then((rows) => rows[0]); + + const secondRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment2.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment2.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + const thirdRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment3.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment3.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(secondRun).toBeNull(); + expect(thirdRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + const deferredWake = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + + const deferredContext = (deferredWake?.payload as Record | null)?._paperclipWakeContext as + | Record + | undefined; + expect(deferredContext?.wakeCommentIds).toEqual([comment2.id, comment3.id]); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2); + await waitFor(async () => { + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + commentIds: [comment2.id, comment3.id], + latestCommentId: comment3.id, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("Second comment"); + expect(String(secondPayload.message ?? "")).toContain("Third comment"); + expect(String(secondPayload.message ?? "")).not.toContain("First comment"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 17718055..859c8960 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -7,7 +7,9 @@ import { buildRealizedExecutionWorkspaceFromPersisted, buildExplicitResumeSessionOverride, deriveTaskKeyWithHeartbeatFallback, + extractWakeCommentIds, formatRuntimeWorkspaceWarningLog, + mergeCoalescedContextSnapshot, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, @@ -357,6 +359,32 @@ describe("deriveTaskKeyWithHeartbeatFallback", () => { }); }); +describe("comment wake batching", () => { + it("preserves ordered wake comment ids when coalescing queued follow-up wakes", () => { + const merged = mergeCoalescedContextSnapshot( + { + issueId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-1", + wakeCommentIds: ["comment-1"], + paperclipWake: { + latestCommentId: "comment-1", + }, + }, + { + issueId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + }, + ); + + expect(extractWakeCommentIds(merged)).toEqual(["comment-1", "comment-2"]); + expect(merged.commentId).toBe("comment-2"); + expect(merged.wakeCommentId).toBe("comment-2"); + expect(merged.paperclipWake).toBeUndefined(); + }); +}); + describe("buildExplicitResumeSessionOverride", () => { it("reuses saved task session params when they belong to the selected failed run", () => { const result = buildExplicitResumeSessionOverride({ diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 07bab9da..3bc1ba11 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -439,6 +439,43 @@ describe("openclaw gateway adapter execute", () => { lifecycle: "ephemeral", }, ], + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-123", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-1", "comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-1", + issueId: "issue-123", + body: "First comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:00.000Z", + author: { type: "user", id: "user-1" }, + }, + { + id: "comment-2", + issueId: "issue-123", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 2, + includedCount: 2, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, }, }, ), @@ -456,6 +493,15 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + expect(String(payload?.message ?? "")).toContain("## Paperclip Wake Payload"); + expect(String(payload?.message ?? "")).toContain("First comment"); + expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]"); + expect(payload?.paperclip).toMatchObject({ + wake: { + latestCommentId: "comment-2", + commentIds: ["comment-1", "comment-2"], + }, + }); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); } finally { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dc14bc99..f585b834 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -12,6 +12,7 @@ import { agentWakeupRequests, heartbeatRunEvents, heartbeatRuns, + issueComments, issues, projects, projectWorkspaces, @@ -66,10 +67,15 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; +const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; const DETACHED_PROCESS_ERROR_CODE = "process_detached"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_INLINE_WAKE_COMMENTS = 8; +const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; +const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", @@ -685,7 +691,9 @@ function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, ) { + const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1); return ( + batchedCommentId ?? readNonEmptyString(contextSnapshot?.wakeCommentId) ?? readNonEmptyString(contextSnapshot?.commentId) ?? readNonEmptyString(payload?.commentId) ?? @@ -693,6 +701,50 @@ function deriveCommentId( ); } +export function extractWakeCommentIds( + contextSnapshot: Record | null | undefined, +): string[] { + const raw = contextSnapshot?.[WAKE_COMMENT_IDS_KEY]; + if (!Array.isArray(raw)) return []; + const out: string[] = []; + for (const entry of raw) { + const value = readNonEmptyString(entry); + if (!value || out.includes(value)) continue; + out.push(value); + } + return out; +} + +function mergeWakeCommentIds(...values: Array): string[] { + const merged: string[] = []; + const append = (value: unknown) => { + const normalized = readNonEmptyString(value); + if (!normalized || merged.includes(normalized)) return; + merged.push(normalized); + }; + + for (const value of values) { + if (Array.isArray(value)) { + for (const entry of value) append(entry); + continue; + } + if (typeof value === "object" && value !== null) { + const candidate = value as Record; + const batched = extractWakeCommentIds(candidate); + if (batched.length > 0) { + for (const entry of batched) append(entry); + continue; + } + append(candidate.wakeCommentId); + append(candidate.commentId); + continue; + } + append(value); + } + + return merged; +} + function enrichWakeContextSnapshot(input: { contextSnapshot: Record; reason: string | null; @@ -705,6 +757,7 @@ function enrichWakeContextSnapshot(input: { const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); + const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload); if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { contextSnapshot.wakeReason = reason; @@ -721,7 +774,13 @@ function enrichWakeContextSnapshot(input: { if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) { contextSnapshot.commentId = commentIdFromPayload; } - if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { + if (wakeCommentIds.length > 0) { + const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1]; + contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds; + contextSnapshot.commentId = latestCommentId; + contextSnapshot.wakeCommentId = latestCommentId; + delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } else if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { contextSnapshot.wakeCommentId = wakeCommentId; } if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) { @@ -740,7 +799,7 @@ function enrichWakeContextSnapshot(input: { }; } -function mergeCoalescedContextSnapshot( +export function mergeCoalescedContextSnapshot( existingRaw: unknown, incoming: Record, ) { @@ -749,14 +808,136 @@ function mergeCoalescedContextSnapshot( ...existing, ...incoming, }; - const commentId = deriveCommentId(incoming, null); - if (commentId) { - merged.commentId = commentId; - merged.wakeCommentId = commentId; + const mergedCommentIds = mergeWakeCommentIds(existing, incoming); + if (mergedCommentIds.length > 0) { + const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1]; + merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds; + merged.commentId = latestCommentId; + merged.wakeCommentId = latestCommentId; + delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; } return merged; } +async function buildPaperclipWakePayload(input: { + db: Db; + companyId: string; + contextSnapshot: Record; + issueSummary?: + | { + id: string; + identifier: string | null; + title: string; + status: string; + priority: string; + } + | null; +}) { + const commentIds = extractWakeCommentIds(input.contextSnapshot); + if (commentIds.length === 0) return null; + + const issueId = readNonEmptyString(input.contextSnapshot.issueId); + const issueSummary = + input.issueSummary ?? + (issueId + ? await input.db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) + .then((rows) => rows[0] ?? null) + : null); + + const commentRows = await input.db + .select({ + id: issueComments.id, + issueId: issueComments.issueId, + body: issueComments.body, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + inArray(issueComments.id, commentIds), + ), + ); + + const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); + const comments: Array> = []; + let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; + let truncated = false; + let missingCommentCount = 0; + + for (const commentId of commentIds) { + const row = commentsById.get(commentId); + if (!row) { + truncated = true; + missingCommentCount += 1; + continue; + } + if (comments.length >= MAX_INLINE_WAKE_COMMENTS) { + truncated = true; + break; + } + + const fullBody = row.body; + const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars); + if (allowedBodyChars <= 0) { + truncated = true; + break; + } + + const body = fullBody.length > allowedBodyChars ? fullBody.slice(0, allowedBodyChars) : fullBody; + const bodyTruncated = body.length < fullBody.length; + if (bodyTruncated) truncated = true; + remainingBodyChars -= body.length; + + comments.push({ + id: row.id, + issueId: row.issueId, + body, + bodyTruncated, + createdAt: row.createdAt.toISOString(), + author: row.authorAgentId + ? { type: "agent", id: row.authorAgentId } + : row.authorUserId + ? { type: "user", id: row.authorUserId } + : { type: "system", id: null }, + }); + } + + return { + reason: readNonEmptyString(input.contextSnapshot.wakeReason), + issue: issueSummary + ? { + id: issueSummary.id, + identifier: issueSummary.identifier, + title: issueSummary.title, + status: issueSummary.status, + priority: issueSummary.priority, + } + : null, + commentIds, + latestCommentId: commentIds[commentIds.length - 1] ?? null, + comments, + commentWindow: { + requestedCount: commentIds.length, + includedCount: comments.length, + missingCount: missingCommentCount, + }, + truncated, + fallbackFetchNeeded: truncated || missingCommentCount > 0, + }; +} + function runTaskKey(run: typeof heartbeatRuns.$inferSelect) { return deriveTaskKey(run.contextSnapshot as Record | null, null); } @@ -2098,6 +2279,8 @@ export function heartbeatService(db: Db) { id: issues.id, identifier: issues.identifier, title: issues.title, + status: issues.status, + priority: issues.priority, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, @@ -2168,12 +2351,33 @@ export function heartbeatService(db: Db) { id: issueContext.id, identifier: issueContext.identifier, title: issueContext.title, + status: issueContext.status, + priority: issueContext.priority, projectId: issueContext.projectId, projectWorkspaceId: issueContext.projectWorkspaceId, executionWorkspaceId: issueContext.executionWorkspaceId, executionWorkspacePreference: issueContext.executionWorkspacePreference, } : null; + const paperclipWakePayload = await buildPaperclipWakePayload({ + db, + companyId: agent.companyId, + contextSnapshot: context, + issueSummary: issueRef + ? { + id: issueRef.id, + identifier: issueRef.identifier, + title: issueRef.title, + status: issueRef.status, + priority: issueRef.priority, + } + : null, + }); + if (paperclipWakePayload) { + context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload; + } else { + delete context[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; const shouldReuseExisting = diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 296e3341..6ceed9eb 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -17,6 +17,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Prefer using it first. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. + Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. @@ -59,6 +61,8 @@ If already checked out by you, returns normally. If owned by another agent: `409 **Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay. +If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. + Use comments incrementally: - if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}` From b825a121cb9f413f7836a218a183c61927b1d9e6 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 10:23:19 -0500 Subject: [PATCH 2/8] Prioritize comment wake prompts --- packages/adapter-utils/src/server-utils.ts | 2 ++ server/src/__tests__/codex-local-execute.test.ts | 4 ++++ server/src/__tests__/openclaw-gateway-adapter.test.ts | 3 +++ skills/paperclip/SKILL.md | 4 ++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 8c4ffbc5..d6e67c3b 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -300,6 +300,8 @@ export function renderPaperclipWakePrompt(value: unknown): string { const lines = [ "## Paperclip Wake Payload", "", + "Treat this wake payload as the highest-priority change for the current heartbeat.", + "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", "Use this inline wake data first before refetching the issue thread.", "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 4ca20ab0..795a0ca3 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -355,6 +355,10 @@ describe("codex execute", () => { commentIds: ["comment-1", "comment-2"], }); expect(capture.prompt).toContain("## Paperclip Wake Payload"); + expect(capture.prompt).toContain("Treat this wake payload as the highest-priority change for the current heartbeat."); + expect(capture.prompt).toContain( + "acknowledge the latest comment and explain how it changes your next action.", + ); expect(capture.prompt).toContain("First comment"); expect(capture.prompt).toContain("Second comment"); } finally { diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 3bc1ba11..e4ca99a5 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -494,6 +494,9 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); expect(String(payload?.message ?? "")).toContain("## Paperclip Wake Payload"); + expect(String(payload?.message ?? "")).toContain( + "Treat this wake payload as the highest-priority change for the current heartbeat.", + ); expect(String(payload?.message ?? "")).toContain("First comment"); expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]"); expect(payload?.paperclip).toMatchObject({ diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 6ceed9eb..fae34f35 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -17,7 +17,7 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. -Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Prefer using it first. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. +Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. @@ -61,7 +61,7 @@ If already checked out by you, returns normally. If owned by another agent: `409 **Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay. -If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. +If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, explicitly reflect the new comment context first, then fetch broader history only if needed. Use comments incrementally: From 4dea3027913493d620227f39441a0e4c28d74ff1 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 10:23:31 -0500 Subject: [PATCH 3/8] Speed up issues-page search Keep issue search local to the loaded list, defer heavy result updates, and memoize the rendered list body so typing stays responsive. Co-Authored-By: Paperclip --- ui/src/components/IssuesList.tsx | 578 +++++++++++++++---------------- 1 file changed, 283 insertions(+), 295 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a601aec7..952c649a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; @@ -68,8 +68,6 @@ const quickFilterPresets = [ { label: "Backlog", statuses: ["backlog"] }, { label: "Done", statuses: ["done", "cancelled"] }, ]; -const ISSUE_SEARCH_COMMIT_DELAY_MS = 150; - function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -144,6 +142,18 @@ function countActiveFilters(state: IssueViewState): number { return count; } +function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean { + if (!normalizedSearch) return true; + + return [ + issue.identifier, + issue.title, + issue.description, + ] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(normalizedSearch)); +} + /* ── Component ── */ interface Agent { @@ -175,44 +185,6 @@ interface IssuesListProps { onUpdateIssue: (id: string, data: Record) => void; } -interface IssuesSearchInputProps { - initialValue: string; - onValueCommitted: (value: string) => void; -} - -function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) { - const [value, setValue] = useState(initialValue); - const onValueCommittedRef = useRef(onValueCommitted); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - useEffect(() => { - onValueCommittedRef.current = onValueCommitted; - }, [onValueCommitted]); - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - onValueCommittedRef.current(value); - }, ISSUE_SEARCH_COMMIT_DELAY_MS); - return () => window.clearTimeout(timeoutId); - }, [value]); - - return ( -
- - setValue(e.target.value)} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
- ); -} - export function IssuesList({ issues, isLoading, @@ -249,7 +221,8 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); - const normalizedIssueSearch = issueSearch.trim(); + const deferredIssueSearch = useDeferredValue(issueSearch); + const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); @@ -266,13 +239,6 @@ export function IssuesList({ } }, [scopedKey, initialAssignees]); - const handleIssueSearchCommit = useCallback((nextSearch: string) => { - startTransition(() => { - setIssueSearch(nextSearch); - }); - onSearchChange?.(nextSearch); - }, [onSearchChange]); - const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; @@ -280,27 +246,18 @@ export function IssuesList({ return next; }); }, [scopedKey]); - - const { data: searchedIssues = [] } = useQuery({ - queryKey: [ - ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - searchFilters ?? {}, - ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), - enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, - placeholderData: (previousData) => previousData, - }); - const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const filtered = useMemo(() => { - const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; + const sourceIssues = normalizedIssueSearch.length > 0 + ? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch)) + : issues; const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -343,7 +300,7 @@ export function IssuesList({ })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); - const newIssueDefaults = (groupKey?: string) => { + const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = {}; if (projectId) defaults.projectId = projectId; if (groupKey) { @@ -355,13 +312,259 @@ export function IssuesList({ } } return defaults; - }; + }, [projectId, viewState.groupBy]); - const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); - }; + }, [onUpdateIssue]); + + const listContent = useMemo(() => { + if (viewState.viewMode === "board") { + return ( + + ); + } + + return groupedContent.map((group) => ( + { + updateView({ + collapsedGroups: open + ? viewState.collapsedGroups.filter((k) => k !== group.key) + : [...viewState.collapsedGroups, group.key], + }); + }} + > + {group.label && ( +
+ + + + {group.label} + + + +
+ )} + + {group.items.map((issue) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + )} + desktopMetaLeading={( + <> + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={timeAgo(issue.updatedAt)} + desktopTrailing={( + <> + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + + +{(issue.labels ?? []).length - 3} + + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {currentUserId && ( + + )} + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name + .toLowerCase() + .includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
+ + )} + trailingMeta={formatDate(issue.createdAt)} + /> + ))} +
+
+ )); + }, [ + agents, + agentName, + assigneePickerIssueId, + assigneeSearch, + assignIssue, + currentUserId, + filtered, + groupedContent, + issueLinkState, + liveIssueIds, + newIssueDefaults, + onUpdateIssue, + openNewIssue, + updateView, + viewState.collapsedGroups, + ]); return (
@@ -372,10 +575,19 @@ export function IssuesList({ New Issue - +
+ + { + setIssueSearch(e.target.value); + onSearchChange?.(e.target.value); + }} + placeholder="Search issues..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search issues" + /> +
@@ -658,231 +870,7 @@ export function IssuesList({ /> )} - {viewState.viewMode === "board" ? ( - - ) : ( - groupedContent.map((group) => ( - { - updateView({ - collapsedGroups: open - ? viewState.collapsedGroups.filter((k) => k !== group.key) - : [...viewState.collapsedGroups, group.key], - }); - }} - > - {group.label && ( -
- - - - {group.label} - - - -
- )} - - {group.items.map((issue) => ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - )} - desktopMetaLeading={( - <> - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={timeAgo(issue.updatedAt)} - desktopTrailing={( - <> - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - - +{(issue.labels ?? []).length - 3} - - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} - > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {currentUserId && ( - - )} - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name - .toLowerCase() - .includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
-
- - )} - trailingMeta={formatDate(issue.createdAt)} - /> - ))} -
-
- )) - )} + {listContent}
); } From b9b2bf3b5b9d9a31a271488bf1545b6656f583a2 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 10:33:40 -0500 Subject: [PATCH 4/8] Trim resumed comment wake prompts --- packages/adapter-utils/src/server-utils.ts | 48 +++++--- .../claude-local/src/server/execute.ts | 5 +- .../codex-local/src/server/execute.ts | 44 ++++--- .../cursor-local/src/server/execute.ts | 5 +- .../gemini-local/src/server/execute.ts | 5 +- .../opencode-local/src/server/execute.ts | 6 +- .../adapters/pi-local/src/server/execute.ts | 5 +- .../src/__tests__/codex-local-execute.test.ts | 110 ++++++++++++++++++ .../__tests__/gemini-local-execute.test.ts | 96 +++++++++++++++ 9 files changed, 282 insertions(+), 42 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d6e67c3b..1d84f776 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -293,24 +293,42 @@ export function stringifyPaperclipWakePayload(value: unknown): string | null { return JSON.stringify(normalized); } -export function renderPaperclipWakePrompt(value: unknown): string { +export function renderPaperclipWakePrompt( + value: unknown, + options: { resumedSession?: boolean } = {}, +): string { const normalized = normalizePaperclipWakePayload(value); if (!normalized) return ""; + const resumedSession = options.resumedSession === true; - const lines = [ - "## Paperclip Wake Payload", - "", - "Treat this wake payload as the highest-priority change for the current heartbeat.", - "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", - "Use this inline wake data first before refetching the issue thread.", - "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, - ]; + const lines = resumedSession + ? [ + "## Paperclip Resume Delta", + "", + "You are resuming an existing Paperclip session.", + "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", + "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ] + : [ + "## Paperclip Wake Payload", + "", + "Treat this wake payload as the highest-priority change for the current heartbeat.", + "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", + "Use this inline wake data first before refetching the issue thread.", + "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; if (normalized.issue?.status) { lines.push(`- issue status: ${normalized.issue.status}`); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 072cdf4c..eca82b50 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -404,12 +404,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ renderedBootstrapPrompt, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 63a31a1b..c6ba3e9b 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -440,11 +440,36 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; + instructionsChars = promptInstructionsPrefix.length; const commandNotes = (() => { if (!instructionsFilePath) { return [repoAgentsNote]; } if (instructionsPrefix.length > 0) { + if (shouldUseResumeDeltaPrompt) { + return [ + `Loaded agent instructions from ${instructionsFilePath}`, + "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + repoAgentsNote, + ]; + } return [ `Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, @@ -456,25 +481,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ - instructionsPrefix, + promptInstructionsPrefix, renderedBootstrapPrompt, wakePrompt, sessionHandoffNote, diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index ded063c2..648ef72c 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -358,12 +358,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const prompt = joinPromptSections([ diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index aaa41a2a..95e9ff67 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -299,12 +299,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const apiAccessNote = renderApiAccessNote(env); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 99241d7f..7651578f 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -227,6 +227,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ instructionsPrefix, diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index cd38db5a..c7eb61a2 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -302,12 +302,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession }); + const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0; + const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const userPrompt = joinPromptSections([ renderedBootstrapPrompt, diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 795a0ca3..8c6d80f7 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -368,6 +368,116 @@ describe("codex execute", () => { } }); + it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + const instructionsPath = path.join(root, "AGENTS.md"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8"); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + let invocationPrompt = ""; + let invocationNotes: string[] = []; + let promptMetrics: Record = {}; + try { + const result = await execute({ + runId: "run-resume-wake", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: { + sessionId: "codex-session-1", + cwd: workspace, + }, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + 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 () => {}, + onMeta: async (meta) => { + invocationPrompt = meta.prompt ?? ""; + invocationNotes = meta.commandNotes ?? []; + promptMetrics = meta.promptMetrics ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"])); + expect(capture.prompt).toContain("## Paperclip Resume Delta"); + expect(capture.prompt).toContain("Second comment"); + expect(capture.prompt).not.toContain("Follow the paperclip heartbeat."); + expect(capture.prompt).not.toContain("You are managed instructions."); + expect(invocationPrompt).toContain("## Paperclip Resume Delta"); + expect(invocationNotes).toContain( + "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + ); + expect(promptMetrics.instructionsChars).toBe(0); + expect(promptMetrics.heartbeatPromptChars).toBe(0); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 06fdaf03..d8b1cb9c 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -168,4 +168,100 @@ describe("gemini execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeGeminiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "gemini-session-1", + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "gemini-2.5-pro", + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + 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(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + const promptFlagIndex = capture.argv.indexOf("--prompt"); + const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : ""; + expect(capture.argv).toContain("--resume"); + expect(capture.argv).toContain("gemini-session-1"); + expect(promptArg).toContain("## Paperclip Resume Delta"); + expect(promptArg).toContain("Second comment"); + expect(promptArg).not.toContain("Follow the paperclip heartbeat."); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); }); From 27accb1bdb954f8a71577171540f44d828c86b7d Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 11:40:43 -0500 Subject: [PATCH 5/8] Clarify issue-scoped comment wake prompts Co-Authored-By: Paperclip --- packages/adapter-utils/src/server-utils.ts | 4 +++- server/src/__tests__/codex-local-execute.test.ts | 2 ++ server/src/__tests__/gemini-local-execute.test.ts | 1 + server/src/__tests__/openclaw-gateway-adapter.test.ts | 3 +++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 1d84f776..f486f675 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -302,10 +302,11 @@ export function renderPaperclipWakePrompt( const resumedSession = options.resumedSession === true; const lines = resumedSession - ? [ + ? [ "## Paperclip Resume Delta", "", "You are resuming an existing Paperclip session.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", @@ -319,6 +320,7 @@ export function renderPaperclipWakePrompt( "## Paperclip Wake Payload", "", "Treat this wake payload as the highest-priority change for the current heartbeat.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", "Use this inline wake data first before refetching the issue thread.", "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 8c6d80f7..da648367 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -356,6 +356,7 @@ describe("codex execute", () => { }); expect(capture.prompt).toContain("## Paperclip Wake Payload"); expect(capture.prompt).toContain("Treat this wake payload as the highest-priority change for the current heartbeat."); + expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain( "acknowledge the latest comment and explain how it changes your next action.", ); @@ -462,6 +463,7 @@ describe("codex execute", () => { const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"])); expect(capture.prompt).toContain("## Paperclip Resume Delta"); + expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("Second comment"); expect(capture.prompt).not.toContain("Follow the paperclip heartbeat."); expect(capture.prompt).not.toContain("You are managed instructions."); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index d8b1cb9c..93a4aadd 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -253,6 +253,7 @@ describe("gemini execute", () => { expect(capture.argv).toContain("--resume"); expect(capture.argv).toContain("gemini-session-1"); expect(promptArg).toContain("## Paperclip Resume Delta"); + expect(promptArg).toContain("Do not switch to another issue until you have handled this wake."); expect(promptArg).toContain("Second comment"); expect(promptArg).not.toContain("Follow the paperclip heartbeat."); } finally { diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index e4ca99a5..9bb85b7c 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -497,6 +497,9 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain( "Treat this wake payload as the highest-priority change for the current heartbeat.", ); + expect(String(payload?.message ?? "")).toContain( + "Do not switch to another issue until you have handled this wake.", + ); expect(String(payload?.message ?? "")).toContain("First comment"); expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]"); expect(payload?.paperclip).toMatchObject({ From 22af797ca3faa65e4713d57d5b074d3efefa4cb7 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 11:40:48 -0500 Subject: [PATCH 6/8] Provision local node_modules in issue worktrees Co-Authored-By: Paperclip --- scripts/provision-worktree.sh | 38 +++++++ .../src/__tests__/workspace-runtime.test.ts | 105 ++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 09cfc36b..14acc040 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -335,6 +335,44 @@ disable_seeded_routines() { disable_seeded_routines +if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then + needs_install=0 + + while IFS= read -r relative_path; do + [[ -n "$relative_path" ]] || continue + target_path="$worktree_cwd/$relative_path" + + if [[ -L "$target_path" ]]; then + rm "$target_path" + needs_install=1 + continue + fi + + if [[ ! -e "$target_path" ]]; then + needs_install=1 + fi + done < <( + cd "$base_cwd" && + find . \ + -mindepth 1 \ + -maxdepth 3 \ + -type d \ + -name node_modules \ + ! -path './.git/*' \ + ! -path './.paperclip/*' \ + | sed 's#^\./##' + ) + + if [[ "$needs_install" -eq 1 ]]; then + ( + cd "$worktree_cwd" + pnpm install --frozen-lockfile + ) + fi + + exit 0 +fi + while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue source_path="$base_cwd/$relative_path" diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 472f58b0..a04e5302 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -50,11 +50,16 @@ if (!embeddedPostgresSupport.supported) { `Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } +const provisionWorktreeScriptPath = new URL("../../../scripts/provision-worktree.sh", import.meta.url); async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); } +async function runPnpm(cwd: string, args: string[]) { + await execFileAsync("pnpm", args, { cwd }); +} + async function createTempRepo(defaultBranch = "main") { const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-")); await runGit(repoRoot, ["init"]); @@ -557,6 +562,106 @@ describe("realizeExecutionWorkspace", () => { } }, 15_000); + it("provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "pnpm-workspace.yaml"), + ["packages:", " - packages/*", " - server", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "packages", "shared", "package.json"), + JSON.stringify( + { + name: "@repo/shared", + version: "1.0.0", + private: true, + type: "module", + exports: "./index.js", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify( + { + name: "server", + private: true, + type: "module", + dependencies: { + "@repo/shared": "workspace:*", + }, + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); + await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); + await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); + await runPnpm(repoRoot, ["install"]); + await runGit(repoRoot, ["add", "."]); + await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-551", + title: "Provision local workspace dependencies", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); + expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); + await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(workspace.cwd, "packages", "shared")), + ); + await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(repoRoot, "packages", "shared")), + ); + }); + it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); From 8ae4c0e765a53a7e8b91c25c27daeb14148a91b8 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 1 Apr 2026 09:35:22 -0500 Subject: [PATCH 7/8] Clean up opencode rebase and stabilize runtime test Co-Authored-By: Paperclip --- packages/adapters/opencode-local/src/server/execute.ts | 2 -- server/src/__tests__/workspace-runtime.test.ts | 8 ++++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 7651578f..2a7dae5a 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -226,8 +226,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { } }, 15_000); - it("provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { + it( + "provisions worktree-local pnpm node_modules instead of reusing base-repo links", + async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); @@ -660,7 +662,9 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( await fs.realpath(path.join(repoRoot, "packages", "shared")), ); - }); + }, + 15_000, + ); it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); From c19208010a0ada02cee323b7959e5370ee083075 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 18:37:19 -0500 Subject: [PATCH 8/8] fix: harden worktree dependency hydration Co-Authored-By: Paperclip --- scripts/provision-worktree.sh | 84 +++++++++++++++++++++----------- server/src/services/heartbeat.ts | 4 ++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 14acc040..861a6037 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -335,6 +335,18 @@ disable_seeded_routines() { disable_seeded_routines +list_base_node_modules_paths() { + cd "$base_cwd" && + find . \ + -mindepth 1 \ + -maxdepth 4 \ + -type d \ + -name node_modules \ + ! -path './.git/*' \ + ! -path './.paperclip/*' \ + | sed 's#^\./##' +} + if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then needs_install=0 @@ -342,32 +354,56 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t [[ -n "$relative_path" ]] || continue target_path="$worktree_cwd/$relative_path" - if [[ -L "$target_path" ]]; then - rm "$target_path" + if [[ -L "$target_path" || ! -e "$target_path" ]]; then needs_install=1 - continue + break fi - - if [[ ! -e "$target_path" ]]; then - needs_install=1 - fi - done < <( - cd "$base_cwd" && - find . \ - -mindepth 1 \ - -maxdepth 3 \ - -type d \ - -name node_modules \ - ! -path './.git/*' \ - ! -path './.paperclip/*' \ - | sed 's#^\./##' - ) + done < <(list_base_node_modules_paths) if [[ "$needs_install" -eq 1 ]]; then + backup_suffix=".paperclip-backup-$BASHPID" + moved_symlink_paths=() + + while IFS= read -r relative_path; do + [[ -n "$relative_path" ]] || continue + target_path="$worktree_cwd/$relative_path" + if [[ -L "$target_path" ]]; then + backup_path="${target_path}${backup_suffix}" + rm -rf "$backup_path" + mv "$target_path" "$backup_path" + moved_symlink_paths+=("$relative_path") + fi + done < <(list_base_node_modules_paths) + + restore_moved_symlinks() { + local relative_path target_path backup_path + for relative_path in "${moved_symlink_paths[@]}"; do + target_path="$worktree_cwd/$relative_path" + backup_path="${target_path}${backup_suffix}" + [[ -L "$backup_path" ]] || continue + rm -rf "$target_path" + mv "$backup_path" "$target_path" + done + } + + cleanup_moved_symlinks() { + local relative_path target_path backup_path + for relative_path in "${moved_symlink_paths[@]}"; do + target_path="$worktree_cwd/$relative_path" + backup_path="${target_path}${backup_suffix}" + [[ -L "$backup_path" ]] && rm "$backup_path" + done + } + ( cd "$worktree_cwd" pnpm install --frozen-lockfile - ) + ) || { + restore_moved_symlinks + exit 1 + } + + cleanup_moved_symlinks fi exit 0 @@ -384,13 +420,5 @@ while IFS= read -r relative_path; do mkdir -p "$(dirname "$target_path")" ln -s "$source_path" "$target_path" done < <( - cd "$base_cwd" && - find . \ - -mindepth 1 \ - -maxdepth 3 \ - -type d \ - -name node_modules \ - ! -path './.git/*' \ - ! -path './.paperclip/*' \ - | sed 's#^\./##' + list_base_node_modules_paths ) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f585b834..0a3a1479 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -779,6 +779,8 @@ function enrichWakeContextSnapshot(input: { contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds; contextSnapshot.commentId = latestCommentId; contextSnapshot.wakeCommentId = latestCommentId; + // Once comment ids are normalized into the snapshot, rebuild the structured + // wake payload from those ids later instead of carrying forward stale data. delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY]; } else if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { contextSnapshot.wakeCommentId = wakeCommentId; @@ -814,6 +816,8 @@ export function mergeCoalescedContextSnapshot( merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds; merged.commentId = latestCommentId; merged.wakeCommentId = latestCommentId; + // The merged context should carry canonical comment ids; the next wake will + // regenerate any structured payload from those ids. delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; } return merged;