diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 80ac0ca76ac..663633ceafb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -238,6 +238,7 @@ import { buildFeedbackDeliveryFingerprint, buildFeedbackDeliveryRetryIdempotencyKey, carriesExplicitFeedbackResume, + collectFeedbackDispositionHandledCommentIds, isEligibleFeedbackDeliveryWake, isSuccessfulFeedbackDeliveryRecoveryMatch, remainingFeedbackDispositionCommentIds, @@ -10211,7 +10212,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) run: typeof heartbeatRuns.$inferSelect, delivery: FeedbackDeliveryContext, ) { - const comment = await db + const comments = await db .select({ id: issueComments.id, metadata: issueComments.metadata }) .from(issueComments) .where(and( @@ -10221,7 +10222,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) isNull(issueComments.deletedAt), )) .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) - .then((rows) => rows.find((row) => { + .then((rows) => rows.filter((row) => { const disposition = row.metadata?.feedbackDisposition; if ( disposition?.kind !== "feedback_delivery" @@ -10231,17 +10232,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) deliveryCommentIds: delivery.commentIds, handledCommentIds: disposition.handledCommentIds, }) !== null; - }) ?? null); - if (!comment) return null; - const disposition = comment.metadata?.feedbackDisposition; - if (!disposition) return null; + })); + if (comments.length === 0) return null; + const handledCommentIds = collectFeedbackDispositionHandledCommentIds({ + deliveryCommentIds: delivery.commentIds, + rootWakeupRequestId: delivery.rootWakeupRequestId, + dispositions: comments.map((comment) => comment.metadata?.feedbackDisposition), + }); // Only an explicit, persisted disposition can acknowledge feedback. Ordinary - // run comments and other same-run issue activity can be unrelated. + // run comments and other same-run issue activity can be unrelated. A run may + // post several partial receipts, so combine all valid receipts for this root. return { kind: "comment", - id: comment.id, - rootWakeupRequestId: disposition.rootWakeupRequestId, - sourceCommentIds: disposition.handledCommentIds, + id: comments[0]!.id, + rootWakeupRequestId: delivery.rootWakeupRequestId, + sourceCommentIds: handledCommentIds, } as const; } diff --git a/server/src/services/recovery/feedback-delivery.test.ts b/server/src/services/recovery/feedback-delivery.test.ts index 417f649a7a3..e23b11f5b46 100644 --- a/server/src/services/recovery/feedback-delivery.test.ts +++ b/server/src/services/recovery/feedback-delivery.test.ts @@ -5,6 +5,7 @@ import { STRANDED_FEEDBACK_DELIVERY_BACKSTOP_SOURCE, buildFeedbackDeliveryFingerprint, buildFeedbackDeliveryRetryIdempotencyKey, + collectFeedbackDispositionHandledCommentIds, decideStrandedFeedbackDeliveryBackstop, hasCompleteFeedbackDispositionCoverage, isSuccessfulFeedbackDeliveryRecoveryMatch, @@ -203,6 +204,18 @@ describe("feedback disposition coverage", () => { handledCommentIds, })).toBeNull(); }); + + it("combines complementary partial receipts from one run", () => { + expect(collectFeedbackDispositionHandledCommentIds({ + deliveryCommentIds: ["comment-1", "comment-2", "comment-3"], + rootWakeupRequestId: "wake-root", + dispositions: [ + { rootWakeupRequestId: "wake-root", handledCommentIds: ["comment-1"] }, + { rootWakeupRequestId: "wake-root", handledCommentIds: ["comment-3"] }, + { rootWakeupRequestId: "other-root", handledCommentIds: ["comment-2"] }, + ], + })).toEqual(["comment-1", "comment-3"]); + }); }); describe("manual feedback retry attempts", () => { diff --git a/server/src/services/recovery/feedback-delivery.ts b/server/src/services/recovery/feedback-delivery.ts index 3bafc0e1d74..0a0832f8c0a 100644 --- a/server/src/services/recovery/feedback-delivery.ts +++ b/server/src/services/recovery/feedback-delivery.ts @@ -130,6 +130,26 @@ export function remainingFeedbackDispositionCommentIds(input: { return [...deliveryIds].filter((commentId) => !handledIds.has(commentId)); } +export function collectFeedbackDispositionHandledCommentIds(input: { + deliveryCommentIds: string[]; + rootWakeupRequestId: string; + dispositions: Array<{ + rootWakeupRequestId: string; + handledCommentIds: string[]; + } | null | undefined>; +}) { + const handledIds = new Set(); + for (const disposition of input.dispositions) { + if (!disposition || disposition.rootWakeupRequestId !== input.rootWakeupRequestId) continue; + if (remainingFeedbackDispositionCommentIds({ + deliveryCommentIds: input.deliveryCommentIds, + handledCommentIds: disposition.handledCommentIds, + }) === null) continue; + for (const commentId of disposition.handledCommentIds) handledIds.add(commentId); + } + return input.deliveryCommentIds.filter((commentId) => handledIds.has(commentId)); +} + export function isSuccessfulFeedbackDeliveryRecoveryMatch(input: { handlingEvidence: { rootWakeupRequestId: string; diff --git a/server/src/services/recovery/index.ts b/server/src/services/recovery/index.ts index 27ea85fcda0..dd65a335a15 100644 --- a/server/src/services/recovery/index.ts +++ b/server/src/services/recovery/index.ts @@ -40,6 +40,7 @@ export { buildFeedbackDeliveryFingerprint, buildFeedbackDeliveryRetryIdempotencyKey, carriesExplicitFeedbackResume, + collectFeedbackDispositionHandledCommentIds, decideStrandedFeedbackDeliveryBackstop, hasCompleteFeedbackDispositionCoverage, isEligibleFeedbackDeliveryWake,