fix: resolve cancelled issue blockers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 07:26:56 +00:00
1 parent 599ad7016c
commit 23bde25828
18 files changed
+432 -113

No files matched your search

+2
View File
@@ -501,6 +501,8 @@ Side effects:
- entering `in_progress` sets `started_at` if null
- entering `done` sets `completed_at`
- entering `cancelled` sets `cancelled_at`
- explicit blockers are dependency-ready when every blocker is terminal (`done` or `cancelled`); `done` blockers additionally wait for required workspace finalization, while `cancelled` blockers resolve immediately
- the final blocker transition to either terminal state emits one idempotent `issue_blockers_resolved` wake for each eligible assigned dependent
V1 non-terminal liveness rule:
+3 -3
View File
@@ -179,9 +179,9 @@ Use it for:
- explicit waiting relationships
- automatic wakeups when all blockers resolve
Blocked issues should stay idle while blockers remain unresolved. Paperclip should not create a queued heartbeat run for that issue until the final blocker is done and the `issue_blockers_resolved` wake can start real work.
Blocked issues should stay idle while blockers remain unresolved. Paperclip should not create a queued heartbeat run for that issue until every blocker is terminal (`done` or `cancelled`) and the `issue_blockers_resolved` wake can start real work.
`cancelled` is terminal for the blocker issue itself, but it does not satisfy the dependency. A cancelled blocker edge remains unresolved until the edge is removed or replaced, and Paperclip must surface blocker attention on the dependent regardless of whether that dependent is currently displayed as `blocked`, `todo`, `backlog`, or another non-terminal agent-owned status.
Both terminal states satisfy the dependency. A `done` blocker remains gated until any required workspace finalization completes so delivered work is synchronized before downstream execution. A `cancelled` blocker resolves immediately because its work was abandoned and has no delivered workspace state to finalize.
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
@@ -189,7 +189,7 @@ If a parent is truly waiting on a child, model that with blockers. Do not rely o
Run-scoped write authorization is subtree-scoped: a run may mutate its checked-out issue and that issue's descendants. Delegated child work still has to report upward, so the platform provides exactly three canonical report channels. Nothing else crosses the boundary.
1. **Completion signal (always on).** When a child that blocks its parent reaches `done`, the `issue_blockers_resolved` wake engages the parent's assignee, who can read the child thread. Completion needs no report comment: the child's own thread is the deliverable record, and relaying `done` as prose would duplicate the first-class wake.
1. **Terminal dependency signal (always on).** When a child that blocks its parent reaches `done` or `cancelled` and every blocker is terminal, the `issue_blockers_resolved` wake engages the parent's assignee, who can read the child thread. The child's own thread is the disposition record; relaying `done` as prose would duplicate the first-class wake, while the existing stop-only relay may still summarize a low-trust child's cancellation.
2. **Direct-parent report comment (trust-gated).** The write boundary widens exactly one hop upward: a run checked out on a child issue may POST comments on the child's **direct parent** — comments only (no status, field, assignment, or document writes), the direct parent only (never grandparents or siblings, never lateral). This is gated per trust preset: **on** for `standard`, **off by default** for `low_trust_review` and other review-contained presets, whose input is untrusted content (diffs, external tickets) and whose report comment would be a prompt-injection carrier into higher-trust context.
3. **Stop-only relay (fallback where the report comment is off).** For presets with direct-parent commenting disabled, the platform delivers a system-attributed relay comment to the direct parent when the child transitions into `blocked` or `cancelled` — never on `done` or `in_review`. Stopping is the event the parent must hear about; completion already has the first-class signal above. A relay is a comment, not a disposition transition, so it can never trigger another relay (depth-1 by construction), and relays dedupe per (child, target status) so status flapping cannot spam the parent.
+6 -2
View File
@@ -1681,7 +1681,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (["backlog", "done", "cancelled"].includes(record.status)) {
throw new Error(`Issue is not wakeable in status: ${record.status}`);
}
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
const unresolved = issueRelationSummary(issueId).blockedBy.filter(
(blocker) => blocker.status !== "done" && blocker.status !== "cancelled",
);
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
return { queued: true, runId: randomUUID() };
},
@@ -1695,7 +1697,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (["backlog", "done", "cancelled"].includes(record.status)) {
throw new Error(`Issue is not wakeable in status: ${record.status}`);
}
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
const unresolved = issueRelationSummary(issueId).blockedBy.filter(
(blocker) => blocker.status !== "done" && blocker.status !== "cancelled",
);
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
results.push({ issueId, queued: true, runId: randomUUID() });
}
+1
View File
@@ -246,6 +246,7 @@ export interface IssueBlockerDiagnosticNode extends IssueBlockerDiagnosticIssueS
}
export interface IssueBlockerDiagnosticsReadiness {
/** Backward-compatible field name; cancelled is done-equivalent, while pending finalization keeps this false. */
allBlockersDone: boolean;
isDependencyReady: boolean;
unresolvedBlockerCount: number;
@@ -233,6 +233,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
async function seedResolvedDependencyBackstopFixture(opts: {
workspaceState?: "none" | "not_finalized" | "finalized";
assignee?: "agent" | null;
blockerStatus?: "done" | "cancelled";
} = {}) {
const workspaceState = opts.workspaceState ?? "none";
const companyId = randomUUID();
@@ -313,7 +314,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
companyId,
projectId: workspaceState === "none" ? null : projectId,
title: "Synthetic completed blocker",
status: "done",
status: opts.blockerStatus ?? "done",
priority: "medium",
executionWorkspaceId: workspaceState === "none" ? null : executionWorkspaceId,
issueNumber: 2,
@@ -544,6 +545,33 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(events[0]).toMatchObject({ entityId: blockedIssueId });
});
it("reconciles a previously stranded dependent whose cancelled blocker has an unfinished workspace", async () => {
const { agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({
workspaceState: "not_finalized",
blockerStatus: "cancelled",
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeNotReadySkipped).toBe(0);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
const wake = await db
.select({
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wake).toMatchObject({
reason: "issue_blockers_resolved",
idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`,
});
});
it("reconciles a resolved blocked dependency after the assignee-null window closes", async () => {
const { agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none", assignee: null });
@@ -680,7 +708,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
});
});
it("does not duplicate an existing dependency wake keyed to any resolved blocker", async () => {
it("does not duplicate an existing dependency wake keyed to any terminal blocker", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
@@ -688,8 +716,8 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
await db.insert(issues).values({
id: secondBlockerIssueId,
companyId,
title: "Second completed blocker",
status: "done",
title: "Cancelled blocker",
status: "cancelled",
priority: "medium",
issueNumber: 3,
identifier: "R-MULTI-3",
@@ -496,7 +496,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("prefers needs_attention over stalled when the chain also has a hard attention case", async () => {
it("ignores cancelled blockers when classifying a stalled review dependency", async () => {
const { companyId, agentId } = await createCompany("PBQ");
const parentId = await insertIssue({ companyId, identifier: "PBQ-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
@@ -519,11 +519,12 @@ describeEmbeddedPostgres("issue blocker attention", () => {
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
state: "stalled",
reason: "stalled_review",
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 1,
attentionBlockerCount: 0,
unresolvedBlockerCount: 1,
sampleStalledBlockerIdentifier: "PBQ-2",
});
});
@@ -568,13 +569,13 @@ describeEmbeddedPostgres("issue blocker attention", () => {
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
});
});
it("does not treat cancelled liveness escalation issues as covered waiting paths", async () => {
it("does not surface a cancelled blocker as the attention leaf", async () => {
const { companyId, agentId } = await createCompany("PBLX");
const parentId = await insertIssue({ companyId, identifier: "PBLX-1", title: "Parent", status: "blocked" });
const cancelledLeafId = await insertIssue({
@@ -601,13 +602,15 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { attention: "blocked" })).find((issue) => issue.id === parentId);
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockedInboxAttention).toMatchObject({
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "blocked_by_cancelled_issue",
leafIssue: { id: cancelledLeafId, identifier: "PBLX-2" },
reason: "attention_required",
unresolvedBlockerCount: 0,
directBlockerIssueId: null,
});
expect(parent?.blockedInboxAttention ?? null).toBeNull();
});
it("does not treat an expired scheduled retry as actively covered work", async () => {
@@ -674,7 +677,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
await expect(svc.count(companyId, { attention: "blocked" })).resolves.toBe(1);
});
it("surfaces cancelled-blocker attention on an assigned todo source", async () => {
it("does not surface cancelled-blocker attention on an assigned todo source", async () => {
const { companyId, agentId } = await createCompany("BICX");
const sourceId = await insertIssue({
companyId,
@@ -692,17 +695,11 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: sourceId });
const rows = await svc.list(companyId, { attention: "blocked" });
const rows = await svc.list(companyId, { status: "todo" });
const source = rows.find((issue) => issue.id === sourceId);
expect(source?.blockedInboxAttention).toMatchObject({
kind: "blocked",
state: "needs_attention",
reason: "blocked_by_cancelled_issue",
owner: { type: "agent", agentId },
action: { label: "Replace blocker" },
leafIssue: { id: blockerId, identifier: "BICX-2" },
});
expect(source?.blockedInboxAttention ?? null).toBeNull();
await expect(svc.count(companyId, { attention: "blocked" })).resolves.toBe(0);
});
it("redacts external wait details from blocked inbox payloads and search", async () => {
@@ -248,6 +248,50 @@ describeEmbeddedPostgres("issue blocker diagnostics route", () => {
});
});
it("reports a cancelled blocker as dependency-ready", async () => {
const company = await seedCompany(db);
const agent = await seedAgent(db, company.id);
const project = await seedProject(db, company.id, "Core");
const root = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
title: "Ship root",
status: "blocked",
assigneeAgentId: agent.id,
});
const blocker = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
title: "Cancelled blocker",
status: "cancelled",
});
await blockIssue(db, company.id, blocker.id, root.id);
const res = await request(createApp(db, boardActor(company)))
.get(`/api/issues/${root.id}/diagnostics/blockers`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
diagnosis: expect.stringContaining("stale blocker hold"),
readiness: {
allBlockersDone: true,
isDependencyReady: true,
unresolvedBlockerCount: 0,
pendingFinalizeBlockerCount: 0,
},
});
expect(res.body.blockers).toEqual([
expect.objectContaining({
id: blocker.id,
status: "cancelled",
isUnresolved: false,
isDependencyReady: true,
isPendingFinalize: false,
flags: [],
}),
]);
});
it("returns null diagnosis for an unblocked issue with no blocker relations", async () => {
const company = await seedCompany(db);
const project = await seedProject(db, company.id, "Core");
@@ -58,6 +58,7 @@ vi.mock("../services/index.js", () => ({
}),
heartbeatService: () => ({
wakeup: mockWakeup,
getActiveRunForAgent: vi.fn(async () => null),
reportRunActivity: vi.fn(async () => undefined),
}),
getIssueContinuationSummaryDocument: vi.fn(async () => null),
@@ -224,7 +225,7 @@ describe("issue dependency wakeups in issue routes", () => {
]);
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
expect(res.status).toBe(200);
expect(res.status, JSON.stringify(res.body)).toBe(200);
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
@@ -239,6 +240,71 @@ describe("issue dependency wakeups in issue routes", () => {
});
});
it("wakes each eligible dependent once when the final blocker is cancelled", async () => {
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-102",
title: "Abandon blocker",
description: null,
status: "in_progress",
priority: "medium",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.update.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-102",
title: "Abandon blocker",
description: null,
status: "cancelled",
priority: "medium",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionWorkspaceId: null,
labels: [],
labelIds: [],
});
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: "issue-2",
assigneeAgentId: "agent-2",
blockerIssueIds: ["issue-1", "issue-3"],
},
]);
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "cancelled" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledTimes(1);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
resolvedBlockerStatus: "cancelled",
mutation: "blocker_cancelled",
}),
contextSnapshot: expect.objectContaining({
resolvedBlockerStatus: "cancelled",
}),
}),
);
});
});
it("wakes an assigned blocked issue when blockers are applied after the blocker is already done", async () => {
const parentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const childIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
+6 -11
View File
@@ -239,7 +239,7 @@ describe("issue graph liveness classifier", () => {
expect(findings).toEqual([]);
});
it("detects cancelled blockers and uninvokable blocker assignees deterministically", () => {
it("ignores cancelled blockers and detects uninvokable non-terminal blocker assignees", () => {
const cancelled = classifyIssueGraphLiveness({
issues: [
issue(),
@@ -254,7 +254,7 @@ describe("issue graph liveness classifier", () => {
relations: blocks,
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })],
});
expect(cancelled[0]?.state).toBe("blocked_by_cancelled_issue");
expect(cancelled).toEqual([]);
const paused = classifyIssueGraphLiveness({
issues: [
@@ -273,7 +273,7 @@ describe("issue graph liveness classifier", () => {
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
});
it("detects a cancelled blocker on an assigned todo source", () => {
it("ignores a cancelled blocker on an assigned todo source", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({ status: "todo" }),
@@ -289,15 +289,10 @@ describe("issue graph liveness classifier", () => {
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Cancelled owner" })],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
state: "blocked_by_cancelled_issue",
recoveryIssueId: blockerId,
});
expect(findings).toEqual([]);
});
it("prefers the blocker finding for an in-review source with a cancelled blocker", () => {
it("classifies the review path independently of a cancelled blocker", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({ status: "in_review" }),
@@ -314,7 +309,7 @@ describe("issue graph liveness classifier", () => {
});
expect(findings).toHaveLength(1);
expect(findings[0]?.state).toBe("blocked_by_cancelled_issue");
expect(findings[0]?.state).toBe("in_review_without_action_path");
});
it("detects blocker assignees under terminated org ancestors as uninvokable", () => {
@@ -4362,6 +4362,41 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
it("does not apply the done-only workspace-finalize barrier to cancelled blockers", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
assigneeAgentId,
} = await seedSharedWorkspaceDependency();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, blockerId));
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
blockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [],
pendingFinalizeBlockerIssueIds: [],
allBlockersDone: true,
isDependencyReady: true,
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({ id: dependentId, assigneeAgentId }),
]);
await expect(svc.checkout(dependentId, assigneeAgentId, ["blocked"], null)).resolves.toMatchObject({
id: dependentId,
status: "in_progress",
});
});
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
@@ -4448,6 +4483,34 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
it("treats mixed done and cancelled blockers as dependency-ready", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const doneBlockerId = randomUUID();
const cancelledBlockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{ id: doneBlockerId, companyId, title: "Done blocker", status: "done", priority: "medium" },
{ id: cancelledBlockerId, companyId, title: "Cancelled blocker", status: "cancelled", priority: "medium" },
{ id: blockedId, companyId, title: "Blocked", status: "blocked", priority: "medium" },
]);
await svc.update(blockedId, { blockedByIssueIds: [doneBlockerId, cancelledBlockerId] });
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
blockerIssueIds: expect.arrayContaining([doneBlockerId, cancelledBlockerId]),
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
allBlockersDone: true,
isDependencyReady: true,
});
});
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@@ -6,11 +6,13 @@ import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentRuntimeState,
agentWakeupRequests,
agents,
approvals,
assets,
companies,
companySkills,
companyMemberships,
costEvents,
createDb,
@@ -107,6 +109,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
await db.delete(costEvents);
await deleteHeartbeatRunsWithDependents();
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(issueRelations);
await db.delete(issueComments);
await db.delete(issueThreadInteractions);
@@ -118,6 +121,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companySkills);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
@@ -675,6 +679,68 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
).rejects.toThrow("Issue is blocked by unresolved blockers");
});
it("allows plugin wakeups when blockers are done or cancelled", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const responsibleUserId = randomUUID();
const doneBlockerIssueId = randomUUID();
const cancelledBlockerIssueId = randomUUID();
const blockedIssueId = randomUUID();
await db.insert(issues).values([
{
id: doneBlockerIssueId,
companyId,
title: "Done blocker",
status: "done",
priority: "medium",
},
{
id: cancelledBlockerIssueId,
companyId,
title: "Cancelled blocker",
status: "cancelled",
priority: "medium",
},
{
id: blockedIssueId,
companyId,
title: "Ready issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
]);
await db.insert(issueRelations).values([
{
companyId,
issueId: doneBlockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
},
{
companyId,
issueId: cancelledBlockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
},
]);
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: responsibleUserId,
status: "active",
membershipRole: "owner",
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
await expect(
services.issues.requestWakeup({
issueId: blockedIssueId,
companyId,
reason: "mission_advance",
}),
).resolves.toMatchObject({ queued: true });
});
it("narrows orchestration cost summaries by subtree and billing code", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const rootIssueId = randomUUID();
@@ -254,4 +254,55 @@ describe("plugin SDK orchestration contract", () => {
harness.ctx.issues.requestWakeup(blockedIssueId, companyId),
).rejects.toThrow("Issue is blocked by unresolved blockers");
});
it("allows plugin wakeups when every blocker is terminal", async () => {
const companyId = randomUUID();
const doneBlockerIssueId = randomUUID();
const cancelledBlockerIssueId = randomUUID();
const blockedIssueId = randomUUID();
const assigneeAgentId = randomUUID();
const harness = createTestHarness({
manifest: manifest(["issues.wakeup", "issues.read"]),
});
harness.seed({
issues: [
issue({ id: doneBlockerIssueId, companyId, title: "Done blocker", status: "done" }),
issue({ id: cancelledBlockerIssueId, companyId, title: "Cancelled blocker", status: "cancelled" }),
issue({
id: blockedIssueId,
companyId,
title: "Ready work",
status: "todo",
assigneeAgentId,
blockedBy: [
{
id: doneBlockerIssueId,
identifier: null,
title: "Done blocker",
status: "done",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: cancelledBlockerIssueId,
identifier: null,
title: "Cancelled blocker",
status: "cancelled",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
],
});
await expect(harness.ctx.issues.requestWakeup(blockedIssueId, companyId)).resolves.toMatchObject({
queued: true,
});
await expect(harness.ctx.issues.requestWakeups([blockedIssueId], companyId)).resolves.toEqual([
expect.objectContaining({ issueId: blockedIssueId, queued: true }),
]);
});
});
+23 -22
View File
@@ -989,14 +989,13 @@ function buildIssueBlockerDiagnosticsResponse(input: {
const isUnresolved = unresolvedIds.has(blocker.id);
const flags: IssueBlockerDiagnosticFlag[] = [];
if (issue.status === "blocked" && blocker.status === "done") flags.push("done_but_blocking");
if (blocker.status === "cancelled") flags.push("cancelled_blocker_in_set");
if (isPendingFinalize) flags.push("workspace_finalize_pending");
return {
...blocker,
isUnresolved,
isPendingFinalize,
isDependencyReady: blocker.status === "done" && !isPendingFinalize,
isDependencyReady: isClosedIssueStatus(blocker.status) && !isPendingFinalize,
flags,
};
});
@@ -1065,13 +1064,6 @@ function buildIssueBlockerDiagnosis(input: {
)} to finish workspace finalization.`;
}
const cancelled = input.blockers.find((blocker) => blocker.status === "cancelled");
if (cancelled) {
return `${blockerDiagnosticLabel(input.issue)} is blocked by ${blockerDiagnosticLabel(
cancelled,
)}, which is cancelled; cancelled blockers do not resolve until the blocker relation is removed or replaced.`;
}
const unresolved = input.blockers.find((blocker) => blocker.isUnresolved);
if (unresolved) {
return `${blockerDiagnosticLabel(input.issue)} is blocked by ${blockerDiagnosticLabel(
@@ -1313,13 +1305,6 @@ function buildIssueWakeDiagnosis(input: {
)} is waiting for ${blockerDiagnosticLabel(pendingFinalize)} to finish workspace finalization, so issue_blockers_resolved has not fired.`;
}
const cancelled = blockerDiagnostics.blockers.find((blocker) => blocker.status === "cancelled");
if (cancelled) {
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
input.issue,
)} is blocked by ${blockerDiagnosticLabel(cancelled)}, which is cancelled; cancelled blockers do not fire issue_blockers_resolved.`;
}
const unresolved = blockerDiagnostics.blockers.find((blocker) => blocker.isUnresolved);
if (unresolved) {
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
@@ -10559,6 +10544,7 @@ export function issueRoutes(
blockerIssueIds: string[];
source: string;
mutation: string;
resolvedBlockerStatus?: "done" | "cancelled";
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
@@ -10586,6 +10572,9 @@ export function issueRoutes(
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
mutation: input.mutation,
...(input.resolvedBlockerStatus
? { resolvedBlockerStatus: input.resolvedBlockerStatus }
: {}),
},
idempotencyKey,
requestedByActorType: actor.actorType,
@@ -10597,6 +10586,9 @@ export function issueRoutes(
source: input.source,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
...(input.resolvedBlockerStatus
? { resolvedBlockerStatus: input.resolvedBlockerStatus }
: {}),
},
});
};
@@ -10744,8 +10736,10 @@ export function issueRoutes(
}
}
const becameDone = existing.status !== "done" && issue.status === "done";
if (becameDone) {
const resolvedBlockerStatus = isClosedIssueStatus(issue.status) ? issue.status : null;
const becameDependencyTerminal =
!isClosedIssueStatus(existing.status) && resolvedBlockerStatus !== null;
if (becameDependencyTerminal) {
const dependents = await svc.listWakeableBlockedDependents(issue.id);
for (const dependent of dependents) {
await addDependencyResolvedWakeup({
@@ -10754,7 +10748,8 @@ export function issueRoutes(
resolvedBlockerIssueId: issue.id,
blockerIssueIds: dependent.blockerIssueIds,
source: "issue.blockers_resolved",
mutation: "blocker_done",
mutation: `blocker_${resolvedBlockerStatus}`,
resolvedBlockerStatus,
});
}
}
@@ -12612,6 +12607,7 @@ export function issueRoutes(
dependentIssueId: string;
resolvedBlockerIssueId: string;
blockerIssueIds: string[];
resolvedBlockerStatus: "done" | "cancelled";
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
@@ -12638,7 +12634,8 @@ export function issueRoutes(
issueId: input.dependentIssueId,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
mutation: "comment",
mutation: `blocker_${input.resolvedBlockerStatus}`,
resolvedBlockerStatus: input.resolvedBlockerStatus,
},
idempotencyKey,
requestedByActorType: actor.actorType,
@@ -12650,6 +12647,7 @@ export function issueRoutes(
source: "issue.blockers_resolved",
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
resolvedBlockerStatus: input.resolvedBlockerStatus,
},
});
};
@@ -12776,8 +12774,10 @@ export function issueRoutes(
});
}
const becameDone = issueBeforeCommentDecision.status !== "done" && currentIssue.status === "done";
if (becameDone) {
const resolvedBlockerStatus = isClosedIssueStatus(currentIssue.status) ? currentIssue.status : null;
const becameDependencyTerminal =
!isClosedIssueStatus(issueBeforeCommentDecision.status) && resolvedBlockerStatus !== null;
if (becameDependencyTerminal) {
const dependents = await svc.listWakeableBlockedDependents(currentIssue.id);
for (const dependent of dependents) {
await addDependencyResolvedWakeup({
@@ -12785,6 +12785,7 @@ export function issueRoutes(
dependentIssueId: dependent.id,
resolvedBlockerIssueId: currentIssue.id,
blockerIssueIds: dependent.blockerIssueIds,
resolvedBlockerStatus,
});
}
}
+26 -26
View File
@@ -755,6 +755,7 @@ export type IssueDependencyReadiness = {
unresolvedBlockerCount: number;
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
pendingFinalizeBlockerIssueIds: string[];
/** Backward-compatible field name; cancelled is done-equivalent, while pending finalization keeps this false. */
allBlockersDone: boolean;
isDependencyReady: boolean;
};
@@ -994,6 +995,14 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
};
}
const TERMINAL_DEPENDENCY_STATUSES = ["done", "cancelled"] as const;
function isTerminalDependencyStatus(status: string): boolean {
return TERMINAL_DEPENDENCY_STATUSES.includes(
status as (typeof TERMINAL_DEPENDENCY_STATUSES)[number],
);
}
/**
* Returns the set of execution-workspace ids whose most recent workspace operation
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
@@ -1238,8 +1247,8 @@ async function listIssueDependencyReadinessMap(
);
// Collect issue/workspace pairs of "done" blockers — these are the only ones
// subject to the workspace-finalize barrier. Blockers that aren't done already
// mark the dependent as not-ready and don't need a finalize check.
// subject to the workspace-finalize barrier. Non-terminal blockers already
// mark the dependent as not-ready and cancelled blockers need no finalize check.
const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = [];
for (const row of blockerRows) {
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
@@ -1258,9 +1267,10 @@ async function listIssueDependencyReadinessMap(
for (const row of blockerRows) {
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
current.blockerIssueIds.push(row.blockerIssueId);
// Only done blockers resolve dependents; cancelled blockers stay unresolved
// until an operator removes or replaces the blocker relationship explicitly.
if (row.blockerStatus !== "done") {
// Both terminal issue states satisfy explicit dependencies. Only `done`
// blockers participate in workspace finalization because cancelled work has
// no delivered workspace state to synchronize.
if (!isTerminalDependencyStatus(row.blockerStatus)) {
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
current.unresolvedBlockerCount += 1;
current.allBlockersDone = false;
@@ -1329,8 +1339,7 @@ async function listUnresolvedBlockerIssueIds(
and(
eq(issues.companyId, companyId),
inArray(issues.id, uniqueBlockerIssueIds),
// Cancelled blockers intentionally remain unresolved until the relation changes.
ne(issues.status, "done"),
notInArray(issues.status, [...TERMINAL_DEPENDENCY_STATUSES]),
),
)
.then((rows) => rows.map((row) => row.id));
@@ -2176,7 +2185,7 @@ async function terminalExplicitBlockersByRoot(
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
ne(issues.status, "done"),
notInArray(issues.status, [...TERMINAL_DEPENDENCY_STATUSES]),
),
);
@@ -2200,7 +2209,7 @@ async function terminalExplicitBlockersByRoot(
const collectTerminal = (issueId: string, seen: Set<string>): IssueRelationIssueSummary[] => {
if (seen.has(issueId)) return [];
const node = nodesById.get(issueId);
if (!node || node.status === "done") return [];
if (!node || isTerminalDependencyStatus(node.status)) return [];
const nextSeen = new Set(seen);
nextSeen.add(issueId);
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
@@ -2414,7 +2423,7 @@ async function listIssueBlockerAttentionMap(
]);
const unresolvedExplicitBlockerRows = explicitBlockerRows.filter(
(row) => row.status !== "done" || pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
(row) => !isTerminalDependencyStatus(row.status) || pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
);
appendBlockerAttentionEdges(edgesByIssueId, [
...unresolvedExplicitBlockerRows
@@ -2500,7 +2509,7 @@ async function listIssueBlockerAttentionMap(
}
const explicitWaitCandidateIds = [...nodesById.values()]
.filter((node) => node.status !== "done")
.filter((node) => !isTerminalDependencyStatus(node.status))
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
if (explicitWaitCandidateIds.length > 0) {
@@ -2643,7 +2652,7 @@ async function listIssueBlockerAttentionMap(
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
}
const nodeSample = blockerSampleIdentifier(node);
if (node.status === "done" && !pendingFinalizeBlockerIssueIds.has(node.id)) {
if (isTerminalDependencyStatus(node.status) && !pendingFinalizeBlockerIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (explicitWaitingIssueIds.has(node.id)) {
@@ -2668,15 +2677,6 @@ async function listIssueBlockerAttentionMap(
if (activeIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "cancelled") {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
terminalBlockerIssueId: node.id,
};
}
if (node.status === "backlog" && node.assigneeAgentId) {
return {
covered: false,
@@ -2689,7 +2689,7 @@ async function listIssueBlockerAttentionMap(
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
return !blocker || !isTerminalDependencyStatus(blocker.status) || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
});
if (downstream.length > 0) {
const nextSeen = new Set(seen);
@@ -2764,7 +2764,7 @@ async function listIssueBlockerAttentionMap(
nextSeen.add(nodeId);
return (edgesByIssueId.get(node.id) ?? []).some((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
if (blocker?.status === "done" && !pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)) return false;
if (blocker && isTerminalDependencyStatus(blocker.status) && !pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)) return false;
return pathHasLiveWork(edge.blockerIssueId, nextSeen);
});
};
@@ -2780,7 +2780,7 @@ async function listIssueBlockerAttentionMap(
for (const root of roots) {
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
return !blocker || !isTerminalDependencyStatus(blocker.status) || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
});
if (topLevelEdges.length === 0) {
attentionMap.set(root.id, createIssueBlockerAttention({
@@ -3762,7 +3762,7 @@ async function listIssueBlockedInboxAttentionMap(
.where(and(
eq(issues.companyId, companyId),
visibleIssueCondition(),
ne(issues.status, "done"),
notInArray(issues.status, [...TERMINAL_DEPENDENCY_STATUSES]),
)),
dbOrTx
.select({
@@ -6589,7 +6589,7 @@ export function issueService(db: Db) {
if (wakeableCandidates.length === 0) return [];
// Defer to the unified readiness check so that a dependent only fires when
// (a) every blocker is done AND (b) every done blocker's workspace has
// (a) every blocker is terminal AND (b) every done blocker's workspace has
// recorded a successful workspace_finalize. The finalize hook also calls
// this function on completion, so a wake initially gated by an in-flight
// sync-back will re-fire once the restore lands locally.
+6 -2
View File
@@ -2172,7 +2172,9 @@ export function buildHostServices(
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
}
const relations = await issues.getRelationSummaries(issue.id);
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
const unresolvedBlockers = relations.blockedBy.filter(
(blocker) => blocker.status !== "done" && blocker.status !== "cancelled",
);
if (unresolvedBlockers.length > 0) {
throw new Error("Issue is blocked by unresolved blockers");
}
@@ -2240,7 +2242,9 @@ export function buildHostServices(
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
}
const relations = await issues.getRelationSummaries(issue.id);
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
const unresolvedBlockers = relations.blockedBy.filter(
(blocker) => blocker.status !== "done" && blocker.status !== "cancelled",
);
if (unresolvedBlockers.length > 0) {
throw new Error("Issue is blocked by unresolved blockers");
}
@@ -7,6 +7,8 @@ export type IssueLivenessState =
| "blocked_by_unassigned_issue"
| "blocked_by_assigned_backlog_issue"
| "blocked_by_uninvokable_assignee"
// Retained for persisted historical incident compatibility; new
// classifications treat cancelled blockers as dependency-ready.
| "blocked_by_cancelled_issue"
| "invalid_review_participant"
| "in_review_without_action_path";
@@ -133,6 +135,10 @@ function issueLabel(issue: IssueLivenessIssueInput) {
return issue.identifier ?? issue.id;
}
function isTerminalDependencyStatus(status: string) {
return status === "done" || status === "cancelled";
}
function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathEntry {
return {
issueId: issue.id,
@@ -598,21 +604,6 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
includeStalledAssignee: true,
});
if (blocker.status === "cancelled") {
return finding({
issue: source,
state: "blocked_by_cancelled_issue",
reason: `${issueLabel(source)} is still blocked by cancelled issue ${issueLabel(blocker)}.`,
dependencyPath,
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Inspect ${issueLabel(blocker)} and either remove it from ${issueLabel(source)}'s blockers or replace it with an actionable unblock issue.`,
blockerIssueId: blocker.id,
});
}
if (hasExplicitWaitingPath(blocker)) return null;
if (blocker.status === "in_review") {
@@ -688,7 +679,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
for (const relation of relations) {
if (relation.companyId !== current.companyId || relation.companyId !== source.companyId) continue;
const blocker = issuesById.get(relation.blockerIssueId);
if (!blocker || blocker.companyId !== source.companyId || blocker.status === "done") continue;
if (!blocker || blocker.companyId !== source.companyId || isTerminalDependencyStatus(blocker.status)) continue;
const path = [...dependencyPath, blocker];
if (blocker.status === "blocked") {
@@ -708,7 +699,11 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
const hasUnresolvedBlockerEdge = (blockersByBlockedIssueId.get(issue.id) ?? []).some((relation) => {
if (relation.companyId !== issue.companyId) return false;
const blocker = issuesById.get(relation.blockerIssueId);
return Boolean(blocker && blocker.companyId === issue.companyId && blocker.status !== "done");
return Boolean(
blocker &&
blocker.companyId === issue.companyId &&
!isTerminalDependencyStatus(blocker.status),
);
});
const shouldInspectBlockedChain = issue.status === "blocked" || (
issue.status !== "done" &&
+2 -2
View File
@@ -205,10 +205,10 @@ The array **replaces** the current set on each update — send `[]` to clear. Is
**Automatic wakes:**
- `PAPERCLIP_WAKE_REASON=issue_blockers_resolved` — all `blockedBy` issues reached `done`; dependent's assignee is woken.
- `PAPERCLIP_WAKE_REASON=issue_blockers_resolved` — all `blockedBy` issues reached a terminal state (`done`/`cancelled`); dependent's assignee is woken.
- `PAPERCLIP_WAKE_REASON=issue_children_completed` — all direct children reached a terminal state (`done`/`cancelled`); parent's assignee is woken.
`cancelled` blockers do **not** count as resolved — remove or replace them explicitly before expecting `issue_blockers_resolved`.
`done` blockers wait for any required workspace finalization before the dependency wake. `cancelled` blockers resolve immediately because there is no delivered workspace state to finalize.
## Requesting Board Approval
+4 -2
View File
@@ -189,7 +189,7 @@ The response also includes `blockedBy` and `blocks` arrays showing first-class d
}
```
Blocker wake semantics are strict: `issue_blockers_resolved` only fires when every blocker reaches `done`. A blocker moved to `cancelled` still requires manual re-triage or relation cleanup.
Blocker wake semantics are terminal-aware: `issue_blockers_resolved` fires when every blocker reaches `done` or `cancelled`. A `done` blocker still waits for required workspace finalization; a `cancelled` blocker resolves immediately.
### Issue Update Response (`PATCH /api/issues/:issueId`)
@@ -237,6 +237,8 @@ Clients that need only a compact receipt can send `Prefer: return=minimal`. The
Use this read-only diagnostic when an issue appears stuck on dependencies, especially after an `issue_blockers_resolved` wake or when an issue looks blocked against a blocker that is already `done`.
The compatibility field `allBlockersDone` treats `cancelled` as done-equivalent but remains false while required workspace finalization is pending. Use `isDependencyReady` as the authoritative readiness signal.
Read `diagnosis` first. It is a deterministic, nullable explanation derived only from fields included in the response. The endpoint also returns bounded structured blocker rows with status, readiness, and anomaly flags:
```json
@@ -1508,4 +1510,4 @@ Every successful or failed value fetch writes both `secret_access_events` and `a
| @-mention agents for no reason | Each mention triggers a budget-consuming heartbeat | Only mention agents who need to act |
| Sit silently on blocked work | Nobody knows you're stuck; the task rots | Comment the blocker and escalate immediately |
| Leave tasks in ambiguous states | Others can't tell if work is progressing | Always update status: `blocked`, `in_review`, or `done` |
| Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are done |
| Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are terminal (`done`/`cancelled`) |