import { and, eq, inArray } from "drizzle-orm"; import { createHash } from "node:crypto"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests } from "@paperclipai/db"; export const ISSUE_BLOCKERS_RESOLVED_WAKE_REASON = "issue_blockers_resolved"; // A wake counts as "already delivered or in flight for the current ready state" // for these statuses. The level-triggered state key uses this full set so that // one wake for a ready state suppresses further wakes for the SAME state. This // bounds reconciliation: after one wake, later passes find the completed row. const IDEMPOTENT_DEPENDENCY_WAKE_STATUSES = [ "queued", "deferred_issue_execution", "claimed", "completed", ] as const; // A wake counts as "still in flight" for these statuses. The `completed` status // is not in this set on purpose. Dependency readiness is level-triggered, so a // historical completed per-edge wake must never suppress a new wake for the // current ready state. The dedup uses this set only for the legacy per-edge key // and for old no-cycle state keys that are still queued after a deploy. const IN_FLIGHT_DEPENDENCY_WAKE_STATUSES = [ "queued", "deferred_issue_execution", "claimed", ] as const; const IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET = new Set(IDEMPOTENT_DEPENDENCY_WAKE_STATUSES); const IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET = new Set(IN_FLIGHT_DEPENDENCY_WAKE_STATUSES); export type IssueBlockersResolvedWakeCycleInput = Date | string | null | undefined; export type IssueBlockersResolvedReadyStateInput = { dependentIssueId: string; blockerIssueIds: string[]; blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput; }; /** * Canonical blocked-cycle stamp for the dependency-ready state key. * `blockedTransitionAt` is UTC ISO-8601, or `none` when the dependent has no * recorded transition into `blocked`. */ export function formatIssueBlockersResolvedWakeCycle( blockedTransitionAt: IssueBlockersResolvedWakeCycleInput, ): string { if (blockedTransitionAt == null || blockedTransitionAt === "") return "none"; const parsed = blockedTransitionAt instanceof Date ? blockedTransitionAt : new Date(blockedTransitionAt); if (Number.isNaN(parsed.getTime())) return "none"; return parsed.toISOString(); } function uniqueSortedBlockerIssueIds(blockerIssueIds: string[]): string[] { return [...new Set(blockerIssueIds.filter(Boolean))].sort(); } function hashBlockerReadyStateDigest(sortedBlockerIssueIds: string[], cycle: string | null): string { const payload = cycle == null ? sortedBlockerIssueIds.join(",") : `${sortedBlockerIssueIds.join(",")}\n${cycle}`; return createHash("sha256").update(payload).digest("hex").slice(0, 32); } function buildStateKey(dependentIssueId: string, digest: string, blockerCount: number): string { return [ ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, "state", dependentIssueId, String(blockerCount), digest, ].join(":"); } /** * Legacy per-edge idempotency key. One key encodes a single resolved blocker * edge `issue_blockers_resolved:{dependentIssueId}:{resolvedBlockerIssueId}`. * The dedup keeps this format only to read wake rows written before the * level-triggered state key existed. */ export function buildIssueBlockersResolvedWakeIdempotencyKey(input: { dependentIssueId: string; resolvedBlockerIssueId: string; }) { return [ ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, input.dependentIssueId, input.resolvedBlockerIssueId, ].join(":"); } /** * Pre-cycle level-triggered key. Rows written before the ready state included * `blockedTransitionAt` hashed only the sorted blocker ids. Lookup still reads * this format so an in-flight deploy-overlap wake can suppress a duplicate. */ export function buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input: { dependentIssueId: string; blockerIssueIds: string[]; }) { const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds); return buildStateKey( input.dependentIssueId, hashBlockerReadyStateDigest(sortedBlockerIssueIds, null), sortedBlockerIssueIds.length, ); } /** * Level-triggered idempotency key. One key encodes the full set of blockers that * defines the current dependency-ready state plus the dependent's current * blocked cycle (`blockedTransitionAt`, or `none`). Two wakes for the same ready * state share the key. A wake from an earlier blocked cycle has a different * cycle stamp, so it produces a different key and never suppresses the current * wake. All three emit paths (route-time, finalize-time, periodic backstop) use * this key so they share one idempotency rule. */ export function buildIssueBlockersResolvedWakeStateKey(input: IssueBlockersResolvedReadyStateInput) { const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds); const cycle = formatIssueBlockersResolvedWakeCycle(input.blockedTransitionAt); return buildStateKey( input.dependentIssueId, hashBlockerReadyStateDigest(sortedBlockerIssueIds, cycle), sortedBlockerIssueIds.length, ); } function parseWakeCycleDate(blockedTransitionAt: IssueBlockersResolvedWakeCycleInput): Date | null { if (blockedTransitionAt == null || blockedTransitionAt === "") return null; const parsed = blockedTransitionAt instanceof Date ? blockedTransitionAt : new Date(blockedTransitionAt); if (Number.isNaN(parsed.getTime())) return null; return parsed; } function wakeCoversIssueBlockersResolvedReadyState( wake: { status: string; idempotencyKey: string | null; requestedAt: Date; }, keys: { cycleKey: string; oldStateKey: string; legacyKeys: Set; blockedTransitionAt: Date | null; }, ): boolean { const idempotencyKey = wake.idempotencyKey; if (!idempotencyKey) return false; if (idempotencyKey === keys.cycleKey) { return IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status); } if (idempotencyKey === keys.oldStateKey) { if (IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status)) return true; if (wake.status !== "completed") return false; if (!keys.blockedTransitionAt) return true; return wake.requestedAt.getTime() >= keys.blockedTransitionAt.getTime(); } if (keys.legacyKeys.has(idempotencyKey)) { return IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status); } return false; } /** * Find a wake that already covers the current dependency-ready state of the * dependent issue. The check is level-triggered and cycle-aware: * * - The cycle-aware state key matches a wake in any idempotent status * (including `completed`). This suppresses a duplicate for the SAME ready * state, including the current blocked cycle. * - The old no-cycle state key matches in-flight statuses (deploy overlap), * or a `completed` wake whose `requestedAt` is at or after the current * `blockedTransitionAt` (same cycle). A completed old-key wake from a * previous cycle does not suppress. * - Each legacy per-edge key matches only a wake that is still in flight. * * Returns the first matching wake or `null`. */ export async function findExistingIssueBlockersResolvedWakeForReadyState( db: Db, input: { companyId: string; dependentIssueId: string; blockerIssueIds: string[]; blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput; }, ) { const cycleKey = buildIssueBlockersResolvedWakeStateKey(input); const oldStateKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input); const legacyKeyList = [ ...new Set( input.blockerIssueIds .filter(Boolean) .map((resolvedBlockerIssueId) => buildIssueBlockersResolvedWakeIdempotencyKey({ dependentIssueId: input.dependentIssueId, resolvedBlockerIssueId, }), ), ), ]; const lookupKeys = [...new Set([cycleKey, oldStateKey, ...legacyKeyList])]; const blockedTransitionAt = parseWakeCycleDate(input.blockedTransitionAt); const rows = await db .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey, requestedAt: agentWakeupRequests.requestedAt, }) .from(agentWakeupRequests) .where( and( eq(agentWakeupRequests.companyId, input.companyId), inArray(agentWakeupRequests.idempotencyKey, lookupKeys), ), ); const covering = rows.find((row) => wakeCoversIssueBlockersResolvedReadyState(row, { cycleKey, oldStateKey, legacyKeys: new Set(legacyKeyList), blockedTransitionAt, }), ); return covering ?? null; }