From d4dd949c4a1d3b3cb79ecd5a70ef2568b894da52 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 07:37:25 +0000 Subject: [PATCH] fix: re-wake dependencies after blocker reclosure Co-Authored-By: Paperclip --- ...eartbeat-issue-liveness-escalation.test.ts | 58 ++++++++++++++++++- .../src/services/issue-dependency-wakeups.ts | 40 ++++++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 40eaaedfe14..4267087880e 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -568,7 +568,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .then((rows) => rows[0] ?? null); expect(wake).toMatchObject({ reason: "issue_blockers_resolved", - idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`, + idempotencyKey: buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }), }); }); @@ -840,6 +843,59 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(stateKeyWakes).toHaveLength(1); }); + it("re-wakes a blocked dependent after its cancelled blocker is reopened and cancelled again", async () => { + await enableAutoRecovery(); + const { companyId, agentId, blockedIssueId, blockerIssueId } = + await seedResolvedDependencyBackstopFixture({ workspaceState: "none", blockerStatus: "cancelled" }); + const stateKey = buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }); + + await db + .update(issues) + .set({ updatedAt: new Date("2026-01-01T00:00:00.000Z") }) + .where(eq(issues.id, blockerIssueId)); + await db.insert(agentWakeupRequests).values({ + companyId, + agentId, + source: "automation", + triggerDetail: "system", + reason: "issue_blockers_resolved", + payload: { + issueId: blockedIssueId, + resolvedBlockerIssueId: blockerIssueId, + blockerIssueIds: [blockerIssueId], + }, + status: "completed", + requestedAt: new Date("2026-01-01T00:00:01.000Z"), + finishedAt: new Date("2026-01-01T00:00:02.000Z"), + idempotencyKey: stateKey, + }); + + await db + .update(issues) + .set({ status: "todo", updatedAt: new Date("2026-01-01T00:00:03.000Z") }) + .where(eq(issues.id, blockerIssueId)); + await db + .update(issues) + .set({ status: "cancelled", updatedAt: new Date("2026-01-01T00:00:04.000Z") }) + .where(eq(issues.id, blockerIssueId)); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.dependencyWakesHealed).toBe(1); + expect(result.dependencyWakeExistingSkipped).toBe(0); + const stateKeyWakes = await db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where(and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.idempotencyKey, stateKey), + )); + expect(stateKeyWakes).toHaveLength(2); + }); + it("counts null dependency wake returns as deferred instead of enqueue failures", async () => { await enableAutoRecovery(); const { companyId, agentId } = diff --git a/server/src/services/issue-dependency-wakeups.ts b/server/src/services/issue-dependency-wakeups.ts index bcd565a8b4e..000d708fc6e 100644 --- a/server/src/services/issue-dependency-wakeups.ts +++ b/server/src/services/issue-dependency-wakeups.ts @@ -1,7 +1,7 @@ import { and, eq, inArray, or } from "drizzle-orm"; import { createHash } from "node:crypto"; import type { Db } from "@paperclipai/db"; -import { agentWakeupRequests } from "@paperclipai/db"; +import { agentWakeupRequests, issues } from "@paperclipai/db"; export const ISSUE_BLOCKERS_RESOLVED_WAKE_REASON = "issue_blockers_resolved"; @@ -74,9 +74,9 @@ export function buildIssueBlockersResolvedWakeStateKey(input: { * Find a wake that already covers the current dependency-ready state of the * dependent issue. The check is level-triggered: * - * - The state key matches a wake in any idempotent status (including - * `completed`). This suppresses a duplicate wake for the SAME ready state and - * bounds reconciliation. + * - The state key matches an in-flight wake, or a `completed` wake requested + * after the latest blocker update. This suppresses a duplicate wake for the + * SAME ready state and lets a later reopen/close cycle create a new wake. * - Each legacy per-edge key matches only a wake that is still in flight * (`queued`, `deferred_issue_execution`, `claimed`). This prevents a duplicate * wake while an old-format wake is still pending after a deploy, but it never @@ -96,6 +96,22 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState( dependentIssueId: input.dependentIssueId, blockerIssueIds: input.blockerIssueIds, }); + const blockerUpdatedAtRows = + input.blockerIssueIds.length > 0 + ? await db + .select({ updatedAt: issues.updatedAt }) + .from(issues) + .where( + and( + eq(issues.companyId, input.companyId), + inArray(issues.id, [...new Set(input.blockerIssueIds)]), + ), + ) + : []; + const latestBlockerUpdatedAt = blockerUpdatedAtRows.reduce( + (latest, row) => (!latest || row.updatedAt > latest ? row.updatedAt : latest), + null, + ); const legacyKeys = [ ...new Set( input.blockerIssueIds @@ -121,11 +137,12 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState( ) : null; - return db + const matchingWakes = await db .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey, + requestedAt: agentWakeupRequests.requestedAt, }) .from(agentWakeupRequests) .where( @@ -134,6 +151,15 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState( legacyMatch ? or(stateMatch, legacyMatch) : stateMatch, ), ) - .limit(1) - .then((rows) => rows[0] ?? null); + .limit(20); + + return ( + matchingWakes.find( + (wake) => + wake.idempotencyKey !== stateKey || + wake.status !== "completed" || + !latestBlockerUpdatedAt || + wake.requestedAt >= latestBlockerUpdatedAt, + ) ?? null + ); }