Fix conference room wake recovery loop

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-30 16:30:23 -05:00
1 parent 1697d49ab4
commit 0cd4e7c2a6
4 files changed
+182

No files matched your search

@@ -771,6 +771,7 @@ export function renderPaperclipWakePrompt(
"Answer the board directly by posting an issue comment on this same issue.",
"Do not run normal inbox or assignment triage for this wake, and do not delegate unless the board explicitly asks you to create or assign work.",
"Keep the reply conversational, concise, and useful to the board.",
"Once you have answered the current board message, treat the chat turn as complete; Paperclip will mark the chat issue done until the board sends another message.",
"If the issue title is still `New chat`, update it to a short descriptive conversation title before or alongside your first substantive reply.",
"",
);
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
import { and, asc, eq } from "drizzle-orm";
import { WebSocketServer } from "ws";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { CONFERENCE_ROOM_ORIGIN_KIND } from "@paperclipai/shared";
import {
agents,
agentWakeupRequests,
@@ -1485,6 +1486,128 @@ describe("heartbeat comment wake batching", () => {
await gateway.close();
}
}, 120_000);
it("auto-completes conference room turns after the agent replies", 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: "CEO",
role: "ceo",
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: "New chat",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
originKind: CONFERENCE_ROOM_ORIGIN_KIND,
originId: agentId,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
source: "conference_room.comment",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
});
expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
authorUserId: null,
createdByRunId: firstRun!.id,
body: "Here is the CEO response.",
});
gateway.releaseFirstWait();
await waitFor(async () => {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded" && run.issueCommentStatus === "satisfied";
});
const completedIssue = await db
.select({
status: issues.status,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
completedAt: issues.completedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(completedIssue?.status).toBe("done");
expect(completedIssue?.checkoutRunId).toBeNull();
expect(completedIssue?.executionRunId).toBeNull();
expect(completedIssue?.completedAt).toBeInstanceOf(Date);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeups).toHaveLength(1);
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 20_000);
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
@@ -28,6 +28,7 @@ describe("conference room wake prompt", () => {
expect(prompt).toContain("## Conference Room Mode");
expect(prompt).toContain("This issue is a board chat with you");
expect(prompt).toContain("Paperclip will mark the chat issue done until the board sends another message");
expect(prompt).toContain("If the issue title is still `New chat`");
});
});
+57
View File
@@ -7,6 +7,7 @@ import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lte, notInArr
import type { Db } from "@paperclipai/db";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
CONFERENCE_ROOM_ORIGIN_KIND,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
MODEL_PROFILE_KEYS,
isEnvironmentDriverSupportedForAdapter,
@@ -3238,6 +3239,57 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.then((rows) => rows[0] ?? null);
}
async function completeConferenceRoomTurnAfterReply(input: {
run: typeof heartbeatRuns.$inferSelect;
issueId: string;
commentId: string;
}) {
if (input.run.status !== "succeeded") return null;
const issue = await db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
status: issues.status,
originKind: issues.originKind,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.run.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) return null;
if (issue.originKind !== CONFERENCE_ROOM_ORIGIN_KIND) return null;
if (issue.status !== "in_progress") return null;
if (issue.assigneeAgentId !== input.run.agentId) return null;
const updated = await issuesSvc.update(input.issueId, {
status: "done",
});
if (!updated) return null;
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "heartbeat",
agentId: input.run.agentId,
runId: input.run.id,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
status: "done",
previousStatus: issue.status,
source: "conference_room.auto_complete",
satisfiedByCommentId: input.commentId,
},
});
return updated;
}
async function refreshContinuationSummaryForRun(
run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect,
@@ -3438,6 +3490,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
issueCommentSatisfiedByCommentId: postedComment.id,
issueCommentRetryQueuedAt: null,
});
await completeConferenceRoomTurnAfterReply({
run,
issueId,
commentId: postedComment.id,
});
return { outcome: "satisfied" as const, queuedRun: null };
}