Files
PaperClipAI/server/src/services/issue-dependency-wakeups.ts
T
zach-hermesandPaperclip 8db826d18a fix(issues): cycle-aware issue_blockers_resolved after terminal reset (#11979)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents resume blocked work through the `issue_blockers_resolved`
wake when every durable blocker is `done`
> - That wake is level-triggered: one ready state produces one wake,
shared by the issue update route, workspace-finalize backstop, and
periodic liveness backstop
> - The ready-state key hashed only the dependent id and blocker set, so
it ignored a later reset from a terminal status back into `blocked`
> - After that reset, completing the same blockers found the previous
cycle's completed wake and suppressed the new continuation
> - This pull request folds the dependent's `blockedTransitionAt` into
the ready-state key, with compatibility for old no-cycle keys
> - The benefit is that a reset blocked issue receives exactly one new
wake without watchdog status repair or a change to blocker edges

## Linked Issues or Issue Description

Refs: https://github.com/paperclipai/paperclip/issues/5985
Refs: https://github.com/paperclipai/paperclip/issues/6555
Related: https://github.com/paperclipai/paperclip/pull/8009
Related: https://github.com/paperclipai/paperclip/pull/11570

This change does not auto-flip `blocked` to `todo`. The wake is the
continuation. It also does not treat cancelled blockers as resolved.

**What happened?**
A blocked assigned issue that was previously `done` or `cancelled`, then
reset to `blocked` on the same blocker set, did not receive
`issue_blockers_resolved` when those blockers later returned to `done`.
A completed wake from the previous cycle reused the same level-triggered
state key and suppressed the new wake. Route-time emit,
workspace-finalize backstop, and periodic liveness backstop all used
that helper.

**Expected behavior**
When every durable blocker is `done`, a currently `blocked` assigned
issue must receive exactly one valid `issue_blockers_resolved`
continuation for the current blocked cycle. A completed wake from an
earlier cycle must not suppress it. Watchdog `blocked` → `todo` repair
must not be required.

**Steps to reproduce**
1. Assign issue B, block it on issue A, mark A `done`, and let B receive
`issue_blockers_resolved`.
2. Mark B `done`.
3. Reset A to `todo` and reset B from `done` to `blocked` on the same A
id. This refreshes `blockedTransitionAt`.
4. Mark A `done` again.
5. Observe that B stays `blocked` with no new `issue_blockers_resolved`
wake.

**Paperclip version or commit**
`master` at `cc42a67e7e9e8eb183097afc8ff4ebfa694fb3e0`

**Deployment mode**
Self-hosted server

## What Changed

- Extend `buildIssueBlockersResolvedWakeStateKey` so the digest includes
the dependent's `blockedTransitionAt` as UTC ISO-8601, or `none`
- Thread `blockedTransitionAt` through `listWakeableBlockedDependents`,
both route emit sites, and both backstop candidate selects
- Keep compatibility: new cycle-aware keys suppress in idempotent
statuses; old no-cycle state keys suppress when in-flight, or when
completed and `requestedAt >= blockedTransitionAt` (or the cycle is
null); legacy per-edge keys stay in-flight-only
- Do not rewrite `blockedByIssueIds`, auto-flip `blocked` → `todo`, or
delete historical wake rows
- Add helper, route, restore, chained dependent, and backstop tests for
the reset cycle

## Verification

```
pnpm --filter @paperclipai/server exec vitest run \
  src/__tests__/issue-dependency-wakeups-routes.test.ts \
  src/__tests__/heartbeat-issue-liveness-escalation.test.ts \
  src/services/issue-dependency-wakeups.ts \
  src/services/issue-dependency-wakeups.test.ts
```

Local result: all named tests passed (helper 9, routes 8, liveness 26).

## Risks

- Deploy overlap: in-flight and same-cycle completed wakes still exist
under the old no-cycle key. The lookup keeps those as suppressors so
this change does not enqueue a duplicate in the current cycle.
- A completed old-key wake from before the current `blockedTransitionAt`
no longer suppresses. That is the intended fix.
- No schema migration. Rollback is revert of this PR.
- This does not change cancelled-blocker semantics or watchdog `blocked`
→ `todo` repair.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Provider: xAI
- Model: Grok 4.6
- Tool use and code execution: yes
- Human-authored: no

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-23 08:50:38 -07:00

240 lines
8.5 KiB
TypeScript

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<string>(IDEMPOTENT_DEPENDENCY_WAKE_STATUSES);
const IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET = new Set<string>(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<string>;
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;
}