Files
PaperClipAI/server/src/services/issue-dependency-wakeups.test.ts
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

232 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import {
buildIssueBlockersResolvedWakeIdempotencyKey,
buildIssueBlockersResolvedWakeStateKey,
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
findExistingIssueBlockersResolvedWakeForReadyState,
} from "./issue-dependency-wakeups.js";
const dependentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const blockerIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const companyId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const firstCycle = new Date("2026-04-01T12:00:00.000Z");
const secondCycle = new Date("2026-08-01T09:30:00.000Z");
type WakeRow = {
id: string;
status: string;
idempotencyKey: string | null;
requestedAt: Date;
};
function dbWithWakes(rows: WakeRow[]): Db {
return {
select() {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
},
} as unknown as Db;
}
describe("buildIssueBlockersResolvedWakeStateKey", () => {
it("is identical for the same dependent, blockers, and blockedTransitionAt", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle.toISOString(),
});
expect(first).toBe(second);
expect(first).toContain(dependentIssueId);
});
it("changes when blockedTransitionAt changes", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
});
expect(first).not.toBe(second);
});
it("hashes a null cycle as none and differs from any timestamp", () => {
const noneKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
});
const omittedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const datedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
expect(noneKey).toBe(omittedKey);
expect(noneKey).not.toBe(datedKey);
expect(noneKey).not.toBe(
buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
}),
);
});
});
describe("findExistingIssueBlockersResolvedWakeForReadyState", () => {
const readyState = {
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
};
it("suppresses a completed wake on the cycle-aware state key", async () => {
const cycleKey = buildIssueBlockersResolvedWakeStateKey(readyState);
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-cycle",
status: "completed",
idempotencyKey: cycleKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-cycle");
});
it("does not let a completed old-key wake from a previous blocked cycle suppress", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-previous-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing).toBeNull();
});
it("suppresses a completed old-key wake requested at or after blockedTransitionAt", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-same-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-same-cycle");
});
it("suppresses a completed old-key wake when blockedTransitionAt is null", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-no-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
{
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
},
);
expect(existing?.id).toBe("wake-old-no-cycle");
});
it("suppresses an in-flight old-key wake across a later blocked cycle", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-queued",
status: "queued",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-queued");
});
it("keeps legacy per-edge matching in-flight only", async () => {
const legacyKey = buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId,
resolvedBlockerIssueId: blockerIssueId,
});
const inFlight = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-claimed",
status: "claimed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(inFlight?.id).toBe("wake-legacy-claimed");
const completed = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-completed",
status: "completed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(completed).toBeNull();
});
});