fix: re-wake dependencies after blocker reclosure

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 07:37:25 +00:00
1 parent 23bde25828
commit d4dd949c4a
2 files changed
+90 -8

No files matched your search

@@ -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 } =
@@ -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<Date | null>(
(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
);
}