mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
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()
|
||||
|
||||
Reference in new issue
Block a user