Recover completed native task replies after workspace ownership handoff

This commit is contained in:
Dotta committed 2026-10-08 20:03:23 -05:00
1 parent d6ebded575
commit c786e9c181
2 files changed
+102 -10

No files matched your search

@@ -11,7 +11,7 @@ import { conversationNativeDecision, isConversation } from "../agent-conversatio
import { issueTreeControlService } from "../issue-tree-control.js";
import { randomUUID } from "node:crypto";
import { preserveNativeWorkspaceExportLease } from "./native-workspace-export-resume.js";
import { and, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
approvals,
@@ -20,6 +20,7 @@ import {
heartbeatRuns,
heartbeatRunEvents,
issueApprovals,
issueComments,
issueRecoveryActions,
issueThreadInteractions,
issues,
@@ -62,7 +63,11 @@ import {
import { logger } from "../../middleware/logger.js";
import {
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON,
findHeartbeatRunCompletionComment,
isExternalChatPresentationContext,
readCompletedAssistantMessageCandidate,
resolveHeartbeatRunResponse,
selectHeartbeatRunFinalAgentMessage,
} from "../heartbeat-run-summary.js";
import { resolveChatRunPresentationAuthorizationReason } from "../chat-run-publications.js";
import {
@@ -940,13 +945,6 @@ export async function repairCommittedNativeChatResponse(
!accepted ||
!decision ||
result.schema !== "paperclip.run_result.v1" ||
result.reportedWorkDisposition !== "yielded" ||
record(result.continuation).kind !== "response_wake" ||
!Array.isArray(result.attentionRequests) ||
result.attentionRequests.length > 0 ||
terminal.runTerminalState !== "succeeded" ||
terminal.turnTerminalState !== "completed" ||
terminal.reportedWorkDisposition !== "yielded" ||
!(await acceptedResponseDigestMatches(tx, run, accepted))
)
return false;
@@ -954,6 +952,69 @@ export async function repairCommittedNativeChatResponse(
// presentation contract, including selected attachments. Do not bypass it.
if (record(decision.decisionJson).externalChatReviewPresentation)
return false;
// Copyback can be owned by reconciliation before the live heartbeat
// reaches its presentation step. Recover a completed internal task's
// exact final reply from the same accepted turn, without rerunning work
// or granting external-chat publication authority.
if (
!isConversation(issue) &&
run.status === "succeeded" &&
record(run.contextSnapshot).skipIssueComment !== true &&
!isExternalChatPresentationContext(run.contextSnapshot) &&
result.reportedWorkDisposition === "done" &&
terminal.runTerminalState === "succeeded" &&
terminal.turnTerminalState === "completed" &&
terminal.reportedWorkDisposition === "done" &&
(await resolveChatRunPresentationAuthorizationReason(tx, input)) === null
) {
const rows = await tx.select({ seq: heartbeatRunEvents.seq, payload: heartbeatRunEvents.payload })
.from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, input.companyId),
eq(heartbeatRunEvents.runId, run.id),
eq(heartbeatRunEvents.eventType, "item.completed"),
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${accepted.turnId}`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
)).orderBy(desc(heartbeatRunEvents.seq)).limit(200);
const finalAgentMessage = selectHeartbeatRunFinalAgentMessage({
candidates: rows.flatMap((row) => {
const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent });
return candidate ? [candidate] : [];
}),
});
if (!finalAgentMessage) return false;
const comments = await tx.select({ id: issueComments.id, body: issueComments.body })
.from(issueComments).where(and(
eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueId),
eq(issueComments.createdByRunId, run.id),
)).orderBy(desc(issueComments.createdAt), desc(issueComments.id));
const resolved = resolveHeartbeatRunResponse({
resultJson: { ...record(run.resultJson), nativeResult: result },
existingComment: findHeartbeatRunCompletionComment(comments, run.resultJson),
finalAgentMessage,
});
if (!resolved.text || resolved.decision.commentAction !== "create") return false;
const comment = await issueService(db).addComment(input.issueId, resolved.text,
{ agentId: run.agentId, runId: run.id }, { completionReply: true }, tx);
await tx.update(heartbeatRuns).set({
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
presentationDecision: { ...resolved.decision, commentId: comment.id,
reasonCodes: [...resolved.decision.reasonCodes, "committed_task_response_recovered"] },
})}::jsonb`, updatedAt: new Date(),
}).where(eq(heartbeatRuns.id, run.id));
agentId = run.agentId;
return true;
}
if (
result.reportedWorkDisposition !== "yielded" ||
record(result.continuation).kind !== "response_wake" ||
!Array.isArray(result.attentionRequests) ||
result.attentionRequests.length > 0 ||
terminal.runTerminalState !== "succeeded" ||
terminal.turnTerminalState !== "completed" ||
terminal.reportedWorkDisposition !== "yielded"
) return false;
await authorizeCommittedChatResponse(db, tx, {
...input,
agentId: run.agentId,
@@ -14,6 +14,7 @@ import {
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issueRelations,
issueThreadInteractions,
@@ -42,7 +43,7 @@ import {
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js";
import { finalizeNativeRun } from "./native-run-finalizer.js";
import { finalizeNativeRun, repairCommittedNativeChatResponse } from "./native-run-finalizer.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { claimNativeReviewExecutionLock, getNativeReviewAssignment } from "./native-review-participant.js";
@@ -974,7 +975,37 @@ describe("PaperclipControlPlanePort conformance", () => {
expect(response.decision.chosenSource).toBe("final_agent_message");
expect(stored.findIndex((row) => row.eventType === "run.result.accepted"))
.toBeGreaterThan(stored.findIndex((row) => row.eventType === "item.completed"));
await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded" });
// A workspace recovery owner can commit before the live heartbeat reaches
// presentation. The file-preparation comment must not hide the real reply.
const [prepared] = await db.insert(issueComments).values({
companyId: identity.companyId, issueId: taskIssueId,
authorAgentId: identity.agentId, authorType: "agent", createdByRunId: taskRunId,
body: "Prepared Continuity file for this response.",
}).returning();
const receipt = { semanticToolReceipts: { file: {
operationId: "register_deliverable", result: {
commandId: "deliverable-prepared:attachment-1", disposition: "applied",
attachmentId: "attachment-1", entityRefs: ["attachment-1", prepared!.id],
},
} } };
await db.update(heartbeatRuns).set({
contextSnapshot: { skipIssueComment: true },
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify(receipt)}::jsonb`,
}).where(eq(heartbeatRuns.id, taskRunId));
await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
const repair = () => repairCommittedNativeChatResponse(db, {
companyId: identity.companyId, issueId: taskIssueId, runId: taskRunId,
});
expect(await repair()).toBe(false);
await db.update(heartbeatRuns).set({ contextSnapshot: { externalChatContinuation: true } })
.where(eq(heartbeatRuns.id, taskRunId));
expect(await repair()).toBe(false);
await db.update(heartbeatRuns).set({ contextSnapshot: {} }).where(eq(heartbeatRuns.id, taskRunId));
expect(await repair()).toBe(true);
expect(await repair()).toBe(false);
expect(await db.select({ body: issueComments.body }).from(issueComments)
.where(eq(issueComments.createdByRunId, taskRunId)).orderBy(asc(issueComments.createdAt)))
.toEqual([{ body: prepared!.body }, { body: finalText }]);
await expect(port.completeRun({
result: taskResult,
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,