From 91e040a69687f667874869d77766b407353196c8 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:55:41 -0500 Subject: [PATCH] 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}`