Cancel stale queued heartbeats when issue graph changes (PAP-2314) (#4534)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-26 21:17:38 -05:00
committed by GitHub
parent 868d08903e
commit 82e257c7ba
21 changed files with 1991 additions and 238 deletions
+8 -1
View File
@@ -395,7 +395,14 @@ Side effects:
- entering `done` sets `completed_at`
- entering `cancelled` sets `cancelled_at`
Detailed ownership, execution, blocker, active-run watchdog, and crash-recovery semantics are documented in `doc/execution-semantics.md`.
V1 non-terminal liveness rule:
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery issue owns the next action
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
## 8.3 Approval Status
+54 -8
View File
@@ -1,7 +1,7 @@
# Execution Semantics
Status: Current implementation guide
Date: 2026-04-23
Date: 2026-04-26
Audience: Product and engineering
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
@@ -150,11 +150,23 @@ Blocked issues should stay idle while blockers remain unresolved. Paperclip shou
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
## 7. Consistent Execution Path Rules
## 7. Non-Terminal Issue Liveness Contract
For agent-assigned, non-terminal, actionable issues, Paperclip should not leave work in a state where nobody is working it and nothing will wake it.
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
The relevant execution path depends on status.
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible comment, or an explicit recovery issue. It must not silently mark work done from prose comments or guess that a dependency is complete.
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
The valid action-path primitives are:
- an active run linked to the issue
- a queued wake or continuation that can be delivered to the responsible agent
- a typed execution-policy participant, such as `executionState.currentParticipant`
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
- a human owner via `assigneeUserId`
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
- an open explicit recovery issue that names the owner and action needed to restore liveness
### Agent-assigned `todo`
@@ -162,9 +174,11 @@ This is dispatch state: ready to start, not yet actively claimed.
A healthy dispatch state means at least one of these is true:
- the issue already has a queued/running wake path
- the issue is intentionally resting in `todo` after a successful agent heartbeat, not after an interrupted dispatch
- the issue has been explicitly surfaced as stranded
- the issue already has a queued wake path
- the issue is intentionally resting in `todo` after a completed agent heartbeat, with no interrupted dispatch evidence
- the issue has been explicitly surfaced as stranded through a visible blocked/recovery path
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
### Agent-assigned `in_progress`
@@ -174,7 +188,39 @@ A healthy active-work state means at least one of these is true:
- there is an active run for the issue
- there is already a queued continuation wake
- the issue has been explicitly surfaced as stranded
- there is an open explicit recovery issue for the lost execution path
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
### `in_review`
This is review/approval state: execution is paused because the next move belongs to a reviewer, approver, board user, or recovery owner.
A healthy `in_review` issue has at least one valid action path:
- a typed execution-policy participant who can approve or request changes
- a pending issue-thread interaction or linked approval waiting for a named responder
- a human owner via `assigneeUserId`
- an active run or queued wake that is expected to process the review state
- an open explicit recovery issue for an ambiguous review handoff
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
### `blocked`
This is explicit waiting state.
A healthy `blocked` issue has an explicit waiting path:
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
- the issue is blocked on an explicit recovery issue that itself has a live or waiting path
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
## 8. Crash and Restart Recovery
+4 -1
View File
@@ -119,11 +119,12 @@ export interface IssueRelationIssueSummary {
terminalBlockers?: IssueRelationIssueSummary[];
}
export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention";
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
export type IssueBlockerAttentionReason =
| "active_child"
| "active_dependency"
| "stalled_review"
| "attention_required"
| null;
@@ -132,8 +133,10 @@ export interface IssueBlockerAttention {
reason: IssueBlockerAttentionReason;
unresolvedBlockerCount: number;
coveredBlockerCount: number;
stalledBlockerCount: number;
attentionBlockerCount: number;
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
}
export interface IssueRelation {
+35
View File
@@ -0,0 +1,35 @@
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { argv } from "node:process";
const outDir = argv[2] ?? "/tmp/paperclip/pap-2373-stalled-blocker-screens";
const baseUrl = argv[3] ?? "http://localhost:6610";
await mkdir(outDir, { recursive: true });
const id = "foundations-status-language--full-matrix";
const runs = [
{ name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" },
{ name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" },
{ name: "mobile-390x844-light", w: 390, h: 844, theme: "light" },
{ name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" },
];
const browser = await chromium.launch();
try {
for (const run of runs) {
const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`;
const context = await browser.newContext({
viewport: { width: run.w, height: run.h },
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
await page.waitForTimeout(1500);
const file = `${outDir}/${run.name}.png`;
await page.screenshot({ path: file, fullPage: true });
console.log("wrote", file);
await context.close();
}
} finally {
await browser.close();
}
@@ -4,7 +4,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import {
activityLog,
agents,
budgetPolicies,
companies,
costEvents,
createDb,
executionWorkspaces,
heartbeatRuns,
@@ -191,7 +193,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
type: "blocks",
});
return { companyId, managerId, blockedIssueId, blockerIssueId };
return { companyId, managerId, coderId, blockedIssueId, blockerIssueId };
}
it("keeps liveness findings advisory when auto recovery is disabled", async () => {
@@ -342,6 +344,71 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(events.some((event) => event.action === "issue.blockers.updated")).toBe(true);
});
it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => {
await enableAutoRecovery();
const { companyId, managerId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const issueTimestamp = new Date(Date.now() - 25 * 60 * 60 * 1000);
await db
.update(issues)
.set({
status: "in_review",
assigneeAgentId: coderId,
updatedAt: issueTimestamp,
})
.where(eq(issues.id, blockerIssueId));
await db.insert(budgetPolicies).values({
companyId,
scopeType: "agent",
scopeId: coderId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 1,
hardStopEnabled: true,
isActive: true,
});
await db.insert(costEvents).values({
companyId,
agentId: coderId,
issueId: blockerIssueId,
provider: "test",
biller: "test",
billingType: "tokens",
model: "test-model",
costCents: 1,
occurredAt: new Date(),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.escalationsCreated).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: managerId,
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"in_review_without_action_path",
blockerIssueId,
].join(":"),
});
const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created");
expect(createdEvent?.details).toMatchObject({
ownerSelection: {
selectedAgentId: managerId,
selectedReason: "assignee_reporting_chain",
budgetBlockedCandidateAgentIds: [coderId],
},
});
});
it("parents recovery under the leaf blocker without inheriting dependent or blocker execution state for manager-owned recovery", async () => {
await enableAutoRecovery();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
@@ -0,0 +1,545 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentRuntimeState,
agentWakeupRequests,
companies,
companySkills,
createDb,
documentRevisions,
documents,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issueRelations,
issueTreeHolds,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { runningProcesses } from "../adapters/index.ts";
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Stale-queue invalidation test run.",
provider: "test",
model: "test-model",
})),
);
vi.mock("../adapters/index.ts", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
return {
...actual,
getServerAdapter: vi.fn(() => ({
supportsLocalAgentJwt: false,
execute: mockAdapterExecute,
})),
};
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat stale-queue invalidation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
await db.execute(sql.raw(`
CREATE TABLE IF NOT EXISTS "issue_relations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"related_issue_id" uuid NOT NULL,
"type" text NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
`));
}
async function waitForCondition(fn: () => Promise<boolean>, timeoutMs = 3_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await fn()) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return fn();
}
type SeedOptions = {
agentName?: string;
agentRole?: string;
maxConcurrentRuns?: number;
};
type SeedResult = {
companyId: string;
agentId: string;
};
describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
let db!: ReturnType<typeof createDb>;
let heartbeat!: ReturnType<typeof heartbeatService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-stale-queue-");
db = createDb(tempDb.connectionString);
heartbeat = heartbeatService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
mockAdapterExecute.mockReset();
mockAdapterExecute.mockImplementation(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Stale-queue invalidation test run.",
provider: "test",
model: "test-model",
}));
runningProcesses.clear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 3) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndAgent(opts: SeedOptions = {}): Promise<SeedResult> {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: opts.agentName ?? "ClaudeCoder",
role: opts.agentRole ?? "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: opts.maxConcurrentRuns ?? 1,
},
},
permissions: {},
});
return { companyId, agentId };
}
async function seedQueuedRun(input: {
companyId: string;
agentId: string;
issueId: string;
wakeReason: string;
contextExtras?: Record<string, unknown>;
invocationSource?: "assignment" | "automation";
}) {
const wakeupRequestId = randomUUID();
const runId = randomUUID();
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId: input.companyId,
agentId: input.agentId,
source: input.invocationSource ?? "assignment",
triggerDetail: "system",
reason: input.wakeReason,
payload: { issueId: input.issueId },
status: "queued",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId: input.companyId,
agentId: input.agentId,
invocationSource: input.invocationSource ?? "assignment",
triggerDetail: "system",
status: "queued",
wakeupRequestId,
contextSnapshot: {
issueId: input.issueId,
wakeReason: input.wakeReason,
...(input.contextExtras ?? {}),
},
});
await db
.update(agentWakeupRequests)
.set({ runId })
.where(eq(agentWakeupRequests.id, wakeupRequestId));
return { runId, wakeupRequestId };
}
it("cancels queued runs when the issue assignee changes before the run starts", async () => {
const { companyId, agentId } = await seedCompanyAndAgent({ agentName: "OriginalCoder" });
const replacementAgentId = randomUUID();
await db.insert(agents).values({
id: replacementAgentId,
companyId,
name: "ReplacementCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Reassigned task",
status: "in_progress",
priority: "high",
assigneeAgentId: replacementAgentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_assigned",
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup] = await Promise.all([
db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_assignee_changed");
expect(run?.resultJson).toMatchObject({ stopReason: "issue_assignee_changed" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("assignee changed");
expect(mockAdapterExecute).not.toHaveBeenCalled();
});
it("cancels queued runs when the issue reaches a terminal status before the run starts", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Already-completed task",
status: "done",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_assigned",
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup] = await Promise.all([
db
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_terminal_status");
expect(wakeup?.status).toBe("skipped");
expect(mockAdapterExecute).not.toHaveBeenCalled();
});
it("cancels queued in_review runs when the current participant changes before the run starts", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const otherAgentId = randomUUID();
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "ReviewerAgent",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "In-review task now owned by reviewer",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
executionState: {
status: "pending",
currentStageId: randomUUID(),
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: otherAgentId, userId: null },
returnAssignee: { type: "agent", agentId, userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_assigned",
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup] = await Promise.all([
db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_review_participant_changed");
expect(run?.resultJson).toMatchObject({ stopReason: "issue_review_participant_changed" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("in-review participant changed");
expect(mockAdapterExecute).not.toHaveBeenCalled();
});
it("still runs comment-driven wakes on in_review issues even when the agent is no longer the current participant", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const otherAgentId = randomUUID();
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "ReviewerAgent",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "In-review task with comment feedback",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
executionState: {
status: "pending",
currentStageId: randomUUID(),
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: otherAgentId, userId: null },
returnAssignee: { type: "agent", agentId, userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorAgentId: otherAgentId,
body: "Review feedback comment",
});
const { runId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_commented",
invocationSource: "automation",
contextExtras: {
commentId,
wakeCommentId: commentId,
source: "issue.comment",
},
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
});
const run = await db
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(run?.status).toBe("succeeded");
expect(run?.errorCode).toBeNull();
});
it("baseline: runs queued runs when the issue is in_progress with the same assignee", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Still actionable",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_assigned",
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
});
const run = await db
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(run?.status).toBe("succeeded");
expect(run?.errorCode).toBeNull();
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
});
});
@@ -253,6 +253,109 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("flags a chain whose leaf is in_review without an action path as stalled", async () => {
const { companyId, agentId } = await createCompany("PBV");
const parentId = await insertIssue({ companyId, identifier: "PBV-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBV-2",
title: "Stalled review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBV-2",
sampleStalledBlockerIdentifier: "PBV-2",
});
});
it("does not flag an in_review leaf as stalled when an active run is still progressing it", async () => {
const { companyId, agentId } = await createCompany("PBW");
const parentId = await insertIssue({ companyId, identifier: "PBW-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBW-2",
title: "Active review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
await activeRun({ companyId, agentId, issueId: reviewLeafId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
stalledBlockerCount: 0,
});
});
it("flags a deep chain whose leaf is stalled in_review through multiple layers", async () => {
const { companyId, agentId } = await createCompany("PBZ");
const rootId = await insertIssue({ companyId, identifier: "PBZ-1", title: "Root", status: "blocked" });
const midId = await insertIssue({ companyId, identifier: "PBZ-2", title: "Mid blocker", status: "blocked" });
const leafId = await insertIssue({
companyId,
identifier: "PBZ-3",
title: "Stalled leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: midId, blockedIssueId: rootId });
await block({ companyId, blockerIssueId: leafId, blockedIssueId: midId });
const root = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === rootId);
expect(root?.blockerAttention).toMatchObject({
state: "stalled",
reason: "stalled_review",
stalledBlockerCount: 1,
sampleStalledBlockerIdentifier: "PBZ-3",
});
});
it("prefers needs_attention over stalled when the chain also has a hard attention case", async () => {
const { companyId, agentId } = await createCompany("PBQ");
const parentId = await insertIssue({ companyId, identifier: "PBQ-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBQ-2",
title: "Stalled review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
const cancelledLeafId = await insertIssue({
companyId,
identifier: "PBQ-3",
title: "Cancelled blocker",
status: "cancelled",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 1,
sampleStalledBlockerIdentifier: "PBQ-2",
});
});
it("does not treat a scheduled retry as actively covered work", async () => {
const { companyId, agentId } = await createCompany("PBY");
const parentId = await insertIssue({ companyId, identifier: "PBY-1", title: "Parent", status: "blocked" });
+187
View File
@@ -234,4 +234,191 @@ describe("issue graph liveness classifier", () => {
incidentKey: `harness_liveness:${companyId}:${blockedId}:invalid_review_participant:missing-agent`,
});
});
it("detects the PAP-2239-style blocked chain at the first stalled in_review leaf without duplicate findings", () => {
const phaseIssueId = "phase-issue-1";
const reviewLeafId = "review-leaf-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: "pap-2239",
identifier: "PAP-2239",
title: "External object reference project",
status: "blocked",
}),
issue({
id: phaseIssueId,
identifier: "PAP-2276",
title: "UX acceptance review phase",
status: "blocked",
assigneeAgentId: coderId,
}),
issue({
id: reviewLeafId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [
{ companyId, blockerIssueId: phaseIssueId, blockedIssueId: "pap-2239" },
{ companyId, blockerIssueId: reviewLeafId, blockedIssueId: phaseIssueId },
],
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: "pap-2239",
identifier: "PAP-2239",
state: "in_review_without_action_path",
recoveryIssueId: reviewLeafId,
recommendedOwnerAgentId: coderId,
dependencyPath: [
expect.objectContaining({ issueId: "pap-2239" }),
expect.objectContaining({ issueId: phaseIssueId }),
expect.objectContaining({ issueId: reviewLeafId }),
],
incidentKey: `harness_liveness:${companyId}:pap-2239:in_review_without_action_path:${reviewLeafId}`,
});
});
it("skips paused stalled review assignees when choosing recovery owner candidates", () => {
const reviewIssueId = "review-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [],
agents: [agent({ status: "paused" }), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "in_review_without_action_path",
recommendedOwnerAgentId: managerId,
});
expect(findings[0]?.recommendedOwnerCandidates).toEqual([
{
agentId: managerId,
reason: "assignee_reporting_chain",
sourceIssueId: reviewIssueId,
},
]);
});
it("does not flag healthy in_review issues with an explicit action path", () => {
const reviewIssueId = "review-1";
const baseReviewIssue = issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
});
const cases = [
{
name: "typed agent participant",
issue: {
...baseReviewIssue,
executionState: {
currentParticipant: { type: "agent", agentId: coderId },
},
},
},
{
name: "typed user participant",
issue: {
...baseReviewIssue,
executionState: {
currentParticipant: { type: "user", userId: "board-user-1" },
},
},
},
{
name: "user owner",
issue: { ...baseReviewIssue, assigneeAgentId: null, assigneeUserId: "board-user-1" },
},
{
name: "active run",
issue: baseReviewIssue,
activeRuns: [{ companyId, issueId: reviewIssueId, agentId: coderId, status: "running" }],
},
{
name: "queued wake",
issue: baseReviewIssue,
queuedWakeRequests: [{ companyId, issueId: reviewIssueId, agentId: coderId, status: "queued" }],
},
{
name: "pending interaction",
issue: baseReviewIssue,
pendingInteractions: [{ companyId, issueId: reviewIssueId, status: "pending" }],
},
{
name: "pending approval",
issue: baseReviewIssue,
pendingApprovals: [{ companyId, issueId: reviewIssueId, status: "pending" }],
},
{
name: "open recovery issue",
issue: baseReviewIssue,
openRecoveryIssues: [{ companyId, issueId: reviewIssueId, status: "todo" }],
},
];
for (const testCase of cases) {
const findings = classifyIssueGraphLiveness({
issues: [testCase.issue],
relations: [],
agents: [agent(), manager],
activeRuns: testCase.activeRuns,
queuedWakeRequests: testCase.queuedWakeRequests,
pendingInteractions: testCase.pendingInteractions,
pendingApprovals: testCase.pendingApprovals,
openRecoveryIssues: testCase.openRecoveryIssues,
});
expect(findings, testCase.name).toEqual([]);
}
});
it("ignores cross-company waiting paths for stalled in_review issues", () => {
const reviewIssueId = "review-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [],
agents: [agent(), manager],
pendingInteractions: [{ companyId: "other-company", issueId: reviewIssueId, status: "pending" }],
openRecoveryIssues: [{ companyId: "other-company", issueId: reviewIssueId, status: "todo" }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "in_review_without_action_path",
recoveryIssueId: reviewIssueId,
});
});
});
+156
View File
@@ -77,6 +77,7 @@ import {
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import {
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
isVerifiedIssueTreeControlInteractionWake,
@@ -3792,6 +3793,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
logger.info({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: cancelled blocked queued run");
return null;
}
const staleness = await evaluateQueuedRunStaleness(run, issueId, context);
if (staleness.stale) {
await cancelQueuedRunForStaleIssue(run, issueId, staleness);
logger.info(
{ runId: run.id, issueId, errorCode: staleness.errorCode },
"claimQueuedRun: cancelled stale queued run",
);
return null;
}
}
const claimedAt = new Date();
@@ -3912,6 +3923,151 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return cancelled;
}
type QueuedRunStaleness =
| { stale: false }
| {
stale: true;
reason: string;
errorCode:
| "issue_not_found"
| "issue_assignee_changed"
| "issue_terminal_status"
| "issue_review_participant_changed";
details: Record<string, unknown>;
};
async function evaluateQueuedRunStaleness(
run: typeof heartbeatRuns.$inferSelect,
issueId: string,
context: Record<string, unknown>,
): Promise<QueuedRunStaleness> {
const issue = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
executionState: issues.executionState,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) {
return {
stale: true,
errorCode: "issue_not_found",
reason: "Cancelled because the target issue no longer exists",
details: { issueId },
};
}
const wakeCommentId = deriveCommentId(context, null);
const isInteractionWake = allowsIssueInteractionWake(context);
const resumeIntent = context.resumeIntent === true || context.followUpRequested === true;
if (issue.assigneeAgentId !== run.agentId && !isInteractionWake) {
return {
stale: true,
errorCode: "issue_assignee_changed",
reason:
"Cancelled because issue assignee changed before the queued run could start; the new owner will be woken instead",
details: {
issueId,
previousAssigneeAgentId: run.agentId,
currentAssigneeAgentId: issue.assigneeAgentId,
},
};
}
if (issue.status === "done" || issue.status === "cancelled") {
if (!resumeIntent && !wakeCommentId) {
return {
stale: true,
errorCode: "issue_terminal_status",
reason: `Cancelled because issue reached terminal status (${issue.status}) before the queued run could start`,
details: { issueId, currentStatus: issue.status },
};
}
}
if (issue.status === "in_review") {
const executionState = parseIssueExecutionState(issue.executionState);
const currentParticipant = executionState?.currentParticipant ?? null;
if (currentParticipant) {
const participantMatches =
currentParticipant.type === "agent" && currentParticipant.agentId === run.agentId;
if (!participantMatches && !wakeCommentId) {
return {
stale: true,
errorCode: "issue_review_participant_changed",
reason:
"Cancelled because the in-review participant changed before the queued run could start; the current participant will be woken instead",
details: {
issueId,
currentStageType: executionState?.currentStageType ?? null,
currentParticipant,
},
};
}
}
}
return { stale: false };
}
async function cancelQueuedRunForStaleIssue(
run: typeof heartbeatRuns.$inferSelect,
issueId: string,
staleness: Extract<QueuedRunStaleness, { stale: true }>,
) {
const now = new Date();
const cancelled = await setRunStatus(run.id, "cancelled", {
finishedAt: now,
error: staleness.reason,
errorCode: staleness.errorCode,
resultJson: {
...parseObject(run.resultJson),
stopReason: staleness.errorCode,
effectiveTimeoutSec: 0,
timeoutConfigured: false,
timeoutSource: "stale_queued_run_gate",
timeoutFired: false,
},
});
if (!cancelled) return null;
await setWakeupStatus(run.wakeupRequestId, "skipped", {
finishedAt: now,
error: staleness.reason,
});
await db
.update(issues)
.set({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: now,
})
.where(
and(
eq(issues.companyId, run.companyId),
eq(issues.id, issueId),
eq(issues.executionRunId, run.id),
),
);
await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), {
eventType: "lifecycle",
stream: "system",
level: "warn",
message: staleness.reason,
payload: staleness.details,
});
return cancelled;
}
async function finalizeAgentStatus(
agentId: string,
outcome: "succeeded" | "failed" | "cancelled" | "timed_out",
+142 -21
View File
@@ -1,10 +1,11 @@
import { Buffer } from "node:buffer";
import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentWakeupRequests,
agents,
approvals,
assets,
companies,
companyMemberships,
@@ -12,6 +13,7 @@ import {
goals,
heartbeatRuns,
executionWorkspaces,
issueApprovals,
issueAttachments,
issueInboxArchives,
issueLabels,
@@ -19,6 +21,7 @@ import {
issueComments,
issueDocuments,
issueReadStates,
issueThreadInteractions,
issues,
labels,
projectWorkspaces,
@@ -660,6 +663,10 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
const ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
const BLOCKER_ATTENTION_MAX_NODES = 2000;
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
@@ -742,8 +749,10 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
reason: input.reason ?? null,
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
coveredBlockerCount: input.coveredBlockerCount ?? 0,
stalledBlockerCount: input.stalledBlockerCount ?? 0,
attentionBlockerCount: input.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
};
}
@@ -1026,6 +1035,55 @@ async function listIssueBlockerAttentionMap(
}
}
const reviewNodeIds = [...nodesById.values()]
.filter((node) => node.status === "in_review")
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
if (reviewNodeIds.length > 0) {
for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const interactionRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
inArray(issueThreadInteractions.status, BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES),
inArray(issueThreadInteractions.issueId, chunk),
),
);
for (const row of interactionRows) explicitWaitingIssueIds.add(row.issueId);
const approvalRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueApprovals.issueId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, companyId),
inArray(approvals.status, BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES),
inArray(issueApprovals.issueId, chunk),
),
);
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
const recoveryRows: Array<{ originId: string | null }> = await dbOrTx
.select({ originId: issues.originId })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
isNull(issues.hiddenAt),
inArray(issues.originId, chunk),
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
),
);
for (const row of recoveryRows) {
if (row.originId) explicitWaitingIssueIds.add(row.originId);
}
}
}
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
? await dbOrTx
.select({
@@ -1038,39 +1096,83 @@ async function listIssueBlockerAttentionMap(
: [];
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
type PathClassification = { covered: boolean; sampleBlockerIdentifier: string | null };
type PathClassification = {
covered: boolean;
stalled: boolean;
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
};
const classifyPath = (
nodeId: string,
seen: Set<string>,
): PathClassification => {
if (truncated || seen.has(nodeId)) return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(nodeId)) };
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
if (truncated || seen.has(nodeId)) {
return { covered: false, stalled: false, sampleBlockerIdentifier: sample, sampleStalledBlockerIdentifier: null };
}
const node = nodesById.get(nodeId);
if (!node || node.companyId !== companyId) return { covered: false, sampleBlockerIdentifier: nodeId };
if (node.status === "done") return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (activeIssueIds.has(node.id)) return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (node.status === "cancelled") return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (!node || node.companyId !== companyId) {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
}
const nodeSample = blockerSampleIdentifier(node);
if (node.status === "done") {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "in_review") {
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id);
if (hasWaitingPath) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample };
}
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 };
}
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
if (downstream.length > 0) {
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
const attention = classified.find((result) => !result.covered);
if (attention) return attention;
const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier);
const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null;
const hardAttention = classified.find((result) => !result.covered && !result.stalled);
if (hardAttention) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
const stalledEntry = classified.find((result) => result.stalled);
if (stalledEntry) {
return {
covered: false,
stalled: true,
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
return {
covered: true,
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? blockerSampleIdentifier(node),
stalled: false,
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.assigneeAgentId) {
const assignee = agentsById.get(node.assigneeAgentId);
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
}
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
};
for (const root of roots) {
@@ -1088,22 +1190,41 @@ async function listIssueBlockerAttentionMap(
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
}));
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
const attentionBlockerCount = classified.length - coveredBlockerCount;
const attentionEntry = classified.find((entry) => !entry.result.covered);
const sampleEntry = attentionEntry ?? classified[0] ?? null;
const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length;
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount;
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled);
const stalledEntry = classified.find((entry) => entry.result.stalled);
const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
const sampleStalledFromChain = classified
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
.find((value) => value);
let state: IssueBlockerAttention["state"];
let reason: IssueBlockerAttention["reason"];
if (attentionBlockerCount > 0) {
state = "needs_attention";
reason = "attention_required";
} else if (stalledBlockerCount > 0) {
state = "stalled";
reason = "stalled_review";
} else {
state = "covered";
reason = topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
? "active_child"
: "active_dependency";
}
attentionMap.set(root.id, createIssueBlockerAttention({
state: attentionBlockerCount === 0 ? "covered" : "needs_attention",
reason: attentionBlockerCount === 0
? topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
? "active_child"
: "active_dependency"
: "attention_required",
state,
reason,
unresolvedBlockerCount: topLevelEdges.length,
coveredBlockerCount,
stalledBlockerCount,
attentionBlockerCount,
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
sampleStalledBlockerIdentifier:
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
}));
}
@@ -6,7 +6,8 @@ export type IssueLivenessState =
| "blocked_by_unassigned_issue"
| "blocked_by_uninvokable_assignee"
| "blocked_by_cancelled_issue"
| "invalid_review_participant";
| "invalid_review_participant"
| "in_review_without_action_path";
export interface IssueLivenessIssueInput {
id: string;
@@ -47,6 +48,12 @@ export interface IssueLivenessExecutionPathInput {
status: string;
}
export interface IssueLivenessWaitingPathInput {
companyId: string;
issueId: string;
status: string;
}
export interface IssueLivenessDependencyPathEntry {
issueId: string;
identifier: string | null;
@@ -89,6 +96,9 @@ export interface IssueGraphLivenessInput {
agents: IssueLivenessAgentInput[];
activeRuns?: IssueLivenessExecutionPathInput[];
queuedWakeRequests?: IssueLivenessExecutionPathInput[];
pendingInteractions?: IssueLivenessWaitingPathInput[];
pendingApprovals?: IssueLivenessWaitingPathInput[];
openRecoveryIssues?: IssueLivenessWaitingPathInput[];
}
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
@@ -122,6 +132,14 @@ function hasActiveExecutionPath(
);
}
function hasWaitingPath(
companyId: string,
issueId: string,
waitingPaths: IssueLivenessWaitingPathInput[],
) {
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
}
function readPrincipalAgentId(principal: unknown): string | null {
if (!principal || typeof principal !== "object") return null;
const value = principal as Record<string, unknown>;
@@ -293,120 +311,225 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
const unresolvedBlockers = new Set<string>();
const findings: IssueLivenessFinding[] = [];
const activeRuns = input.activeRuns ?? [];
const queuedWakeRequests = input.queuedWakeRequests ?? [];
const pendingInteractions = input.pendingInteractions ?? [];
const pendingApprovals = input.pendingApprovals ?? [];
const openRecoveryIssues = input.openRecoveryIssues ?? [];
for (const relation of input.relations) {
const list = blockersByBlockedIssueId.get(relation.blockedIssueId) ?? [];
list.push(relation);
blockersByBlockedIssueId.set(relation.blockedIssueId, list);
const blocker = issuesById.get(relation.blockerIssueId);
const blocked = issuesById.get(relation.blockedIssueId);
if (
blocker &&
blocked &&
blocker.companyId === relation.companyId &&
blocked.companyId === relation.companyId &&
blocker.status !== "done" &&
blocker.status !== "cancelled" &&
blocked.status === "blocked"
) {
unresolvedBlockers.add(blocker.id);
}
}
for (const relations of blockersByBlockedIssueId.values()) {
relations.sort((left, right) => {
const leftIssue = issuesById.get(left.blockerIssueId);
const rightIssue = issuesById.get(right.blockerIssueId);
const leftLabel = leftIssue ? issueLabel(leftIssue) : left.blockerIssueId;
const rightLabel = rightIssue ? issueLabel(rightIssue) : right.blockerIssueId;
return leftLabel.localeCompare(rightLabel);
});
}
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
return Boolean(issue.assigneeUserId) ||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
hasWaitingPath(issue.companyId, issue.id, openRecoveryIssues);
}
function reviewFinding(
source: IssueLivenessIssueInput,
reviewIssue: IssueLivenessIssueInput,
dependencyPath: IssueLivenessIssueInput[],
): IssueLivenessFinding | null {
if (reviewIssue.status !== "in_review") return null;
if (hasExplicitWaitingPath(reviewIssue)) return null;
const ownerCandidates = ownerCandidatesForRecoveryIssue(reviewIssue, input.agents, agentsById, {
includeStalledAssignee: true,
});
const participant = reviewIssue.executionState?.currentParticipant;
const participantAgentId = readPrincipalAgentId(participant);
if (participantAgentId) {
const participantAgent = agentsById.get(participantAgentId);
if (isInvokableAgent(participantAgent) && participantAgent?.companyId === reviewIssue.companyId) return null;
return finding({
issue: source,
state: "invalid_review_participant",
reason: participantAgent
? `${issueLabel(reviewIssue)} is in review, but current participant agent is ${participantAgent.status}.`
: `${issueLabel(reviewIssue)} is in review, but current participant agent cannot be resolved.`,
dependencyPath,
recoveryIssue: reviewIssue,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Repair ${issueLabel(reviewIssue)}'s review participant or return the issue to an active assignee with a clear change request.`,
participantAgentId,
});
}
if (principalIsResolvableUser(participant)) return null;
if (reviewIssue.executionState) {
return finding({
issue: source,
state: "invalid_review_participant",
reason: `${issueLabel(reviewIssue)} is in review, but its current participant cannot be resolved.`,
dependencyPath,
recoveryIssue: reviewIssue,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Repair ${issueLabel(reviewIssue)}'s review participant or return the issue to an active assignee with a clear change request.`,
});
}
if (!reviewIssue.assigneeAgentId || reviewIssue.assigneeUserId) return null;
return finding({
issue: source,
state: "in_review_without_action_path",
reason: `${issueLabel(reviewIssue)} is in review with an agent assignee but no participant, interaction, approval, user owner, wake, active run, or recovery issue owning the next action.`,
dependencyPath,
recoveryIssue: reviewIssue,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Review ${issueLabel(reviewIssue)} and make the next action explicit: add a reviewer/interaction, return it to active work with a change request, mark it done if accepted, or open a bounded recovery issue.`,
blockerIssueId: reviewIssue.id,
});
}
function blockedFindingForLeaf(
source: IssueLivenessIssueInput,
blocker: IssueLivenessIssueInput,
dependencyPath: IssueLivenessIssueInput[],
): IssueLivenessFinding | null {
const ownerCandidates = ownerCandidatesForRecoveryIssue(blocker, input.agents, agentsById, {
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") {
return reviewFinding(source, blocker, dependencyPath);
}
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
return finding({
issue: source,
state: "blocked_by_unassigned_issue",
reason: `${issueLabel(source)} is blocked by unassigned issue ${issueLabel(blocker)} with no user owner.`,
dependencyPath,
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Assign ${issueLabel(blocker)} to an owner who can complete it, or remove it from ${issueLabel(source)}'s blockers if it is no longer required.`,
blockerIssueId: blocker.id,
});
}
if (!blocker.assigneeAgentId) return null;
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
if (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
return finding({
issue: source,
state: "blocked_by_uninvokable_assignee",
reason: blockerAgent
? `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
: `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
dependencyPath,
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Review ${issueLabel(blocker)} and assign it to an active owner or replace the blocker with an actionable issue.`,
blockerIssueId: blocker.id,
});
}
return null;
}
function firstBlockedChainFinding(
source: IssueLivenessIssueInput,
current: IssueLivenessIssueInput,
dependencyPath: IssueLivenessIssueInput[],
seen: Set<string>,
): IssueLivenessFinding | null {
if (seen.has(current.id)) return null;
seen.add(current.id);
const relations = blockersByBlockedIssueId.get(current.id) ?? [];
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;
const path = [...dependencyPath, blocker];
if (blocker.status === "blocked") {
const nested = firstBlockedChainFinding(source, blocker, path, new Set(seen));
if (nested) return nested;
if (hasExplicitWaitingPath(blocker)) continue;
}
const leafFinding = blockedFindingForLeaf(source, blocker, path);
if (leafFinding) return leafFinding;
}
return null;
}
for (const issue of input.issues) {
if (issue.status === "blocked") {
const relations = blockersByBlockedIssueId.get(issue.id) ?? [];
for (const relation of relations) {
if (relation.companyId !== issue.companyId) continue;
const blocker = issuesById.get(relation.blockerIssueId);
if (!blocker || blocker.companyId !== issue.companyId || blocker.status === "done") continue;
const ownerCandidates = ownerCandidatesForRecoveryIssue(blocker, input.agents, agentsById, {
includeStalledAssignee: true,
});
if (blocker.status === "cancelled") {
findings.push(finding({
issue,
state: "blocked_by_cancelled_issue",
reason: `${issueLabel(issue)} is still blocked by cancelled issue ${issueLabel(blocker)}.`,
dependencyPath: [issue, blocker],
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Inspect ${issueLabel(blocker)} and either remove it from ${issueLabel(issue)}'s blockers or replace it with an actionable unblock issue.`,
blockerIssueId: blocker.id,
}));
continue;
}
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
findings.push(finding({
issue,
state: "blocked_by_unassigned_issue",
reason: `${issueLabel(issue)} is blocked by unassigned issue ${issueLabel(blocker)} with no user owner.`,
dependencyPath: [issue, blocker],
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Assign ${issueLabel(blocker)} to an owner who can complete it, or remove it from ${issueLabel(issue)}'s blockers if it is no longer required.`,
blockerIssueId: blocker.id,
}));
continue;
}
if (!blocker.assigneeAgentId) continue;
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
if (!blockerAgent || blockerAgent.companyId !== issue.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
findings.push(finding({
issue,
state: "blocked_by_uninvokable_assignee",
reason: blockerAgent
? `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
: `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
dependencyPath: [issue, blocker],
recoveryIssue: blocker,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Review ${issueLabel(blocker)} and assign it to an active owner or replace the blocker with an actionable issue.`,
blockerIssueId: blocker.id,
}));
}
}
if (unresolvedBlockers.has(issue.id)) continue;
const chainFinding = firstBlockedChainFinding(issue, issue, [issue], new Set());
if (chainFinding) findings.push(chainFinding);
}
if (issue.status !== "in_review" || !issue.executionState) continue;
const ownerCandidates = ownerCandidatesForRecoveryIssue(issue, input.agents, agentsById);
const participant = issue.executionState.currentParticipant;
const participantAgentId = readPrincipalAgentId(participant);
if (participantAgentId) {
const participantAgent = agentsById.get(participantAgentId);
if (!isInvokableAgent(participantAgent) || participantAgent?.companyId !== issue.companyId) {
findings.push(finding({
issue,
state: "invalid_review_participant",
reason: participantAgent
? `${issueLabel(issue)} is in review, but current participant agent is ${participantAgent.status}.`
: `${issueLabel(issue)} is in review, but current participant agent cannot be resolved.`,
dependencyPath: [issue],
recoveryIssue: issue,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
participantAgentId,
}));
}
continue;
}
if (!principalIsResolvableUser(participant)) {
findings.push(finding({
issue,
state: "invalid_review_participant",
reason: `${issueLabel(issue)} is in review, but its current participant cannot be resolved.`,
dependencyPath: [issue],
recoveryIssue: issue,
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
recommendedOwnerCandidates: ownerCandidates,
recommendedAction:
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
}));
if (issue.status === "in_review" && !unresolvedBlockers.has(issue.id)) {
const review = reviewFinding(issue, issue, [issue]);
if (review) findings.push(review);
}
}
+59 -1
View File
@@ -3,11 +3,14 @@ import type { Db } from "@paperclipai/db";
import {
agents,
agentWakeupRequests,
approvals,
companies,
heartbeatRunEvents,
heartbeatRunWatchdogDecisions,
heartbeatRuns,
issueApprovals,
issueRelations,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
@@ -1540,7 +1543,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}
async function collectIssueGraphLivenessFindings() {
const [issueRows, relationRows, agentRows, activeRunRows, activeIssueRunRows, wakeRows] = await Promise.all([
const [
issueRows,
relationRows,
agentRows,
activeRunRows,
activeIssueRunRows,
wakeRows,
interactionRows,
approvalRows,
recoveryIssueRows,
] = await Promise.all([
db
.select({
id: issues.id,
@@ -1617,8 +1630,50 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
})
.from(agentWakeupRequests)
.where(inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])),
db
.select({
companyId: issueThreadInteractions.companyId,
issueId: issueThreadInteractions.issueId,
status: issueThreadInteractions.status,
})
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.status, "pending")),
db
.select({
companyId: issueApprovals.companyId,
issueId: issueApprovals.issueId,
status: approvals.status,
})
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(inArray(approvals.status, ["pending", "revision_requested"])),
db
.select({
companyId: issues.companyId,
id: issues.id,
status: issues.status,
originId: issues.originId,
})
.from(issues)
.where(
and(
isNull(issues.hiddenAt),
eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND),
notInArray(issues.status, ["done", "cancelled"]),
),
),
]);
const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
const issueId = readNonEmptyString(row.originId);
if (!issueId) return [];
return [{
companyId: row.companyId,
issueId,
status: row.status,
}];
});
return classifyIssueGraphLiveness({
issues: issueRows,
relations: relationRows,
@@ -1640,6 +1695,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
status: row.status,
issueId: issueIdFromWakePayload(row.payload),
})),
pendingInteractions: interactionRows,
pendingApprovals: approvalRows,
openRecoveryIssues,
});
}
+102
View File
@@ -0,0 +1,102 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
.flatMap((blocker) => blocker.terminalBlockers ?? [])
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const isStalled = blockerAttention?.state === "stalled";
const stalledLeafIdentifier =
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
const stalledLeafBlockers = (() => {
const candidates: IssueRelationIssueSummary[] = [];
for (const blocker of [...blockers, ...terminalBlockers]) {
if (blocker.status !== "in_review") continue;
if (candidates.some((existing) => existing.id === blocker.id)) continue;
candidates.push(blocker);
}
if (stalledLeafIdentifier) {
const preferred = candidates.find(
(blocker) => (blocker.identifier ?? blocker.id) === stalledLeafIdentifier,
);
if (preferred) {
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
}
}
return candidates;
})();
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
</IssueLinkQuicklook>
);
};
return (
<div
data-blocker-attention-state={blockerAttention?.state}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</div>
</div>
</div>
);
}
+9 -63
View File
@@ -32,6 +32,7 @@ import type {
FeedbackVote,
FeedbackVoteValue,
IssueAttachment,
IssueBlockerAttention,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
@@ -88,7 +89,6 @@ import {
} from "../lib/issue-chat-scroll";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { CompanyUserProfile } from "../lib/company-members";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { timeAgo } from "../lib/timeAgo";
import {
describeToolInput,
@@ -104,7 +104,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
interface IssueChatMessageContext {
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
@@ -245,6 +245,7 @@ interface IssueChatThreadProps {
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@@ -344,66 +345,6 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
}
}
function IssueBlockedNotice({
issueStatus,
blockers,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
.flatMap((blocker) => blocker.terminalBlockers ?? [])
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
</IssueLinkQuicklook>
);
};
return (
<div className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</div>
</div>
</div>
);
}
function IssueAssigneePausedNotice({ agent }: { agent: Agent | null }) {
if (!agent || agent.status !== "paused") return null;
@@ -2511,6 +2452,7 @@ export function IssueChatThread({
liveRuns = [],
activeRun = null,
blockedBy = [],
blockerAttention = null,
companyId,
projectId,
issueStatus,
@@ -2867,7 +2809,11 @@ export function IssueChatThread({
)}
{showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
<IssueBlockedNotice
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
) : null}
+6 -13
View File
@@ -677,8 +677,7 @@ export function IssueDocumentsSection({
};
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
const documentBodyPaddingClassName = "";
const documentBodyShellClassName = "mt-3";
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
const toggleFoldedDocument = (key: string) => {
setFoldedDocumentKeys((current) =>
@@ -784,9 +783,7 @@ export function IssueDocumentsSection({
PLAN
</span>
</div>
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
</div>
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
</div>
) : null}
@@ -1088,14 +1085,12 @@ export function IssueDocumentsSection({
/>
)}
<div
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
activeDraft || isHistoricalPreview ? "" : "hover:bg-accent/10"
className={`${documentBodyShellClassName} ${
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
}`}
>
{isHistoricalPreview ? (
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
renderFoldableBody(displayedBody, documentBodyContentClassName)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
@@ -1117,9 +1112,7 @@ export function IssueDocumentsSection({
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
renderFoldableBody(displayedBody, documentBodyContentClassName)
)}
</div>
<div className="flex min-h-4 items-center justify-end px-1">
@@ -403,8 +403,10 @@ describe("IssueProperties", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}),
childIssues: [],
+30
View File
@@ -14,8 +14,10 @@ describe("StatusIcon", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -38,8 +40,10 @@ describe("StatusIcon", () => {
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -58,8 +62,10 @@ describe("StatusIcon", () => {
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -69,4 +75,28 @@ describe("StatusIcon", () => {
expect(html).toContain("border-red-600");
expect(html).not.toContain("border-dashed");
});
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="stalled"');
expect(html).toContain('aria-label="Blocked · review stalled on PAP-2279"');
expect(html).toContain("border-amber-600");
expect(html).not.toContain("border-cyan-600");
expect(html).not.toContain("border-red-600");
});
});
+21 -2
View File
@@ -40,6 +40,14 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "stalled_review") {
const count = blockerAttention.stalledBlockerCount;
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
if (count === 1) return "Blocked · review stalled with no clear next step";
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.unresolvedBlockerCount;
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
@@ -51,11 +59,19 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
: isStalledBlocked
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
const blockerAttentionState = isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: undefined;
const circle = (
<span
@@ -65,7 +81,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
onChange && !showLabel && "cursor-pointer",
className
)}
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
data-blocker-attention-state={blockerAttentionState}
aria-label={ariaLabel}
title={ariaLabel}
>
@@ -75,6 +91,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
{isStalledBlocked && (
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
);
+2
View File
@@ -824,8 +824,10 @@ describe("IssueDetail", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}));
+4
View File
@@ -551,6 +551,7 @@ type IssueDetailChatTabProps = {
issueStatus: Issue["status"];
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -603,6 +604,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueStatus,
executionRunId,
blockedBy,
blockerAttention,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -797,6 +799,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -3392,6 +3395,7 @@ export function IssueDetail() {
issueStatus={issue.status}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
+230 -26
View File
@@ -1,11 +1,12 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
import { CopyText } from "@/components/CopyText";
import { EmptyState } from "@/components/EmptyState";
import { Identity } from "@/components/Identity";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
@@ -43,6 +44,21 @@ type CoveredBlockedCell = {
expectedCopy: string;
};
function attention(
partial: Partial<IssueBlockerAttention> & Pick<IssueBlockerAttention, "state" | "reason">,
): IssueBlockerAttention {
return {
state: partial.state,
reason: partial.reason,
unresolvedBlockerCount: partial.unresolvedBlockerCount ?? 0,
coveredBlockerCount: partial.coveredBlockerCount ?? 0,
stalledBlockerCount: partial.stalledBlockerCount ?? 0,
attentionBlockerCount: partial.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: partial.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: partial.sampleStalledBlockerIdentifier ?? null,
};
}
const coveredBlockedMatrix: CoveredBlockedCell[] = [
{
label: "Normal blocked",
@@ -54,98 +70,116 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
{
label: "Covered by 1 active child",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on active sub-issue PAP-2175",
},
{
label: "Covered by N active children",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 3,
coveredBlockerCount: 3,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on 3 active sub-issues",
},
{
label: "Covered by active dependency",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-1918",
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by active dependency PAP-1918",
},
{
label: "Covered by N active dependencies",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by 2 active dependencies",
},
{
label: "Stalled review (single leaf)",
status: "blocked",
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
stalledBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
}),
expectedVisual: "amber ring with dot",
expectedCopy: "Blocked · review stalled on PAP-2279",
},
{
label: "Stalled review (multiple leaves)",
status: "blocked",
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 2,
stalledBlockerCount: 2,
sampleStalledBlockerIdentifier: "PAP-2279",
}),
expectedVisual: "amber ring with dot",
expectedCopy: "Blocked · 2 reviews stalled with no clear next step",
},
{
label: "Mixed: 1 covered, 1 needs attention",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 2,
coveredBlockerCount: 1,
attentionBlockerCount: 1,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 2 unresolved blockers need attention",
},
{
label: "Needs attention (single blocker)",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-1042",
},
}),
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 1 unresolved blocker needs attention",
},
{
label: "Non-blocked with prop ignored",
status: "in_progress",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
}),
expectedVisual: "yellow ring",
expectedCopy: "In Progress",
},
@@ -163,6 +197,157 @@ const coveredBlockedIssue = createIssue({
updatedAt: new Date("2026-04-24T13:40:00.000Z"),
});
function summaryBlocker(
partial: Partial<IssueRelationIssueSummary> & Pick<IssueRelationIssueSummary, "id" | "title" | "status">,
): IssueRelationIssueSummary {
return {
id: partial.id,
identifier: partial.identifier ?? null,
title: partial.title,
status: partial.status,
priority: partial.priority ?? "medium",
assigneeAgentId: partial.assigneeAgentId ?? null,
assigneeUserId: partial.assigneeUserId ?? null,
terminalBlockers: partial.terminalBlockers,
};
}
type BlockedNoticeStateLabel =
| "Default covered"
| "Stalled (single leaf)"
| "Stalled (multiple leaves)";
type BlockedNoticeFixture = {
label: BlockedNoticeStateLabel;
caption: string;
blockers: IssueRelationIssueSummary[];
blockerAttention: IssueBlockerAttention;
};
const stalledLeafSingle = summaryBlocker({
id: "issue-stalled-leaf-single",
identifier: "PAP-2279",
title: "Stage gate review for export pipeline",
status: "in_review",
});
const stalledLeafMultiPrimary = summaryBlocker({
id: "issue-stalled-leaf-multi-1",
identifier: "PAP-2284",
title: "Approve schema migration",
status: "in_review",
});
const stalledLeafMultiSecondary = summaryBlocker({
id: "issue-stalled-leaf-multi-2",
identifier: "PAP-2291",
title: "Sign off on rollout copy",
status: "in_review",
});
const blockedNoticeFixtures: BlockedNoticeFixture[] = [
{
label: "Default covered",
caption: "Active sub-issue covers the chain — informational only.",
blockers: [
summaryBlocker({
id: "issue-active-child",
identifier: "PAP-2175",
title: "Wire export pipeline preview",
status: "in_progress",
}),
],
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2175",
}),
},
{
label: "Stalled (single leaf)",
caption: "Chain stalled on one leaf review — copy names the leaf and shows the chip strip.",
blockers: [
summaryBlocker({
id: "issue-stalled-parent-single",
identifier: "PAP-2278",
title: "Ship rollout dashboard",
status: "blocked",
terminalBlockers: [stalledLeafSingle],
}),
],
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
stalledBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
}),
},
{
label: "Stalled (multiple leaves)",
caption: "Multiple stalled reviews — body uses plural agreement (\"reviews\"/\"them\") to match the chip strip.",
blockers: [
summaryBlocker({
id: "issue-stalled-parent-multi-a",
identifier: "PAP-2283",
title: "Coordinate billing change rollout",
status: "blocked",
terminalBlockers: [stalledLeafMultiPrimary],
}),
summaryBlocker({
id: "issue-stalled-parent-multi-b",
identifier: "PAP-2290",
title: "Coordinate marketing handoff",
status: "blocked",
terminalBlockers: [stalledLeafMultiSecondary],
}),
],
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 2,
stalledBlockerCount: 2,
sampleStalledBlockerIdentifier: "PAP-2284",
}),
},
];
function BlockedNoticeSurface({
mode,
size,
fixture,
}: {
mode: "light" | "dark";
size: "desktop" | "mobile";
fixture: BlockedNoticeFixture;
}) {
const isDark = mode === "dark";
const isMobile = size === "mobile";
return (
<div className={isDark ? "dark" : undefined}>
<div className="rounded-lg border border-border bg-background text-foreground">
<div className="flex items-center justify-between border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{fixture.label}</span>
<span className="font-mono">
{size} · {mode}
</span>
</div>
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
<IssueBlockedNotice
issueStatus="blocked"
blockers={fixture.blockers}
blockerAttention={fixture.blockerAttention}
/>
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
</div>
</div>
</div>
);
}
function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "desktop" | "mobile" }) {
const isDark = mode === "dark";
const isMobile = size === "mobile";
@@ -248,8 +433,9 @@ function StatusLanguage() {
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Tooltip and aria-label copy begin with "Blocked · " for cells 2-7; cells 6 and 7 retain the solid red ring
and mention blockers that need attention.
Tooltip and aria-label copy begin with "Blocked · " for every cell after the first. Covered cells show a cyan
ring with a small dot, stalled-review cells show an amber ring with a centered dot, and the needs-attention
cells retain the solid red ring.
</p>
</Section>
@@ -262,6 +448,24 @@ function StatusLanguage() {
</div>
</Section>
<Section eyebrow="Covered blocked" title="IssueBlockedNotice in chat thread">
<div className="space-y-5">
{blockedNoticeFixtures.map((fixture) => (
<div key={fixture.label} className="grid gap-4 xl:grid-cols-2">
<BlockedNoticeSurface mode="light" size="desktop" fixture={fixture} />
<BlockedNoticeSurface mode="dark" size="desktop" fixture={fixture} />
<BlockedNoticeSurface mode="light" size="mobile" fixture={fixture} />
<BlockedNoticeSurface mode="dark" size="mobile" fixture={fixture} />
</div>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Stalled-state copy switches to "stalled in review without a clear next step" and adds a "Stalled in review"
chip strip beneath the regular blocker chips. The trailing imperative pluralizes when multiple stalled
leaves are surfaced ("reviews"/"them") to match the chip strip.
</p>
</Section>
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="grid gap-3 sm:grid-cols-2">