mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40: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
|
|
@ -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");
|
||||
|
|
|
|||
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,
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
agentWakeupRequests,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
|
|
@ -66,10 +67,15 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
|||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
||||
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
|
||||
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const MAX_INLINE_WAKE_COMMENTS = 8;
|
||||
const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000;
|
||||
const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
|
||||
const execFile = promisify(execFileCallback);
|
||||
const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
|
|
@ -685,7 +691,9 @@ function deriveCommentId(
|
|||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1);
|
||||
return (
|
||||
batchedCommentId ??
|
||||
readNonEmptyString(contextSnapshot?.wakeCommentId) ??
|
||||
readNonEmptyString(contextSnapshot?.commentId) ??
|
||||
readNonEmptyString(payload?.commentId) ??
|
||||
|
|
@ -693,6 +701,50 @@ function deriveCommentId(
|
|||
);
|
||||
}
|
||||
|
||||
export function extractWakeCommentIds(
|
||||
contextSnapshot: Record<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: {
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
reason: string | null;
|
||||
|
|
@ -705,6 +757,7 @@ function enrichWakeContextSnapshot(input: {
|
|||
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
|
||||
const taskKey = deriveTaskKey(contextSnapshot, payload);
|
||||
const wakeCommentId = deriveCommentId(contextSnapshot, payload);
|
||||
const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload);
|
||||
|
||||
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
|
||||
contextSnapshot.wakeReason = reason;
|
||||
|
|
@ -721,7 +774,13 @@ function enrichWakeContextSnapshot(input: {
|
|||
if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) {
|
||||
contextSnapshot.commentId = commentIdFromPayload;
|
||||
}
|
||||
if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) {
|
||||
if (wakeCommentIds.length > 0) {
|
||||
const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1];
|
||||
contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds;
|
||||
contextSnapshot.commentId = latestCommentId;
|
||||
contextSnapshot.wakeCommentId = latestCommentId;
|
||||
delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
} else if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) {
|
||||
contextSnapshot.wakeCommentId = wakeCommentId;
|
||||
}
|
||||
if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) {
|
||||
|
|
@ -740,7 +799,7 @@ function enrichWakeContextSnapshot(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function mergeCoalescedContextSnapshot(
|
||||
export function mergeCoalescedContextSnapshot(
|
||||
existingRaw: unknown,
|
||||
incoming: Record<string, unknown>,
|
||||
) {
|
||||
|
|
@ -749,14 +808,136 @@ function mergeCoalescedContextSnapshot(
|
|||
...existing,
|
||||
...incoming,
|
||||
};
|
||||
const commentId = deriveCommentId(incoming, null);
|
||||
if (commentId) {
|
||||
merged.commentId = commentId;
|
||||
merged.wakeCommentId = commentId;
|
||||
const mergedCommentIds = mergeWakeCommentIds(existing, incoming);
|
||||
if (mergedCommentIds.length > 0) {
|
||||
const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1];
|
||||
merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds;
|
||||
merged.commentId = latestCommentId;
|
||||
merged.wakeCommentId = latestCommentId;
|
||||
delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function buildPaperclipWakePayload(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
contextSnapshot: Record<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) {
|
||||
return deriveTaskKey(run.contextSnapshot as Record<string, unknown> | null, null);
|
||||
}
|
||||
|
|
@ -2098,6 +2279,8 @@ export function heartbeatService(db: Db) {
|
|||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
|
|
@ -2168,12 +2351,33 @@ export function heartbeatService(db: Db) {
|
|||
id: issueContext.id,
|
||||
identifier: issueContext.identifier,
|
||||
title: issueContext.title,
|
||||
status: issueContext.status,
|
||||
priority: issueContext.priority,
|
||||
projectId: issueContext.projectId,
|
||||
projectWorkspaceId: issueContext.projectWorkspaceId,
|
||||
executionWorkspaceId: issueContext.executionWorkspaceId,
|
||||
executionWorkspacePreference: issueContext.executionWorkspacePreference,
|
||||
}
|
||||
: null;
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
contextSnapshot: context,
|
||||
issueSummary: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (paperclipWakePayload) {
|
||||
context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
|
||||
} else {
|
||||
delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const shouldReuseExisting =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue