fix: advance manual feedback retry keys

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 21:18:56 +00:00
1 parent 2c081e4b47
commit d3366b1a8e
3 files changed
+62 -2

No files matched your search

+16 -2
View File
@@ -2,10 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, asc, desc, eq, inArray, isNull, notInArray } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, like, notInArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentWakeupRequests,
agents,
approvals,
companyMemberships,
@@ -113,6 +114,7 @@ import {
FEEDBACK_DELIVERY_RECOVERY_CAUSE,
FEEDBACK_DELIVERY_RETRY_WAKE_REASON,
FEEDBACK_DELIVERY_WAKE_COMMENT_IDS_KEY,
nextFeedbackDeliveryManualRetryAttempt,
remainingFeedbackDispositionCommentIds,
readFeedbackDeliveryRunContext,
} from "../services/recovery/feedback-delivery.js";
@@ -10978,6 +10980,18 @@ export function issueRoutes(
}
const latestCommentId = outstandingCommentIds.at(-1) ?? null;
const manualRetryKeyPrefix = `${activeAction.fingerprint}:manual:`;
const priorManualRetryKeys = await db
.select({ idempotencyKey: agentWakeupRequests.idempotencyKey })
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.companyId, issue.companyId),
like(agentWakeupRequests.idempotencyKey, `${manualRetryKeyPrefix}%`),
));
const manualRetryAttempt = nextFeedbackDeliveryManualRetryAttempt({
fingerprint: activeAction.fingerprint,
existingIdempotencyKeys: priorManualRetryKeys.map((row) => row.idempotencyKey),
});
const deliveryContext = {
issueId: issue.id,
taskId: issue.id,
@@ -10994,7 +11008,7 @@ export function issueRoutes(
payload: deliveryContext,
idempotencyKey: buildFeedbackDeliveryManualRetryIdempotencyKey({
fingerprint: activeAction.fingerprint,
attempt: (activeAction.attemptCount ?? 0) + 1,
attempt: manualRetryAttempt,
}),
requestedByActorType: actor.actorType === "user" ? "user" : "system",
requestedByActorId: actor.actorId,
@@ -8,6 +8,7 @@ import {
decideStrandedFeedbackDeliveryBackstop,
hasCompleteFeedbackDispositionCoverage,
isSuccessfulFeedbackDeliveryRecoveryMatch,
nextFeedbackDeliveryManualRetryAttempt,
remainingFeedbackDispositionCommentIds,
readFeedbackDeliveryRunContext,
type StrandedFeedbackDeliveryBackstopProbe,
@@ -204,6 +205,31 @@ describe("feedback disposition coverage", () => {
});
});
describe("manual feedback retry attempts", () => {
it("advances past durable failed wakes instead of relying on the recovery-action counter", () => {
const fingerprint = "feedback_delivery:company:issue:agent:root";
expect(nextFeedbackDeliveryManualRetryAttempt({
fingerprint,
existingIdempotencyKeys: [
`${fingerprint}:manual:2`,
`${fingerprint}:manual:5`,
`${fingerprint}:manual:not-a-number`,
"feedback_delivery:other:manual:99",
null,
],
})).toBe(6);
});
it("gives concurrent requests the same first attempt for database deduplication", () => {
const input = {
fingerprint: "feedback_delivery:company:issue:agent:root",
existingIdempotencyKeys: [],
};
expect(nextFeedbackDeliveryManualRetryAttempt(input)).toBe(1);
expect(nextFeedbackDeliveryManualRetryAttempt(input)).toBe(1);
});
});
describe("stranded feedback delivery backstop", () => {
it("requeues exactly one normal-model recovery for the original assignee", () => {
const decision = decide();
@@ -77,6 +77,26 @@ export function buildFeedbackDeliveryManualRetryIdempotencyKey(input: {
return `${input.fingerprint}:manual:${input.attempt}`;
}
/**
* Allocate from durable wake keys rather than the recovery-action counter.
* Concurrent requests observe the same next value and collapse at the unique
* index; a later request advances even if a failed wake never updated its
* recovery action.
*/
export function nextFeedbackDeliveryManualRetryAttempt(input: {
fingerprint: string;
existingIdempotencyKeys: Array<string | null>;
}) {
const prefix = `${input.fingerprint}:manual:`;
let maxAttempt = 0;
for (const key of input.existingIdempotencyKeys) {
if (!key?.startsWith(prefix)) continue;
const attempt = Number(key.slice(prefix.length));
if (Number.isSafeInteger(attempt) && attempt > maxAttempt) maxAttempt = attempt;
}
return maxAttempt + 1;
}
export function hasCompleteFeedbackDispositionCoverage(input: {
deliveryCommentIds: string[];
handledCommentIds: string[];