mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
fix: key dependency wakes to terminal transitions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d4dd949c4a
commit
5ab0d0d5cc
2 files changed
+88
-11
No files matched your search
@@ -854,7 +854,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ updatedAt: new Date("2026-01-01T00:00:00.000Z") })
|
||||
.set({
|
||||
cancelledAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
})
|
||||
.where(eq(issues.id, blockerIssueId));
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId,
|
||||
@@ -875,11 +878,19 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "todo", updatedAt: new Date("2026-01-01T00:00:03.000Z") })
|
||||
.set({
|
||||
status: "todo",
|
||||
cancelledAt: null,
|
||||
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") })
|
||||
.set({
|
||||
status: "cancelled",
|
||||
cancelledAt: new Date("2026-01-01T00:00:04.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:04.000Z"),
|
||||
})
|
||||
.where(eq(issues.id, blockerIssueId));
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
@@ -896,6 +907,60 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(stateKeyWakes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not re-wake a continuously ready dependency after an unrelated blocker edit", 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({
|
||||
cancelledAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
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({
|
||||
title: "Edited blocker title",
|
||||
updatedAt: new Date("2026-01-01T00:00:04.000Z"),
|
||||
})
|
||||
.where(eq(issues.id, blockerIssueId));
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(0);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(1);
|
||||
const stateKeyWakes = await db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, stateKey),
|
||||
));
|
||||
expect(stateKeyWakes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts null dependency wake returns as deferred instead of enqueue failures", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId } =
|
||||
|
||||
@@ -75,8 +75,9 @@ export function buildIssueBlockersResolvedWakeStateKey(input: {
|
||||
* dependent issue. The check is level-triggered:
|
||||
*
|
||||
* - 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.
|
||||
* after the latest blocker terminal transition. This suppresses a duplicate
|
||||
* wake for the SAME ready state and lets a later reopen/close cycle create a
|
||||
* new wake without treating unrelated blocker edits as a new cycle.
|
||||
* - 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,10 +97,14 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
|
||||
dependentIssueId: input.dependentIssueId,
|
||||
blockerIssueIds: input.blockerIssueIds,
|
||||
});
|
||||
const blockerUpdatedAtRows =
|
||||
const blockerTerminalStateRows =
|
||||
input.blockerIssueIds.length > 0
|
||||
? await db
|
||||
.select({ updatedAt: issues.updatedAt })
|
||||
.select({
|
||||
status: issues.status,
|
||||
completedAt: issues.completedAt,
|
||||
cancelledAt: issues.cancelledAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
@@ -108,8 +113,15 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const latestBlockerUpdatedAt = blockerUpdatedAtRows.reduce<Date | null>(
|
||||
(latest, row) => (!latest || row.updatedAt > latest ? row.updatedAt : latest),
|
||||
const latestBlockerTerminalTransitionAt = blockerTerminalStateRows.reduce<Date | null>(
|
||||
(latest, row) => {
|
||||
const transitionedAt = row.status === "done"
|
||||
? row.completedAt
|
||||
: row.status === "cancelled"
|
||||
? row.cancelledAt
|
||||
: null;
|
||||
return transitionedAt && (!latest || transitionedAt > latest) ? transitionedAt : latest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
const legacyKeys = [
|
||||
@@ -158,8 +170,8 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
|
||||
(wake) =>
|
||||
wake.idempotencyKey !== stateKey ||
|
||||
wake.status !== "completed" ||
|
||||
!latestBlockerUpdatedAt ||
|
||||
wake.requestedAt >= latestBlockerUpdatedAt,
|
||||
!latestBlockerTerminalTransitionAt ||
|
||||
wake.requestedAt >= latestBlockerTerminalTransitionAt,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user