mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
fix: make connection checks and task handoffs reliable (#13404)
Preserve connection-probe outcomes through cleanup, reduce unrelated startup work, and report selected Claude authentication accurately. Make artifact download actions match their labels. Route delegated feedback through its active child, retain accepted messages across completion, and avoid redundant worker runs for proven closing notes. Preserve explicit follow-ups, human input, company boundaries, source provenance, and mixed issue references. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2d47a8058d
commit
f1d57863d2
20 files changed
+740
-65
No files matched your search
@@ -106,6 +106,8 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
|
||||
assigneeAgentId?: string | null;
|
||||
executionRunId?: string | null;
|
||||
checkoutRunId?: string | null;
|
||||
parentId?: string | null;
|
||||
identifier?: string;
|
||||
}): Promise<string> {
|
||||
const issueId = input.issueId ?? randomUUID();
|
||||
await db.insert(issues).values({
|
||||
@@ -117,6 +119,8 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
executionRunId: input.executionRunId ?? null,
|
||||
checkoutRunId: input.checkoutRunId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
identifier: input.identifier,
|
||||
});
|
||||
return issueId;
|
||||
}
|
||||
@@ -165,6 +169,76 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
it.each([
|
||||
"completed", "multiple_comments", "repeated_reference", "parent_reference", "mixed_issue_references", "mixed_foreign_references", "mixed_unknown_references", "no_comments", "missing_comment",
|
||||
"human_comment", "other_run_comment", "foreign_comment", "other_issue_comment", "deleted_comment",
|
||||
"mixed_human_comments", "mixed_other_run_comments", "mixed_unrelated_comments",
|
||||
"no_reference", "code_reference", "ambiguous_children", "child_open", "child_cancelled",
|
||||
"foreign_child", "unrelated_child", "other_child_assignee", "parent_open",
|
||||
"source_other_task", "source_other_agent", "wrong_company", "wrong_run",
|
||||
])("proves completed delegation from transactional comment and child rows (%s)", async (scenario) => {
|
||||
const companyId = await seedCompany();
|
||||
const otherCompanyId = await seedCompany();
|
||||
const leadId = await seedAgent({ companyId, name: "Lead" });
|
||||
const workerId = await seedAgent({ companyId, name: "Worker" });
|
||||
const foreignAgentId = await seedAgent({ companyId: otherCompanyId });
|
||||
const issueId = await seedIssue({ companyId, identifier: "QA-1", assigneeAgentId: leadId, status: scenario === "parent_open" ? "in_progress" : "done" });
|
||||
const otherIssueId = await seedIssue({ companyId, identifier: "QA-99" });
|
||||
const foreignIssueId = await seedIssue({ companyId: otherCompanyId, identifier: "QA-98" });
|
||||
const runId = await seedRun({ companyId, agentId: scenario === "source_other_agent" ? workerId : leadId,
|
||||
status: "succeeded", contextSnapshot: { issueId: scenario === "source_other_task" ? otherIssueId : issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
const otherRunId = await seedRun({ companyId, agentId: leadId, status: "succeeded", contextSnapshot: { issueId } });
|
||||
await seedIssue({
|
||||
companyId: scenario === "foreign_child" ? otherCompanyId : companyId,
|
||||
identifier: "QA-2", parentId: scenario === "unrelated_child" ? otherIssueId : issueId,
|
||||
assigneeAgentId: scenario === "foreign_child" ? foreignAgentId : scenario === "other_child_assignee" ? leadId : workerId,
|
||||
status: scenario === "child_open" ? "in_progress" : scenario === "child_cancelled" ? "cancelled" : "done",
|
||||
});
|
||||
if (scenario === "ambiguous_children") {
|
||||
await seedIssue({ companyId, identifier: "QA-3", parentId: issueId, assigneeAgentId: workerId, status: "done" });
|
||||
}
|
||||
const body = scenario === "parent_reference" ? "QA-1 is done thanks to @Worker completing QA-2"
|
||||
: scenario === "mixed_issue_references" ? "@Worker completed QA-2; now investigate QA-99"
|
||||
: scenario === "mixed_foreign_references" ? "@Worker completed QA-2; also check QA-98"
|
||||
: scenario === "mixed_unknown_references" ? "@Worker completed QA-2; also check QA-999"
|
||||
: scenario === "no_reference" ? "Thanks @Worker" : scenario === "code_reference" ? "Example `QA-2`"
|
||||
: scenario === "ambiguous_children" ? "@Worker completed QA-2 and QA-3"
|
||||
: scenario === "repeated_reference" ? "@Worker completed [QA-2](/issues/QA-2); QA-2 is done"
|
||||
: "@Worker completed QA-2";
|
||||
const [comment] = await db.insert(issueComments).values({
|
||||
companyId: scenario === "foreign_comment" ? otherCompanyId : companyId,
|
||||
issueId: scenario === "foreign_comment" ? foreignIssueId : scenario === "other_issue_comment" ? otherIssueId : issueId,
|
||||
authorAgentId: scenario === "human_comment" ? null : leadId,
|
||||
authorUserId: scenario === "human_comment" ? "responsible-user" : null,
|
||||
createdByRunId: scenario === "human_comment" ? null : scenario === "other_run_comment" ? otherRunId : runId,
|
||||
body, deletedAt: scenario === "deleted_comment" ? new Date() : null,
|
||||
}).returning();
|
||||
const commentIds = scenario === "no_comments" ? [] : [comment.id];
|
||||
if (scenario === "missing_comment") commentIds.push(randomUUID());
|
||||
if (["multiple_comments", "mixed_human_comments", "mixed_other_run_comments", "mixed_unrelated_comments"].includes(scenario)) {
|
||||
const [second] = await db.insert(issueComments).values({ companyId, issueId,
|
||||
authorAgentId: scenario === "mixed_human_comments" ? null : leadId,
|
||||
authorUserId: scenario === "mixed_human_comments" ? "responsible-user" : null,
|
||||
createdByRunId: scenario === "mixed_human_comments" ? null : scenario === "mixed_other_run_comments" ? otherRunId : runId,
|
||||
body: scenario === "mixed_unrelated_comments" ? "@Worker, investigate a new error" : "QA-2 was delivered",
|
||||
}).returning();
|
||||
commentIds.push(second.id);
|
||||
}
|
||||
let checked = false;
|
||||
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
|
||||
await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => {
|
||||
checked = true;
|
||||
const completed = await ports.transaction.isCompletedDelegationMention({
|
||||
companyId: scenario === "wrong_company" ? otherCompanyId : companyId,
|
||||
issueId, finishingRunId: scenario === "wrong_run" ? otherRunId : runId, wakeAgentId: workerId, commentIds,
|
||||
});
|
||||
expect(completed).toBe(["completed", "multiple_comments", "repeated_reference", "parent_reference"].includes(scenario));
|
||||
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
|
||||
});
|
||||
expect(checked).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("rechecks disabled chat mode before interrupted queue promotion (conversation=%s)", async (conversation) => {
|
||||
const settings = instanceSettingsService(db);
|
||||
const original = (await settings.getExperimental()).enableAgentChat;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { currentConversationCommentCondition } from "../../../services/agent-con
|
||||
import { getExecutionBlocker } from "../../../services/execution-blocker.js";
|
||||
import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { extractIssueReferenceIdentifiers } from "@paperclipai/shared";
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
@@ -373,6 +374,44 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
|
||||
return { allSelfAuthored: rows.length > 0 && rows.every((row) => row.createdByRunId === finishingRunId) };
|
||||
},
|
||||
|
||||
async isCompletedDelegationMention({ companyId, issueId, finishingRunId, wakeAgentId, commentIds }) {
|
||||
if (
|
||||
run.companyId !== companyId || run.id !== finishingRunId ||
|
||||
readNonEmptyString(parseObject(run.contextSnapshot).issueId) !== issueId
|
||||
) return false;
|
||||
const uniqueCommentIds = [...new Set(commentIds)];
|
||||
if (uniqueCommentIds.length === 0) return false;
|
||||
const parent = await tx.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId, identifier: issues.identifier })
|
||||
.from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
||||
.then((rows) => rows[0]);
|
||||
if (parent?.status !== "done" || parent.assigneeAgentId !== run.agentId) return false;
|
||||
|
||||
const comments = await tx.select({ body: issueComments.body, createdByRunId: issueComments.createdByRunId,
|
||||
authorAgentId: issueComments.authorAgentId })
|
||||
.from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||||
inArray(issueComments.id, uniqueCommentIds), isNull(issueComments.deletedAt), currentConversationCommentCondition(),
|
||||
));
|
||||
if (comments.length !== uniqueCommentIds.length || comments.some((comment) =>
|
||||
comment.createdByRunId !== run.id || (comment.authorAgentId !== null && comment.authorAgentId !== run.agentId)
|
||||
)) return false;
|
||||
const referencesByComment = comments.map((comment) => extractIssueReferenceIdentifiers(comment.body)
|
||||
.filter((identifier) => identifier !== parent.identifier));
|
||||
// A parent link is context; every other reference must identify the
|
||||
// single completed child. Never discard unrelated follow-up work.
|
||||
if (referencesByComment.some((references) => references.length !== 1)) return false;
|
||||
const identifiers = [...new Set(referencesByComment.flat())];
|
||||
const children = await tx.select({ identifier: issues.identifier, status: issues.status })
|
||||
.from(issues).where(and(
|
||||
eq(issues.companyId, companyId), eq(issues.parentId, issueId),
|
||||
eq(issues.assigneeAgentId, wakeAgentId), inArray(issues.identifier, identifiers),
|
||||
));
|
||||
return referencesByComment.every((references) => {
|
||||
const referencedChildren = children.filter((child) => child.identifier !== null && references.includes(child.identifier));
|
||||
return referencedChildren.length === 1 && referencedChildren[0].status === "done";
|
||||
});
|
||||
},
|
||||
|
||||
async reopenIssue({ companyId, issueId }) {
|
||||
const updated = await issuesSvc.updateForCompany(issueId, companyId, { status: "todo", executionState: null }, tx);
|
||||
return updated ? toIssueSnapshot(updated as unknown as IssueRow) : null;
|
||||
|
||||
Reference in new issue
Block a user