mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
Batch inline comment wake payloads
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e75960f284
commit
91e040a696
14 changed files with 1049 additions and 9 deletions
|
|
@ -193,6 +193,152 @@ export function joinPromptSections(
|
||||||
.join(separator);
|
.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<string, string>): Record<string, string> {
|
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||||
const redacted: Record<string, string> = {};
|
const redacted: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(env)) {
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
resolveCommandForLogs,
|
resolveCommandForLogs,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -170,6 +172,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
|
|
||||||
if (wakeTaskId) {
|
if (wakeTaskId) {
|
||||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
|
|
@ -189,6 +192,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||||
if (linkedIssueIds.length > 0) {
|
if (linkedIssueIds.length > 0) {
|
||||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
}
|
}
|
||||||
|
if (wakePayloadJson) {
|
||||||
|
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
|
}
|
||||||
if (effectiveWorkspaceCwd) {
|
if (effectiveWorkspaceCwd) {
|
||||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
}
|
}
|
||||||
|
|
@ -403,15 +409,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
renderedPrompt,
|
renderedPrompt,
|
||||||
]);
|
]);
|
||||||
const promptMetrics = {
|
const promptMetrics = {
|
||||||
promptChars: prompt.length,
|
promptChars: prompt.length,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
resolveCommandForLogs,
|
resolveCommandForLogs,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
@ -313,6 +315,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
if (wakeTaskId) {
|
if (wakeTaskId) {
|
||||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +334,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (linkedIssueIds.length > 0) {
|
if (linkedIssueIds.length > 0) {
|
||||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
}
|
}
|
||||||
|
if (wakePayloadJson) {
|
||||||
|
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
|
}
|
||||||
if (effectiveWorkspaceCwd) {
|
if (effectiveWorkspaceCwd) {
|
||||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
}
|
}
|
||||||
|
|
@ -465,10 +471,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
instructionsPrefix,
|
instructionsPrefix,
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
renderedPrompt,
|
renderedPrompt,
|
||||||
]);
|
]);
|
||||||
|
|
@ -476,6 +484,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
promptChars: prompt.length,
|
promptChars: prompt.length,
|
||||||
instructionsChars,
|
instructionsChars,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
@ -219,6 +221,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
if (wakeTaskId) {
|
if (wakeTaskId) {
|
||||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +240,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (linkedIssueIds.length > 0) {
|
if (linkedIssueIds.length > 0) {
|
||||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
}
|
}
|
||||||
|
if (wakePayloadJson) {
|
||||||
|
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
|
}
|
||||||
if (effectiveWorkspaceCwd) {
|
if (effectiveWorkspaceCwd) {
|
||||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
}
|
}
|
||||||
|
|
@ -357,11 +363,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
instructionsPrefix,
|
instructionsPrefix,
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
paperclipEnvNote,
|
paperclipEnvNote,
|
||||||
renderedPrompt,
|
renderedPrompt,
|
||||||
|
|
@ -370,6 +378,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
promptChars: prompt.length,
|
promptChars: prompt.length,
|
||||||
instructionsChars,
|
instructionsChars,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
runtimeNoteChars: paperclipEnvNote.length,
|
runtimeNoteChars: paperclipEnvNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
parseObject,
|
parseObject,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||||
|
|
@ -193,12 +195,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
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 (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
|
|
@ -300,12 +304,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
const apiAccessNote = renderApiAccessNote(env);
|
const apiAccessNote = renderApiAccessNote(env);
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
instructionsPrefix,
|
instructionsPrefix,
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
paperclipEnvNote,
|
paperclipEnvNote,
|
||||||
apiAccessNote,
|
apiAccessNote,
|
||||||
|
|
@ -315,6 +321,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
promptChars: prompt.length,
|
promptChars: prompt.length,
|
||||||
instructionsChars: instructionsPrefix.length,
|
instructionsChars: instructionsPrefix.length,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@ import type {
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterRuntimeServiceReport,
|
AdapterRuntimeServiceReport,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
parseObject,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import crypto, { randomUUID } from "node:crypto";
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
|
@ -335,7 +342,11 @@ function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: Wak
|
||||||
return paperclipEnv;
|
return paperclipEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string>): string {
|
function buildWakeText(
|
||||||
|
payload: WakePayload,
|
||||||
|
paperclipEnv: Record<string, string>,
|
||||||
|
structuredWakePrompt: string,
|
||||||
|
): string {
|
||||||
const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
||||||
const orderedKeys = [
|
const orderedKeys = [
|
||||||
"PAPERCLIP_RUN_ID",
|
"PAPERCLIP_RUN_ID",
|
||||||
|
|
@ -404,6 +415,12 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string
|
||||||
"- POST /api/issues/{issueId}/comments",
|
"- POST /api/issues/{issueId}/comments",
|
||||||
"- PATCH /api/issues/{issueId}",
|
"- PATCH /api/issues/{issueId}",
|
||||||
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
||||||
|
...(structuredWakePrompt
|
||||||
|
? [
|
||||||
|
"",
|
||||||
|
structuredWakePrompt,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
"",
|
"",
|
||||||
"Complete the workflow in this run.",
|
"Complete the workflow in this run.",
|
||||||
];
|
];
|
||||||
|
|
@ -415,6 +432,17 @@ function appendWakeText(baseText: string, wakeText: string): string {
|
||||||
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
return trimmedBase.length > 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(
|
function buildStandardPaperclipPayload(
|
||||||
ctx: AdapterExecutionContext,
|
ctx: AdapterExecutionContext,
|
||||||
wakePayload: WakePayload,
|
wakePayload: WakePayload,
|
||||||
|
|
@ -447,6 +475,10 @@ function buildStandardPaperclipPayload(
|
||||||
approvalStatus: wakePayload.approvalStatus,
|
approvalStatus: wakePayload.approvalStatus,
|
||||||
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
||||||
};
|
};
|
||||||
|
const structuredWake = parseObject(ctx.context.paperclipWake);
|
||||||
|
if (Object.keys(structuredWake).length > 0) {
|
||||||
|
standardPaperclip.wake = structuredWake;
|
||||||
|
}
|
||||||
|
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
standardPaperclip.workspace = workspace;
|
standardPaperclip.workspace = workspace;
|
||||||
|
|
@ -1053,7 +1085,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
|
|
||||||
const wakePayload = buildWakePayload(ctx);
|
const wakePayload = buildWakePayload(ctx);
|
||||||
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
||||||
const wakeText = buildWakeText(wakePayload, paperclipEnv);
|
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake);
|
||||||
|
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
||||||
|
const wakeText = buildWakeText(
|
||||||
|
wakePayload,
|
||||||
|
paperclipEnv,
|
||||||
|
structuredWakeJson
|
||||||
|
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
||||||
|
: structuredWakePrompt,
|
||||||
|
);
|
||||||
|
|
||||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
||||||
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
||||||
|
|
@ -1075,6 +1115,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
idempotencyKey: ctx.runId,
|
idempotencyKey: ctx.runId,
|
||||||
};
|
};
|
||||||
delete agentParams.text;
|
delete agentParams.text;
|
||||||
|
agentParams.paperclip = paperclipPayload;
|
||||||
|
|
||||||
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
||||||
if (configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
if (configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
resolveCommandForLogs,
|
resolveCommandForLogs,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
|
|
@ -154,12 +156,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
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 (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
|
|
@ -276,10 +280,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
instructionsPrefix,
|
instructionsPrefix,
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
renderedPrompt,
|
renderedPrompt,
|
||||||
]);
|
]);
|
||||||
|
|
@ -287,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
promptChars: prompt.length,
|
promptChars: prompt.length,
|
||||||
instructionsChars: instructionsPrefix.length,
|
instructionsChars: instructionsPrefix.length,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
||||||
|
|
@ -177,6 +179,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
|
|
||||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
|
|
@ -184,6 +187,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
|
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
|
|
@ -303,9 +307,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
: "";
|
: "";
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
const userPrompt = joinPromptSections([
|
const userPrompt = joinPromptSections([
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
renderedHeartbeatPrompt,
|
renderedHeartbeatPrompt,
|
||||||
]);
|
]);
|
||||||
|
|
@ -313,6 +319,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
systemPromptChars: renderedSystemPromptExtension.length,
|
systemPromptChars: renderedSystemPromptExtension.length,
|
||||||
promptChars: userPrompt.length,
|
promptChars: userPrompt.length,
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const payload = {
|
||||||
argv: process.argv.slice(2),
|
argv: process.argv.slice(2),
|
||||||
prompt: fs.readFileSync(0, "utf8"),
|
prompt: fs.readFileSync(0, "utf8"),
|
||||||
codexHome: process.env.CODEX_HOME || null,
|
codexHome: process.env.CODEX_HOME || null,
|
||||||
|
paperclipWakePayloadJson: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON || null,
|
||||||
paperclipEnvKeys: Object.keys(process.env)
|
paperclipEnvKeys: Object.keys(process.env)
|
||||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||||
.sort(),
|
.sort(),
|
||||||
|
|
@ -32,6 +33,7 @@ type CapturePayload = {
|
||||||
argv: string[];
|
argv: string[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
codexHome: string | null;
|
codexHome: string | null;
|
||||||
|
paperclipWakePayloadJson: string | null;
|
||||||
paperclipEnvKeys: string[];
|
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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
|
|
|
||||||
418
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
Normal file
418
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
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<boolean>, 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<Record<string, unknown>> = [];
|
||||||
|
let firstWaitRelease: (() => void) | null = null;
|
||||||
|
let firstWaitGate = new Promise<void>((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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
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<void>((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<void>((resolve) => wss.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("heartbeat comment wake batching", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
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<string, unknown> | null)?._paperclipWakeContext as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| 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);
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,9 @@ import {
|
||||||
buildRealizedExecutionWorkspaceFromPersisted,
|
buildRealizedExecutionWorkspaceFromPersisted,
|
||||||
buildExplicitResumeSessionOverride,
|
buildExplicitResumeSessionOverride,
|
||||||
deriveTaskKeyWithHeartbeatFallback,
|
deriveTaskKeyWithHeartbeatFallback,
|
||||||
|
extractWakeCommentIds,
|
||||||
formatRuntimeWorkspaceWarningLog,
|
formatRuntimeWorkspaceWarningLog,
|
||||||
|
mergeCoalescedContextSnapshot,
|
||||||
prioritizeProjectWorkspaceCandidatesForRun,
|
prioritizeProjectWorkspaceCandidatesForRun,
|
||||||
parseSessionCompactionPolicy,
|
parseSessionCompactionPolicy,
|
||||||
resolveRuntimeSessionParamsForWorkspace,
|
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", () => {
|
describe("buildExplicitResumeSessionOverride", () => {
|
||||||
it("reuses saved task session params when they belong to the selected failed run", () => {
|
it("reuses saved task session params when they belong to the selected failed run", () => {
|
||||||
const result = buildExplicitResumeSessionOverride({
|
const result = buildExplicitResumeSessionOverride({
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,43 @@ describe("openclaw gateway adapter execute", () => {
|
||||||
lifecycle: "ephemeral",
|
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("wake now");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
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_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);
|
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
issueComments,
|
||||||
issues,
|
issues,
|
||||||
projects,
|
projects,
|
||||||
projectWorkspaces,
|
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_DEFAULT = 1;
|
||||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
||||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
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 DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||||
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
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 execFile = promisify(execFileCallback);
|
||||||
const SESSIONED_LOCAL_ADAPTERS = new Set([
|
const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||||
"claude_local",
|
"claude_local",
|
||||||
|
|
@ -685,7 +691,9 @@ function deriveCommentId(
|
||||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||||
payload: Record<string, unknown> | null | undefined,
|
payload: Record<string, unknown> | null | undefined,
|
||||||
) {
|
) {
|
||||||
|
const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1);
|
||||||
return (
|
return (
|
||||||
|
batchedCommentId ??
|
||||||
readNonEmptyString(contextSnapshot?.wakeCommentId) ??
|
readNonEmptyString(contextSnapshot?.wakeCommentId) ??
|
||||||
readNonEmptyString(contextSnapshot?.commentId) ??
|
readNonEmptyString(contextSnapshot?.commentId) ??
|
||||||
readNonEmptyString(payload?.commentId) ??
|
readNonEmptyString(payload?.commentId) ??
|
||||||
|
|
@ -693,6 +701,50 @@ function deriveCommentId(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractWakeCommentIds(
|
||||||
|
contextSnapshot: Record<string, unknown> | 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<unknown>): 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<string, unknown>;
|
||||||
|
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: {
|
function enrichWakeContextSnapshot(input: {
|
||||||
contextSnapshot: Record<string, unknown>;
|
contextSnapshot: Record<string, unknown>;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
|
@ -705,6 +757,7 @@ function enrichWakeContextSnapshot(input: {
|
||||||
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
|
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
|
||||||
const taskKey = deriveTaskKey(contextSnapshot, payload);
|
const taskKey = deriveTaskKey(contextSnapshot, payload);
|
||||||
const wakeCommentId = deriveCommentId(contextSnapshot, payload);
|
const wakeCommentId = deriveCommentId(contextSnapshot, payload);
|
||||||
|
const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload);
|
||||||
|
|
||||||
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
|
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
|
||||||
contextSnapshot.wakeReason = reason;
|
contextSnapshot.wakeReason = reason;
|
||||||
|
|
@ -721,7 +774,13 @@ function enrichWakeContextSnapshot(input: {
|
||||||
if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) {
|
if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) {
|
||||||
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;
|
contextSnapshot.wakeCommentId = wakeCommentId;
|
||||||
}
|
}
|
||||||
if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) {
|
if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) {
|
||||||
|
|
@ -740,7 +799,7 @@ function enrichWakeContextSnapshot(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCoalescedContextSnapshot(
|
export function mergeCoalescedContextSnapshot(
|
||||||
existingRaw: unknown,
|
existingRaw: unknown,
|
||||||
incoming: Record<string, unknown>,
|
incoming: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -749,14 +808,136 @@ function mergeCoalescedContextSnapshot(
|
||||||
...existing,
|
...existing,
|
||||||
...incoming,
|
...incoming,
|
||||||
};
|
};
|
||||||
const commentId = deriveCommentId(incoming, null);
|
const mergedCommentIds = mergeWakeCommentIds(existing, incoming);
|
||||||
if (commentId) {
|
if (mergedCommentIds.length > 0) {
|
||||||
merged.commentId = commentId;
|
const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1];
|
||||||
merged.wakeCommentId = commentId;
|
merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds;
|
||||||
|
merged.commentId = latestCommentId;
|
||||||
|
merged.wakeCommentId = latestCommentId;
|
||||||
|
delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildPaperclipWakePayload(input: {
|
||||||
|
db: Db;
|
||||||
|
companyId: string;
|
||||||
|
contextSnapshot: Record<string, unknown>;
|
||||||
|
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<Record<string, unknown>> = [];
|
||||||
|
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) {
|
function runTaskKey(run: typeof heartbeatRuns.$inferSelect) {
|
||||||
return deriveTaskKey(run.contextSnapshot as Record<string, unknown> | null, null);
|
return deriveTaskKey(run.contextSnapshot as Record<string, unknown> | null, null);
|
||||||
}
|
}
|
||||||
|
|
@ -2098,6 +2279,8 @@ export function heartbeatService(db: Db) {
|
||||||
id: issues.id,
|
id: issues.id,
|
||||||
identifier: issues.identifier,
|
identifier: issues.identifier,
|
||||||
title: issues.title,
|
title: issues.title,
|
||||||
|
status: issues.status,
|
||||||
|
priority: issues.priority,
|
||||||
projectId: issues.projectId,
|
projectId: issues.projectId,
|
||||||
projectWorkspaceId: issues.projectWorkspaceId,
|
projectWorkspaceId: issues.projectWorkspaceId,
|
||||||
executionWorkspaceId: issues.executionWorkspaceId,
|
executionWorkspaceId: issues.executionWorkspaceId,
|
||||||
|
|
@ -2168,12 +2351,33 @@ export function heartbeatService(db: Db) {
|
||||||
id: issueContext.id,
|
id: issueContext.id,
|
||||||
identifier: issueContext.identifier,
|
identifier: issueContext.identifier,
|
||||||
title: issueContext.title,
|
title: issueContext.title,
|
||||||
|
status: issueContext.status,
|
||||||
|
priority: issueContext.priority,
|
||||||
projectId: issueContext.projectId,
|
projectId: issueContext.projectId,
|
||||||
projectWorkspaceId: issueContext.projectWorkspaceId,
|
projectWorkspaceId: issueContext.projectWorkspaceId,
|
||||||
executionWorkspaceId: issueContext.executionWorkspaceId,
|
executionWorkspaceId: issueContext.executionWorkspaceId,
|
||||||
executionWorkspacePreference: issueContext.executionWorkspacePreference,
|
executionWorkspacePreference: issueContext.executionWorkspacePreference,
|
||||||
}
|
}
|
||||||
: null;
|
: 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 =
|
const existingExecutionWorkspace =
|
||||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||||
const shouldReuseExisting =
|
const shouldReuseExisting =
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
|
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <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.
|
**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.
|
**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:
|
Use comments incrementally:
|
||||||
|
|
||||||
- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}`
|
- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue