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

@@ -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 };
}