diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 4267087880e..fdf6e53a5a2 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -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 } = diff --git a/server/src/services/issue-dependency-wakeups.ts b/server/src/services/issue-dependency-wakeups.ts index 000d708fc6e..31018620b61 100644 --- a/server/src/services/issue-dependency-wakeups.ts +++ b/server/src/services/issue-dependency-wakeups.ts @@ -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( - (latest, row) => (!latest || row.updatedAt > latest ? row.updatedAt : latest), + const latestBlockerTerminalTransitionAt = blockerTerminalStateRows.reduce( + (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 ); }