fix(heartbeat): deliver a person's answer that waited behind a turn that closed the task

A person can answer an agent's question while the agent's turn is still
running. The answer then waits on its own deferred wake. If that turn
completed the task, release cancelled every deferred assignee wake on the
done task, including the answer, even though the person's own queued
messages reopen the task. The answer was dropped without any run receiving it.

Treat an undelivered person's answer that was saved before the task was
completed as owed input: reopen the done task straight to in_progress (an
answer continues the assignee's own execution) and promote the answer wake.
Answers saved after completion, tool-action outcomes, already-delivered
answers, agent-requested wakes and cancelled tasks keep the old behavior.

Eval steering.approval-delivery (oracle v7): also accept Acorn work that the
worker's settling turn saved after the answer was saved, while still
requiring exactly one later turn bound to the answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-11 19:42:20 -06:00
1 parent 42fd790e80
commit 8a4eaab2c0
8 files changed
+238 -9

No files matched your search

@@ -34,6 +34,7 @@ import {
nativeRunFinalizations,
} from "@paperclipai/db";
import { hasConversationContinuationPolicy } from "../../../services/conversation-continuation.js";
import { readQueuedInteractionResponse } from "../../../services/queued-interaction-response.js";
import { legacyExecutionNeedsReconciliationWithEvidence } from "../../../services/legacy-execution-recovery.js";
import {
authorizeFailedChatRunRetryWake,
@@ -450,6 +451,15 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
};
},
async isOwedInteractionAnswer({ companyId, issueId, payload }) {
const response = await readQueuedInteractionResponse(tx as unknown as Db, companyId, issueId, payload);
if (!response || response.toolActionOutcome || !response.comment.authorUserId) return false;
const [task] = await tx.select({ completedAt: issues.completedAt }).from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
const answeredAt = new Date(response.comment.createdAt);
return !!task && (!task.completedAt || answeredAt.getTime() <= task.completedAt.getTime());
},
async getCommentSelfAuthorship({ companyId, issueId, finishingRunId, commentIds }) {
const rows = await tx
.select({ createdByRunId: issueComments.createdByRunId })
@@ -500,8 +510,8 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
return isCompletedOnboardingHandoffWake(tx, input);
},
async reopenIssue({ companyId, issueId }) {
const updated = await issuesSvc.updateForCompany(issueId, companyId, { status: "todo", executionState: null }, tx);
async reopenIssue({ companyId, issueId, status = "todo" }) {
const updated = await issuesSvc.updateForCompany(issueId, companyId, { status, executionState: null }, tx);
return updated ? toIssueSnapshot(updated as unknown as IssueRow) : null;
},