mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix: resolve cancelled issue blockers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
599ad7016c
commit
23bde25828
18 files changed
+432
-113
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
Reference in new issue
Block a user