From 23bde2582880f96da1ad5bd58d1ac9a0d235a5f9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 18 Aug 2026 21:07:22 +0000 Subject: [PATCH] fix: resolve cancelled issue blockers Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 2 + doc/execution-semantics.md | 6 +- packages/plugins/sdk/src/testing.ts | 8 ++- packages/shared/src/types/issue.ts | 1 + ...eartbeat-issue-liveness-escalation.test.ts | 36 ++++++++-- .../__tests__/issue-blocker-attention.test.ts | 39 +++++------ .../issue-blocker-diagnostics-routes.test.ts | 44 ++++++++++++ .../issue-dependency-wakeups-routes.test.ts | 68 ++++++++++++++++++- server/src/__tests__/issue-liveness.test.ts | 17 ++--- server/src/__tests__/issues-service.test.ts | 63 +++++++++++++++++ .../plugin-orchestration-apis.test.ts | 66 ++++++++++++++++++ .../plugin-sdk-orchestration-contract.test.ts | 51 ++++++++++++++ server/src/routes/issues.ts | 45 ++++++------ server/src/services/issues.ts | 52 +++++++------- server/src/services/plugin-host-services.ts | 8 ++- .../services/recovery/issue-graph-liveness.ts | 29 ++++---- skills/paperclip/SKILL.md | 4 +- skills/paperclip/references/api-reference.md | 6 +- 18 files changed, 432 insertions(+), 113 deletions(-) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5bba24c9b9e..39b73204f9d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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: diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 6d34194ab06..787b8845e93 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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. diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 43ce72f6588..2f3d044f057 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -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() }); } diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 151dd32f10a..ec4ebe7b7f2 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 1f6d5b72a86..40eaaedfe14 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -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", diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 1e6c00631e7..1c5e1fef61d 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -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 () => { diff --git a/server/src/__tests__/issue-blocker-diagnostics-routes.test.ts b/server/src/__tests__/issue-blocker-diagnostics-routes.test.ts index e488f0266eb..3d3e7d49049 100644 --- a/server/src/__tests__/issue-blocker-diagnostics-routes.test.ts +++ b/server/src/__tests__/issue-blocker-diagnostics-routes.test.ts @@ -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"); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index a840a81a4af..ae450e3c1c6 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -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"; diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts index 53e1c8d3879..8c35bf4fbbb 100644 --- a/server/src/__tests__/issue-liveness.test.ts +++ b/server/src/__tests__/issue-liveness.test.ts @@ -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", () => { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ee0632e6732..6477821e529 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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({ diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index b54fe6e31da..b18a6cfcbd6 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -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(); diff --git a/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts index 957262d8fec..4e1f02c1af7 100644 --- a/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts +++ b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts @@ -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 }), + ]); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7872a177493..2bdbf2c3659 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, }); } } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 58092a36b1e..6828e446fbf 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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): 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(); 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. diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 185524fc97e..cd377aa6974 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -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"); } diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index acf32b0a2b5..97bc691bba7 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -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" && diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 33aeceda407..5b853ec18f6 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -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 diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 3648e3bf472..594950250c9 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -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`) |