fix: key dependency wakes to terminal transitions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 07:43:23 +00:00
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
);
}