Batch inline comment wake payloads

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 09:55:41 -05:00
parent e75960f284
commit 91e040a696
14 changed files with 1049 additions and 9 deletions

View file

@ -13,6 +13,7 @@ const payload = {
argv: process.argv.slice(2),
prompt: fs.readFileSync(0, "utf8"),
codexHome: process.env.CODEX_HOME || null,
paperclipWakePayloadJson: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON || null,
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => 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");

View 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);
});

View file

@ -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({

View file

@ -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 {