mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix: re-wake dependencies after blocker reclosure
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user