fix: retry reviewed chat setup after temporary contention (#19787)

Retry reviewed chat preparation with the original accepted input.

Bound retries for authorization proof waits that expire before provider
work. Preserve the original answer receipt and queue later wakes
separately. Keep plan confirmation semantics by assigning the interaction
marker only to question answer delivery and legacy question payloads.

Verification: 433 tests across four backend suites, all 17 project-chat
browser tests, and all server source/test typecheck layers passed. The
plan approval regression was reproduced on the previous head before the
fix. All applicable CI passed and exact-head Greptile Apex scored 5/5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-07 02:14:42 -08:00
1 parent 27170dca49
commit bf12b2d8ad
16 files changed
+405 -28

No files matched your search

@@ -19,6 +19,7 @@ import {
heartbeatRunEvents,
heartbeatRuns,
issueRelations,
issueComments,
issues,
projects,
} from "@paperclipai/db";
@@ -493,7 +494,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
return { companyId, agentId, issueId, runId, now };
}
it.each(["remote_workspace_prepare_timeout", "reusable_sandbox_resume_failed", "remote_workspace_restore_failed"].flatMap(errorCode =>
it.each(["remote_workspace_prepare_timeout", "reusable_sandbox_resume_failed", "remote_workspace_restore_failed", "reviewed_chat_execution_binding_not_ready"].flatMap(errorCode =>
(["resume", "paused", "Stop", "dependency"] as const).map(action => [errorCode, action] as const)))("bounds %s bootstrap retries across restart and honors %s", async (errorCode, action) => {
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } })
@@ -535,6 +536,39 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(3);
});
it("retains the reviewed chat admission on a durable retry without approving the task", async () => {
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
const wakeCommentIds = [randomUUID(), randomUUID()];
const source = "chat:slack";
const contextSnapshot = { issueId, taskId: issueId, source, wakeCommentIds, commentId: wakeCommentIds[0] };
await db.update(heartbeatRuns).set({
errorCode: "reviewed_chat_execution_binding_not_ready", contextSnapshot,
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
}).where(eq(heartbeatRuns.id, runId));
const outcomes = await Promise.all([
heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 }),
heartbeatService(db).scheduleBoundedRetry(runId, { now, random: () => 0 }),
]);
expect(outcomes.every(outcome => outcome.outcome === "scheduled")).toBe(true);
const children = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
expect(children).toHaveLength(1);
expect(children[0]).toMatchObject({ companyId, agentId, issueId, scopeKind: "issue", status: "scheduled_retry",
scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure", contextSnapshot });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ status: "in_review", executionRunId: children[0]!.id });
const [laterComment] = await db.insert(issueComments).values({ companyId, issueId,
authorType: "user", authorUserId: "responsible-user", body: "A later board follow-up" }).returning();
await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "system", reason: "issue_commented",
payload: { issueId, commentId: laterComment!.id }, requestedByActorType: "user", requestedByActorId: "responsible-user",
contextSnapshot: { issueId, taskId: issueId, source: "issue.comment", wakeCommentId: laterComment!.id, wakeCommentIds: [laterComment!.id] } });
const [preserved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, children[0]!.id));
expect(preserved).toEqual(children[0]);
const laterWakes = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution")));
expect(laterWakes).toHaveLength(1);
expect(laterWakes[0]).toMatchObject({ runId: null, payload: { issueId, commentId: laterComment!.id } });
});
it("bounds interrupted conversations across restarts and concurrent scheduling", async () => {
const { companyId, issueId, runId, now } = await seedMaxTurnFixture();
const resultJson = { conversationContinuation: "continue_conversation_v1" };
@@ -278,6 +278,43 @@ describe.each(["slack", "discord"] as const)("reviewed %s execution binding", (p
await temporary?.cleanup();
});
it("names exhausted lock contention without accepting authority, then rechecks access after release", async () => {
let release!: () => void;
let acquired!: () => void;
const held = new Promise<void>((resolve) => { acquired = resolve; });
const gate = new Promise<void>((resolve) => { release = resolve; });
const lock = db.transaction(async (tx) => {
await tx.select().from(issues).where(eq(issues.id, issueId)).for("update");
acquired();
await gate;
});
await held;
const onDenied = vi.fn();
try {
await expect(attestReviewedExternalChatRun({
db, ...binding, contextSnapshot: context, onDenied,
})).rejects.toMatchObject({
message: "reviewed_chat_execution_binding_not_ready",
code: "reviewed_chat_execution_binding_not_ready",
});
expect(onDenied).not.toHaveBeenCalled();
} finally {
release();
await lock;
}
await expect(attest()).resolves.toBe(true);
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, userId));
try {
await expect(attest()).resolves.toBe(false);
} finally {
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId));
}
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ status: "in_review", executionRunId: runId, checkoutRunId: null });
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0]?.status)
.toBe("pending");
}, 30_000);
it("attests existing execution ownership while preserving real pending governance and checkout state", async () => {
const [before] = await db
.select()