From d3366b1a8e492629cf6468b394edd67038568e2c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 16 Aug 2026 21:18:56 +0000 Subject: [PATCH] fix: advance manual feedback retry keys Co-Authored-By: Paperclip --- server/src/routes/issues.ts | 18 +++++++++++-- .../recovery/feedback-delivery.test.ts | 26 +++++++++++++++++++ .../services/recovery/feedback-delivery.ts | 20 ++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index d38da557473..0b6fda37eb8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/recovery/feedback-delivery.test.ts b/server/src/services/recovery/feedback-delivery.test.ts index 796e1460562..417f649a7a3 100644 --- a/server/src/services/recovery/feedback-delivery.test.ts +++ b/server/src/services/recovery/feedback-delivery.test.ts @@ -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(); diff --git a/server/src/services/recovery/feedback-delivery.ts b/server/src/services/recovery/feedback-delivery.ts index ed6b12d06e3..3bafc0e1d74 100644 --- a/server/src/services/recovery/feedback-delivery.ts +++ b/server/src/services/recovery/feedback-delivery.ts @@ -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; +}) { + 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[];