Add read-only issue subtree diagnostics endpoint (#9135)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The issue/task orchestration subsystem tracks parent–child and
blocker–dependent relationships, forming a directed acyclic (in
intention) subtree below each root issue
> - Agents and operators have no lightweight way to inspect the
dependency and wake state across an entire issue subtree — they must
walk the tree issue-by-issue, making multiple round-trips with full
object fetches
> - A bounded, read-only subtree diagnostic endpoint lets callers
understand the health of an entire work tree (which nodes are blocked,
which are cycling, which have pending wakes) from a single authenticated
request
> - This pull request adds `GET /api/issues/:id/diagnostics/subtree`, a
depth/node/per-node capped traversal that reuses the blocker and wake
projection helpers from the companion blocker and wake diagnostics
endpoints (see Refs #9114, #9133)
> - The benefit is that platform operators, monitoring, and coaching
tooling can surface \"why is this subtree stalled?\" across all nodes
without database access or unbounded graph walks, using only data the
caller already has read permission for

## Linked Issues or Issue Description

Refs #9114 (companion blocker diagnostics endpoint — blocker projection
helpers reused here)
Refs #9133 (companion wake diagnostics endpoint — wake projection
helpers reused here)

## What Changed

- **New route** `GET /api/issues/:id/diagnostics/subtree` in
`server/src/routes/issues.ts`: returns a bounded subtree traversal
rooted at `:id`, with depth/node/per-node caps and explicit truncation
flags
- **Cycle-safe traversal**: visited-node set prevents infinite loops on
any accidental cycle in the ancestry graph
- **Per-node authorization**: each subtree node is individually filtered
through `assertIssueReadAllowed`; unauthorized nodes are omitted from
the response and do not influence aggregate counts
- **Blocker and wake reuse**: per-node blocker rows and wake events are
projected through the same helpers as #9114 and #9133 — raw wake
payloads, raw errors, activity details, and trigger detail fields are
stripped
- **Low-trust filtering**: the `mention-scoped` low-trust path redacts
node/blocker identifiers for unauthorized actors, consistent with #9133
- **Truncation reporting**: response includes `depthTruncated`,
`nodeTruncated`, and per-node `blockersTruncated`/`wakesTruncated` flags
when caps are hit
- **Shared types** in `@paperclipai/shared`:
`IssueSubtreeDiagnosticsResponse` and supporting node/blocker/wake types
exported from the shared package
- **OpenAPI tag registration** for the new route
- **API reference docs** in
`skills/paperclip/references/api-reference.md`
- **Test coverage**
(`server/src/__tests__/issue-subtree-diagnostics-routes.test.ts`,
embedded Postgres): happy path, quiet singleton (no children/blockers),
node cap truncation, mention-scoped low-trust filtering, cross-company
denial

## Verification

```bash
# Subtree diagnostics tests only
pnpm exec vitest run server/src/__tests__/issue-subtree-diagnostics-routes.test.ts

# Full diagnostics suite (blocker + wake + subtree)
pnpm exec vitest run server/src/__tests__/issue-blocker-diagnostics-routes.test.ts server/src/__tests__/issue-wake-diagnostics-routes.test.ts server/src/__tests__/issue-subtree-diagnostics-routes.test.ts

# Type-check shared and server packages
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/server typecheck

# Whitespace / diff check
git diff --check
```

All commands passed locally (5 subtree tests, 17 total across the three
diagnostics test files).

## Risks

- **No schema or migration changes** — read-only projection over
existing relations; no DDL risk
- **Bounded traversal** — depth, node count, and per-node blocker/wake
caps prevent unbounded graph walks; truncation is reported explicitly in
the response
- **Auth boundary** — root issue read is company-scoped and checked
before the subtree is built; each subtree node is individually
authorized; cross-company access is denied at `assertCompanyAccess`
- **No raw payloads** — raw wake payload, raw error, activity details,
and trigger detail fields are stripped from all nodes, consistent with
the companion endpoints
- Low overall risk; the endpoint is additive and read-only

## Model Used

- **Provider:** Anthropic
- **Model:** Claude Sonnet 4.6 (`claude-sonnet-4-6`)
- **Tool use:** yes (file reads, edits, bash execution, Paperclip API
calls)
- **Reasoning mode:** standard (no extended thinking)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-06 22:39:34 -07:00 committed by GitHub
parent 47aef634e5
commit 9d5b0e3c57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1207 additions and 8 deletions

View File

@ -651,6 +651,9 @@ export type {
IssueWakeDiagnosticWakeFailureClass,
IssueWakeDiagnosticWakeRequest,
IssueWakeDiagnosticsResponse,
IssueSubtreeDiagnosticNode,
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,

View File

@ -298,6 +298,9 @@ export type {
IssueWakeDiagnosticWakeFailureClass,
IssueWakeDiagnosticWakeRequest,
IssueWakeDiagnosticsResponse,
IssueSubtreeDiagnosticNode,
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,

View File

@ -307,6 +307,80 @@ export interface IssueWakeDiagnosticsResponse {
};
}
export interface IssueSubtreeDiagnosticNode {
issue: IssueBlockerDiagnosticIssueSummary;
parentId: string | null;
depth: number;
diagnosis: string | null;
likelyReason: string | null;
blockers: IssueBlockerDiagnosticNode[];
blockerReadiness: IssueBlockerDiagnosticsReadiness | null;
omittedUnauthorizedBlockerCount: number | null;
wakeEvents: IssueWakeDiagnosticEvent[];
wakeRequestCount: number;
activityRecordCount: number;
truncated: boolean;
truncatedSections: {
blockers: boolean;
wakeRequests: boolean;
activityRecords: boolean;
};
}
export type IssueSubtreeDiagnosticEdge =
| {
kind: "parent";
fromIssueId: string;
toIssueId: string;
timestamp: string | null;
}
| {
kind: "blocks";
fromIssueId: string;
toIssueId: string;
timestamp: string | null;
}
| {
kind: "wake_request";
issueId: string;
agentId: string | null;
reason: string | null;
status: string;
timestamp: string;
}
| {
kind: "activity";
issueId: string;
action: string;
timestamp: string;
};
export interface IssueSubtreeDiagnosticsResponse {
issue: IssueBlockerDiagnosticIssueSummary;
diagnosis: string | null;
likelyReason: string | null;
nodes: IssueSubtreeDiagnosticNode[];
edges: IssueSubtreeDiagnosticEdge[];
nodeCount: number;
omittedUnauthorizedNodeCount: number | null;
truncated: boolean;
truncatedSections: {
nodes: boolean;
depth: boolean;
blockers: boolean;
wakeRequests: boolean;
activityRecords: boolean;
};
caps: {
maxDepth: number;
maxNodes: number;
maxBlockersPerNode: number;
maxWakeRequestsPerNode: number;
maxActivityRecordsPerNode: number;
lookbackDays: number;
};
}
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
export type IssueBlockerAttentionReason =

View File

@ -0,0 +1,429 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
companies,
createDb,
heartbeatRuns,
issueComments,
issueRelations,
issues,
projects,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres subtree diagnostic route tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
type Db = ReturnType<typeof createDb>;
type CompanyRow = typeof companies.$inferSelect;
type AgentRow = typeof agents.$inferSelect;
type ProjectRow = typeof projects.$inferSelect;
type IssueRow = typeof issues.$inferSelect;
function createApp(db: Db, actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
function boardActor(company: CompanyRow): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
companyIds: [company.id],
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
isInstanceAdmin: true,
source: "local_implicit",
};
}
function agentActor(company: CompanyRow, agent: AgentRow, runId: string): Express.Request["actor"] {
return {
type: "agent",
agentId: agent.id,
companyId: company.id,
runId,
source: "agent_jwt",
};
}
async function seedCompany(db: Db, label = "Subtree Diagnostics") {
const nonce = randomUUID().slice(0, 8);
const [company] = await db.insert(companies).values({
name: `${label} ${nonce}`,
issuePrefix: `SD${nonce.slice(0, 4).toUpperCase()}`,
defaultResponsibleUserId: "board-user",
}).returning();
return company!;
}
async function seedAgent(db: Db, companyId: string, permissions: Record<string, unknown> = {}) {
const [agent] = await db.insert(agents).values({
companyId,
name: `Agent ${randomUUID().slice(0, 6)}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
}).returning();
return agent!;
}
async function seedProject(db: Db, companyId: string, name: string) {
const [project] = await db.insert(projects).values({
companyId,
name,
status: "in_progress",
}).returning();
return project!;
}
async function seedIssue(
db: Db,
input: {
companyId: string;
projectId?: string | null;
title: string;
status?: string;
assigneeAgentId?: string | null;
parentId?: string | null;
},
) {
const [issue] = await db.insert(issues).values({
companyId: input.companyId,
projectId: input.projectId ?? null,
parentId: input.parentId ?? null,
title: input.title,
status: input.status ?? "todo",
priority: "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
responsibleUserId: "board-user",
}).returning();
return issue!;
}
async function blockIssue(db: Db, companyId: string, blockerIssueId: string, blockedIssueId: string) {
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
}
async function attachMentionScopedLowTrustRun(db: Db, fixture: {
company: CompanyRow;
ownerAgent: AgentRow;
mentionedAgent: AgentRow;
allowedProject: ProjectRow;
root: IssueRow;
}) {
const executionPolicy = {
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: fixture.company.id,
projectIds: [fixture.allowedProject.id],
issueIds: [],
allowedAgentIds: [],
},
},
};
await db.update(agents).set({
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: executionPolicy.authorizationPolicy,
},
}).where(eq(agents.id, fixture.mentionedAgent.id));
fixture.mentionedAgent.permissions = {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: executionPolicy.authorizationPolicy,
};
await db.insert(issueComments).values({
companyId: fixture.company.id,
issueId: fixture.root.id,
authorAgentId: fixture.ownerAgent.id,
body: `[@Mentioned Agent](agent://${fixture.mentionedAgent.id}) please inspect this root.`,
});
const [run] = await db.insert(heartbeatRuns).values({
companyId: fixture.company.id,
agentId: fixture.mentionedAgent.id,
status: "running",
contextSnapshot: {
issueId: fixture.root.id,
executionPolicy,
},
}).returning();
return run!;
}
describeEmbeddedPostgres("issue subtree diagnostics route", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-subtree-diagnostics-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(agentWakeupRequests);
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(projects);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("returns visible subtree nodes, blocker edges, wake edges, and a deterministic stall diagnosis", async () => {
const company = await seedCompany(db);
const agent = await seedAgent(db, company.id);
const project = await seedProject(db, company.id, "Core");
const root = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
title: "Blocked root",
status: "blocked",
assigneeAgentId: agent.id,
});
const child = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
parentId: root.id,
title: "Unfinished child blocker",
status: "in_progress",
});
const rawMarker = `RAW-SUBTREE-${randomUUID()}`;
await blockIssue(db, company.id, child.id, root.id);
await db.insert(agentWakeupRequests).values({
companyId: company.id,
agentId: agent.id,
source: "automation",
reason: "issue_commented",
status: "completed",
payload: { issueId: child.id, privateValue: rawMarker },
error: `secret ${rawMarker}`,
requestedAt: new Date(Date.now() - 5_000),
finishedAt: new Date(Date.now() - 1_000),
});
const res = await request(createApp(db, boardActor(company)))
.get(`/api/issues/${root.id}/diagnostics/subtree`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
diagnosis: expect.stringContaining("Blocked root appears to be the subtree stall point"),
likelyReason: expect.stringContaining("Blocked root appears to be the subtree stall point"),
nodeCount: 2,
omittedUnauthorizedNodeCount: 0,
truncated: false,
caps: {
maxDepth: 8,
maxNodes: 100,
maxBlockersPerNode: 20,
maxWakeRequestsPerNode: 5,
maxActivityRecordsPerNode: 5,
lookbackDays: 14,
},
});
expect(res.body.nodes).toHaveLength(2);
const rootNode = res.body.nodes.find((node: { issue: { id: string } }) => node.issue.id === root.id);
const childNode = res.body.nodes.find((node: { issue: { id: string } }) => node.issue.id === child.id);
expect(rootNode).toMatchObject({
diagnosis: expect.stringContaining("blocked by Unfinished child blocker"),
blockers: [expect.objectContaining({ id: child.id, status: "in_progress", isUnresolved: true })],
});
expect(childNode).toMatchObject({
parentId: root.id,
depth: 1,
wakeRequestCount: 1,
wakeEvents: [expect.objectContaining({ kind: "wake_request", reason: "issue_commented", status: "completed" })],
});
expect(res.body.edges).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "parent", fromIssueId: root.id, toIssueId: child.id }),
expect.objectContaining({ kind: "blocks", fromIssueId: child.id, toIssueId: root.id }),
expect.objectContaining({ kind: "wake_request", issueId: child.id, reason: "issue_commented" }),
]));
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain(rawMarker);
expect(serialized).not.toContain("\"payload\"");
expect(serialized).not.toContain("\"details\"");
expect(serialized).not.toContain("\"triggerDetail\"");
expect(serialized).not.toContain("\"error\"");
});
it("returns null diagnosis for a quiet unblocked singleton subtree", async () => {
const company = await seedCompany(db);
const project = await seedProject(db, company.id, "Core");
const issue = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
title: "Quiet root",
status: "todo",
});
const res = await request(createApp(db, boardActor(company)))
.get(`/api/issues/${issue.id}/diagnostics/subtree`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.diagnosis).toBeNull();
expect(res.body.likelyReason).toBeNull();
expect(res.body.nodes).toHaveLength(1);
expect(res.body.nodes[0]).toMatchObject({
diagnosis: null,
likelyReason: null,
blockers: [],
wakeEvents: [],
truncated: false,
});
expect(res.body.edges).toEqual([]);
expect(res.body.truncated).toBe(false);
});
it("caps subtree nodes and reports truncation explicitly", async () => {
const company = await seedCompany(db);
const project = await seedProject(db, company.id, "Core");
const root = await seedIssue(db, {
companyId: company.id,
projectId: project.id,
title: "Wide root",
status: "todo",
});
const childRows = [];
for (let index = 0; index < 100; index += 1) {
childRows.push({
companyId: company.id,
projectId: project.id,
parentId: root.id,
title: `Child ${String(index).padStart(3, "0")}`,
status: "todo",
priority: "medium",
responsibleUserId: "board-user",
});
}
await db.insert(issues).values(childRows);
const res = await request(createApp(db, boardActor(company)))
.get(`/api/issues/${root.id}/diagnostics/subtree`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.nodes).toHaveLength(100);
expect(res.body.nodeCount).toBe(100);
expect(res.body.truncated).toBe(true);
expect(res.body.truncatedSections).toMatchObject({ nodes: true });
expect(res.body.omittedUnauthorizedNodeCount).toBeNull();
expect(res.body.diagnosis).toContain("bounded to depth 8 and 100 nodes");
});
it("omits subtree nodes outside a mention-scoped actor's issue-read grant", async () => {
const company = await seedCompany(db);
const ownerAgent = await seedAgent(db, company.id);
const mentionedAgent = await seedAgent(db, company.id);
const allowedProject = await seedProject(db, company.id, "Allowed");
const targetProject = await seedProject(db, company.id, "Target");
const hiddenMarker = `HIDDEN-SUBTREE-${randomUUID()}`;
const root = await seedIssue(db, {
companyId: company.id,
projectId: targetProject.id,
title: "Mention-visible root",
status: "todo",
assigneeAgentId: ownerAgent.id,
});
const hiddenChild = await seedIssue(db, {
companyId: company.id,
projectId: targetProject.id,
parentId: root.id,
title: hiddenMarker,
status: "blocked",
});
await db.insert(agentWakeupRequests).values({
companyId: company.id,
agentId: ownerAgent.id,
source: "automation",
reason: "issue_commented",
status: "failed",
payload: { issueId: hiddenChild.id, privateValue: hiddenMarker },
error: `secret ${hiddenMarker}`,
requestedAt: new Date(Date.now() - 5_000),
});
const run = await attachMentionScopedLowTrustRun(db, {
company,
ownerAgent,
mentionedAgent,
allowedProject,
root,
});
const res = await request(createApp(db, agentActor(company, mentionedAgent, run.id)))
.get(`/api/issues/${root.id}/diagnostics/subtree`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.nodes).toHaveLength(1);
expect(res.body.nodes[0].issue.id).toBe(root.id);
expect(res.body.omittedUnauthorizedNodeCount).toBe(1);
expect(res.body.diagnosis).toContain("authorization boundary");
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain(hiddenChild.id);
expect(serialized).not.toContain(hiddenMarker);
expect(serialized).not.toContain("\"payload\"");
expect(serialized).not.toContain("\"error\"");
});
it("denies cross-company issue reads", async () => {
const companyA = await seedCompany(db, "Company A");
const companyB = await seedCompany(db, "Company B");
const agentB = await seedAgent(db, companyB.id);
const projectA = await seedProject(db, companyA.id, "A");
const issueA = await seedIssue(db, {
companyId: companyA.id,
projectId: projectA.id,
title: "Company A issue",
status: "blocked",
});
const [runB] = await db.insert(heartbeatRuns).values({
companyId: companyB.id,
agentId: agentB.id,
status: "running",
contextSnapshot: { issueId: issueA.id },
}).returning();
const res = await request(createApp(db, agentActor(companyB, agentB, runB!.id)))
.get(`/api/issues/${issueA.id}/diagnostics/subtree`);
expect(res.status, JSON.stringify(res.body)).toBe(403);
});
});

View File

@ -69,6 +69,9 @@ import {
type IssueBlockerDiagnosticNode,
type IssueBlockerDiagnosticsReadiness,
type IssueBlockerDiagnosticsResponse,
type IssueSubtreeDiagnosticEdge,
type IssueSubtreeDiagnosticNode,
type IssueSubtreeDiagnosticsResponse,
type IssueWakeDiagnosticActivityRecord,
type IssueWakeDiagnosticEvent,
type IssueWakeDiagnosticWakeFailureClass,
@ -818,6 +821,7 @@ function buildIssueBlockerDiagnosticsResponse(input: {
pendingFinalizeBlockerIssueIds: string[];
};
truncated: boolean;
maxBlockers?: number;
}): IssueBlockerDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const visibleBlockerIds = new Set(input.visibleBlockers.map((blocker) => blocker.id));
@ -866,13 +870,14 @@ function buildIssueBlockerDiagnosticsResponse(input: {
readiness,
omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount,
truncated: input.truncated,
maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
}),
readiness,
blockers,
omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount,
truncated: input.truncated,
caps: {
maxBlockers: ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
},
};
}
@ -883,10 +888,11 @@ function buildIssueBlockerDiagnosis(input: {
readiness: IssueBlockerDiagnosticsReadiness | null;
omittedUnauthorizedBlockerCount: number | null;
truncated: boolean;
maxBlockers: number;
}) {
if (input.truncated) {
return `Blocker diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated at ${
ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS
input.maxBlockers
} blockers, so readiness is not reported.`;
}
const omittedUnauthorizedBlockerCount = input.omittedUnauthorizedBlockerCount ?? 0;
@ -1086,12 +1092,15 @@ function buildIssueWakeDiagnosis(input: {
events: IssueWakeDiagnosticEvent[];
blockerDiagnostics: IssueBlockerDiagnosticsResponse;
truncated: boolean;
maxWakeRequests: number;
maxActivityRecords: number;
lookbackDays: number;
}) {
if (input.truncated) {
return `Wake diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated to ${
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS
} wake requests and ${ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS} activity records over ${
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS
input.maxWakeRequests
} wake requests and ${input.maxActivityRecords} activity records over ${
input.lookbackDays
} days, so the diagnosis only covers returned records.`;
}
@ -1203,6 +1212,9 @@ function buildIssueWakeDiagnosticsResponse(input: {
truncatedWakeRequests: boolean;
truncatedActivityRecords: boolean;
includeInternalIds: boolean;
maxWakeRequests?: number;
maxActivityRecords?: number;
lookbackDays?: number;
}): IssueWakeDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const events: IssueWakeDiagnosticEvent[] = [
@ -1214,11 +1226,17 @@ function buildIssueWakeDiagnosticsResponse(input: {
),
].sort((left, right) => issueWakeDiagnosticEventTimestamp(right) - issueWakeDiagnosticEventTimestamp(left));
const truncated = input.truncatedWakeRequests || input.truncatedActivityRecords;
const maxWakeRequests = input.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS;
const maxActivityRecords = input.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS;
const lookbackDays = input.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS;
const diagnosis = buildIssueWakeDiagnosis({
issue,
events,
blockerDiagnostics: input.blockerDiagnostics,
truncated,
maxWakeRequests,
maxActivityRecords,
lookbackDays,
});
return {
@ -1234,13 +1252,249 @@ function buildIssueWakeDiagnosticsResponse(input: {
activityRecords: input.truncatedActivityRecords,
},
caps: {
maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
maxWakeRequests,
maxActivityRecords,
lookbackDays,
},
};
}
type IssueSubtreeDiagnosticAuthzNode = IssueBlockerDiagnosticAuthzIssue & {
depth: number;
createdAt: Date | string;
updatedAt: Date | string;
};
type IssueSubtreeDiagnosticBlockerAuthzRow = IssueBlockerDiagnosticAuthzIssue & {
blockedIssueId: string;
relationCreatedAt: Date | string;
};
type IssueSubtreeDiagnosticWakeRequestRow = {
issueId: string;
agentId: string;
source: string;
reason: string | null;
status: string;
coalescedCount: number;
runId: string | null;
requestedAt: Date | string;
claimedAt: Date | string | null;
finishedAt: Date | string | null;
error: string | null;
};
type IssueSubtreeDiagnosticActivityRow = {
issueId: string;
action: string;
entityType: string;
entityId: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date | string;
};
function groupByIssueId<T extends { issueId: string }>(rows: T[]) {
const map = new Map<string, T[]>();
for (const row of rows) {
const issueRows = map.get(row.issueId) ?? [];
issueRows.push(row);
map.set(row.issueId, issueRows);
}
return map;
}
function groupBlockersByBlockedIssueId(rows: IssueSubtreeDiagnosticBlockerAuthzRow[]) {
const map = new Map<string, IssueSubtreeDiagnosticBlockerAuthzRow[]>();
for (const row of rows) {
const issueRows = map.get(row.blockedIssueId) ?? [];
issueRows.push(row);
map.set(row.blockedIssueId, issueRows);
}
return map;
}
function issueSubtreeEdgeTimestamp(edge: IssueSubtreeDiagnosticEdge) {
return edge.timestamp ? new Date(edge.timestamp).getTime() : 0;
}
function buildIssueSubtreeDiagnosis(input: {
issue: IssueBlockerDiagnosticIssueSummary;
nodes: IssueSubtreeDiagnosticNode[];
omittedUnauthorizedNodeCount: number | null;
truncated: boolean;
caps: IssueSubtreeDiagnosticsResponse["caps"];
}) {
if (input.truncated) {
return `Subtree diagnostics for ${blockerDiagnosticLabel(input.issue)} are bounded to depth ${
input.caps.maxDepth
} and ${input.caps.maxNodes} nodes, so the diagnosis only covers returned visible nodes.`;
}
if ((input.omittedUnauthorizedNodeCount ?? 0) > 0) {
return `One or more subtree nodes under ${blockerDiagnosticLabel(
input.issue,
)} are outside this actor's authorization boundary, so this diagnosis only covers visible nodes.`;
}
const blockedNodeWithDiagnosis = input.nodes.find((node) => node.issue.status === "blocked" && node.diagnosis);
const firstNodeWithDiagnosis = blockedNodeWithDiagnosis ?? input.nodes.find((node) => node.diagnosis);
if (!firstNodeWithDiagnosis?.diagnosis) return null;
return `${blockerDiagnosticLabel(firstNodeWithDiagnosis.issue)} appears to be the subtree stall point: ${
firstNodeWithDiagnosis.diagnosis
}`;
}
function buildIssueSubtreeDiagnosticsResponse(input: {
issue: IssueBlockerDiagnosticReadableIssue;
nodes: IssueSubtreeDiagnosticAuthzNode[];
visibleNodes: IssueSubtreeDiagnosticAuthzNode[];
blockersByIssueId: Map<string, IssueSubtreeDiagnosticBlockerAuthzRow[]>;
visibleBlockers: IssueSubtreeDiagnosticBlockerAuthzRow[];
readinessByIssueId: Map<string, {
allBlockersDone: boolean;
isDependencyReady: boolean;
unresolvedBlockerIssueIds: string[];
pendingFinalizeBlockerIssueIds: string[];
}>;
wakeRequestsByIssueId: Map<string, IssueSubtreeDiagnosticWakeRequestRow[]>;
activityRecordsByIssueId: Map<string, IssueSubtreeDiagnosticActivityRow[]>;
truncatedNodes: boolean;
truncatedDepth: boolean;
truncatedBlockerIssueIds: Set<string>;
truncatedWakeIssueIds: Set<string>;
truncatedActivityIssueIds: Set<string>;
includeInternalIds: boolean;
caps: IssueSubtreeDiagnosticsResponse["caps"];
}): IssueSubtreeDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const visibleNodeIds = new Set(input.visibleNodes.map((node) => node.id));
const visibleBlockerIdsByIssueId = groupBlockersByBlockedIssueId(input.visibleBlockers);
const omittedUnauthorizedNodeCount = input.truncatedNodes || input.truncatedDepth
? null
: input.nodes.filter((node) => !visibleNodeIds.has(node.id)).length;
const nodeResponses: IssueSubtreeDiagnosticNode[] = [];
const edges: IssueSubtreeDiagnosticEdge[] = [];
for (const node of input.visibleNodes) {
const rawBlockers = input.blockersByIssueId.get(node.id) ?? [];
const visibleBlockers = visibleBlockerIdsByIssueId.get(node.id) ?? [];
const blockerResponse = buildIssueBlockerDiagnosticsResponse({
issue: node,
blockers: rawBlockers,
visibleBlockers,
readiness: input.readinessByIssueId.get(node.id) ?? {
allBlockersDone: true,
isDependencyReady: true,
unresolvedBlockerIssueIds: [],
pendingFinalizeBlockerIssueIds: [],
},
truncated: input.truncatedBlockerIssueIds.has(node.id),
maxBlockers: input.caps.maxBlockersPerNode,
});
const wakeResponse = buildIssueWakeDiagnosticsResponse({
issue: node,
wakeRequests: input.wakeRequestsByIssueId.get(node.id) ?? [],
activityRecords: input.activityRecordsByIssueId.get(node.id) ?? [],
blockerDiagnostics: blockerResponse,
truncatedWakeRequests: input.truncatedWakeIssueIds.has(node.id),
truncatedActivityRecords: input.truncatedActivityIssueIds.has(node.id),
includeInternalIds: input.includeInternalIds,
maxWakeRequests: input.caps.maxWakeRequestsPerNode,
maxActivityRecords: input.caps.maxActivityRecordsPerNode,
lookbackDays: input.caps.lookbackDays,
});
const nodeDiagnosis = wakeResponse.diagnosis ?? blockerResponse.diagnosis;
if (node.parentId && visibleNodeIds.has(node.parentId)) {
edges.push({
kind: "parent",
fromIssueId: node.parentId,
toIssueId: node.id,
timestamp: dateToIso(node.createdAt),
});
}
for (const blocker of visibleBlockers) {
edges.push({
kind: "blocks",
fromIssueId: blocker.id,
toIssueId: node.id,
timestamp: dateToIso(blocker.relationCreatedAt),
});
}
for (const event of wakeResponse.events) {
if (event.kind === "wake_request") {
edges.push({
kind: "wake_request",
issueId: node.id,
agentId: event.agentId,
reason: event.reason,
status: event.status,
timestamp: event.requestedAt,
});
} else {
edges.push({
kind: "activity",
issueId: node.id,
action: event.action,
timestamp: event.createdAt,
});
}
}
nodeResponses.push({
issue: toIssueBlockerDiagnosticSummary(node),
parentId: node.parentId && visibleNodeIds.has(node.parentId) ? node.parentId : null,
depth: node.depth,
diagnosis: nodeDiagnosis,
likelyReason: nodeDiagnosis,
blockers: blockerResponse.blockers,
blockerReadiness: blockerResponse.readiness,
omittedUnauthorizedBlockerCount: blockerResponse.omittedUnauthorizedBlockerCount,
wakeEvents: wakeResponse.events,
wakeRequestCount: wakeResponse.wakeRequestCount,
activityRecordCount: wakeResponse.activityRecordCount,
truncated: blockerResponse.truncated || wakeResponse.truncated,
truncatedSections: {
blockers: blockerResponse.truncated,
wakeRequests: wakeResponse.truncatedSections.wakeRequests,
activityRecords: wakeResponse.truncatedSections.activityRecords,
},
});
}
edges.sort((left, right) => issueSubtreeEdgeTimestamp(right) - issueSubtreeEdgeTimestamp(left));
const truncatedSections = {
nodes: input.truncatedNodes,
depth: input.truncatedDepth,
blockers: input.truncatedBlockerIssueIds.size > 0,
wakeRequests: input.truncatedWakeIssueIds.size > 0,
activityRecords: input.truncatedActivityIssueIds.size > 0,
};
const truncated = Object.values(truncatedSections).some(Boolean);
const diagnosis = buildIssueSubtreeDiagnosis({
issue,
nodes: nodeResponses,
omittedUnauthorizedNodeCount,
truncated,
caps: input.caps,
});
return {
issue,
diagnosis,
likelyReason: diagnosis,
nodes: nodeResponses,
edges,
nodeCount: nodeResponses.length,
omittedUnauthorizedNodeCount,
truncated,
truncatedSections,
caps: input.caps,
};
}
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
@ -4188,6 +4442,60 @@ export function issueRoutes(
res.json(response);
});
router.get("/issues/:id/diagnostics/subtree", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const [diagnostic, includeInternalIds] = await Promise.all([
svc.getSubtreeDiagnostics(issue.id),
actorCanReadCompanyScope(req, issue.companyId),
]);
const allBlockers = [...diagnostic.blockersByIssueId.values()].flat();
const [visibleNodes, visibleBlockers] = await Promise.all([
filterIssuesForActor(req, diagnostic.nodes),
filterIssuesForActor(req, allBlockers),
]);
const response = buildIssueSubtreeDiagnosticsResponse({
issue,
nodes: diagnostic.nodes,
visibleNodes,
blockersByIssueId: diagnostic.blockersByIssueId,
visibleBlockers,
readinessByIssueId: diagnostic.readinessByIssueId,
wakeRequestsByIssueId: diagnostic.wakeRequestsByIssueId,
activityRecordsByIssueId: diagnostic.activityRecordsByIssueId,
truncatedNodes: diagnostic.truncatedNodes,
truncatedDepth: diagnostic.truncatedDepth,
truncatedBlockerIssueIds: diagnostic.truncatedBlockerIssueIds,
truncatedWakeIssueIds: diagnostic.truncatedWakeIssueIds,
truncatedActivityIssueIds: diagnostic.truncatedActivityIssueIds,
includeInternalIds,
caps: diagnostic.caps,
});
logger.info(
{
companyId: issue.companyId,
issueId: issue.id,
actorType: req.actor.type,
nodeCount: response.nodeCount,
omittedUnauthorizedNodeCount: response.omittedUnauthorizedNodeCount,
edgeCount: response.edges.length,
internalIdsIncluded: includeInternalIds,
truncated: response.truncated,
},
"issue subtree diagnostics read",
);
res.json(response);
});
router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View File

@ -4970,6 +4970,13 @@ registerCurrentRoute({
summary: "Get wake diagnostics for an issue",
});
registerCurrentRoute({
method: "get",
path: "/api/issues/{id}/diagnostics/subtree",
tags: ["issues"],
summary: "Get bounded subtree wake and blocker diagnostics for an issue",
});
registerCurrentRoute({
method: "get",
path: "/api/issues/{id}/recovery-actions",

View File

@ -104,6 +104,11 @@ export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100;
export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50;
export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50;
export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH = 8;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES = 100;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE = 20;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE = 5;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE = 5;
const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
@ -548,6 +553,30 @@ type IssueWakeDiagnosticsActivityRow = {
details: Record<string, unknown> | null;
createdAt: Date;
};
type IssueSubtreeDiagnosticsIssueRow = IssueBlockerDiagnosticsIssueRow & {
depth: number;
createdAt: Date;
updatedAt: Date;
};
type IssueSubtreeDiagnosticsBlockerRow = IssueBlockerDiagnosticsIssueRow & {
blockedIssueId: string;
relationCreatedAt: Date;
};
type IssueSubtreeDiagnosticsWakeRequestRow = IssueWakeDiagnosticsWakeRequestRow & {
issueId: string;
};
type IssueSubtreeDiagnosticsActivityRow = IssueWakeDiagnosticsActivityRow & {
issueId: string;
};
type IssueSubtreeDiagnosticsBlockerResultRow = IssueSubtreeDiagnosticsBlockerRow & {
rowNumber: number | string;
};
type IssueSubtreeDiagnosticsWakeRequestResultRow = IssueSubtreeDiagnosticsWakeRequestRow & {
rowNumber: number | string;
};
type IssueSubtreeDiagnosticsActivityResultRow = IssueSubtreeDiagnosticsActivityRow & {
rowNumber: number | string;
};
export type IssueDependencyReadiness = {
issueId: string;
blockerIssueIds: string[];
@ -5052,6 +5081,303 @@ export function issueService(db: Db) {
};
},
getSubtreeDiagnostics: async (
issueId: string,
opts?: {
maxDepth?: number;
maxNodes?: number;
maxBlockersPerNode?: number;
maxWakeRequestsPerNode?: number;
maxActivityRecordsPerNode?: number;
lookbackDays?: number;
},
) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
const maxDepth = Math.max(
0,
Math.min(opts?.maxDepth ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH, ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH),
);
const maxNodes = Math.max(
1,
Math.min(opts?.maxNodes ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES, ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES),
);
const maxBlockersPerNode = Math.max(
0,
Math.min(
opts?.maxBlockersPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
),
);
const maxWakeRequestsPerNode = Math.max(
0,
Math.min(
opts?.maxWakeRequestsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
),
);
const maxActivityRecordsPerNode = Math.max(
0,
Math.min(
opts?.maxActivityRecordsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
),
);
const lookbackDays = Math.max(
1,
Math.min(opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS),
);
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
const sinceIso = since.toISOString();
const rawSubtreeRows = await db.execute(sql<IssueSubtreeDiagnosticsIssueRow>`
WITH RECURSIVE issue_tree AS (
SELECT
id,
company_id,
project_id,
parent_id,
identifier,
title,
status,
priority,
assignee_agent_id,
assignee_user_id,
created_at,
updated_at,
0 AS depth,
ARRAY[id] AS path
FROM issues
WHERE company_id = ${issue.companyId}
AND id = ${issue.id}
AND hidden_at IS NULL
UNION ALL
SELECT
child.id,
child.company_id,
child.project_id,
child.parent_id,
child.identifier,
child.title,
child.status,
child.priority,
child.assignee_agent_id,
child.assignee_user_id,
child.created_at,
child.updated_at,
issue_tree.depth + 1,
issue_tree.path || child.id
FROM issues child
JOIN issue_tree ON child.parent_id = issue_tree.id
WHERE child.company_id = ${issue.companyId}
AND child.hidden_at IS NULL
AND issue_tree.depth < ${maxDepth + 1}
AND NOT child.id = ANY(issue_tree.path)
)
SELECT
id,
company_id AS "companyId",
project_id AS "projectId",
parent_id AS "parentId",
identifier,
title,
status,
priority,
assignee_agent_id AS "assigneeAgentId",
assignee_user_id AS "assigneeUserId",
created_at AS "createdAt",
updated_at AS "updatedAt",
depth::int AS depth
FROM issue_tree
ORDER BY depth ASC, created_at ASC, id ASC
LIMIT ${maxNodes + 1}
`);
const subtreeRows = Array.from(rawSubtreeRows)
.map((row) => ({ ...row, depth: Number(row.depth) }));
const rowsWithinDepth = subtreeRows.filter((row) => row.depth <= maxDepth);
const nodes = rowsWithinDepth.slice(0, maxNodes) as IssueSubtreeDiagnosticsIssueRow[];
const truncatedNodes = rowsWithinDepth.length > maxNodes;
const truncatedDepth = truncatedNodes || subtreeRows.some((row) => row.depth > maxDepth);
const nodeIds = nodes.map((node) => node.id);
const readiness = nodeIds.length > 0
? await listIssueDependencyReadinessMap(db, issue.companyId, nodeIds)
: new Map<string, IssueDependencyReadiness>();
const blockersByIssueId = new Map<string, IssueSubtreeDiagnosticsBlockerRow[]>();
const wakeRequestsByIssueId = new Map<string, IssueSubtreeDiagnosticsWakeRequestRow[]>();
const activityRecordsByIssueId = new Map<string, IssueSubtreeDiagnosticsActivityRow[]>();
const truncatedBlockerIssueIds = new Set<string>();
const truncatedWakeIssueIds = new Set<string>();
const truncatedActivityIssueIds = new Set<string>();
if (nodeIds.length > 0) {
const nodeIdValues = sql.join(nodeIds.map((id) => sql`${id}`), sql`, `);
const rawBlockerRows = Array.from(await db.execute(sql`
WITH blocker_rows AS (
SELECT
blocker.id,
blocker.company_id AS "companyId",
blocker.project_id AS "projectId",
blocker.parent_id AS "parentId",
blocker.identifier,
blocker.title,
blocker.status,
blocker.priority,
blocker.assignee_agent_id AS "assigneeAgentId",
blocker.assignee_user_id AS "assigneeUserId",
relation.related_issue_id AS "blockedIssueId",
relation.created_at AS "relationCreatedAt",
row_number() OVER (
PARTITION BY relation.related_issue_id
ORDER BY blocker.title ASC, blocker.id ASC
)::int AS "rowNumber"
FROM issue_relations relation
INNER JOIN issues blocker ON blocker.id = relation.issue_id
WHERE relation.company_id = ${issue.companyId}
AND relation.type = 'blocks'
AND blocker.company_id = ${issue.companyId}
AND blocker.hidden_at IS NULL
AND relation.related_issue_id::text IN (${nodeIdValues})
)
SELECT *
FROM blocker_rows
WHERE "rowNumber" <= ${maxBlockersPerNode + 1}
ORDER BY "blockedIssueId" ASC, "rowNumber" ASC
`)) as IssueSubtreeDiagnosticsBlockerResultRow[];
for (const row of rawBlockerRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxBlockersPerNode) {
truncatedBlockerIssueIds.add(normalized.blockedIssueId);
continue;
}
const rows = blockersByIssueId.get(normalized.blockedIssueId) ?? [];
rows.push(normalized);
blockersByIssueId.set(normalized.blockedIssueId, rows);
}
const wakeTargetIssueIdSql = sql<string>`
coalesce(
wake.payload ->> 'issueId',
wake.payload ->> 'taskId',
wake.payload -> '_paperclipWakeContext' ->> 'issueId',
wake.payload -> '_paperclipWakeContext' ->> 'taskId'
)
`;
const rawWakeRows = Array.from(await db.execute(sql`
WITH wake_rows AS (
SELECT
${wakeTargetIssueIdSql} AS "issueId",
wake.agent_id AS "agentId",
wake.source,
wake.reason,
wake.status,
wake.coalesced_count AS "coalescedCount",
wake.run_id AS "runId",
wake.requested_at AS "requestedAt",
wake.claimed_at AS "claimedAt",
wake.finished_at AS "finishedAt",
wake.error,
row_number() OVER (
PARTITION BY ${wakeTargetIssueIdSql}
ORDER BY wake.requested_at DESC, wake.created_at DESC
)::int AS "rowNumber"
FROM agent_wakeup_requests wake
WHERE wake.company_id = ${issue.companyId}
AND wake.requested_at >= ${sinceIso}::timestamptz
AND ${wakeTargetIssueIdSql} IN (${nodeIdValues})
)
SELECT *
FROM wake_rows
WHERE "rowNumber" <= ${maxWakeRequestsPerNode + 1}
ORDER BY "issueId" ASC, "requestedAt" DESC
`)) as IssueSubtreeDiagnosticsWakeRequestResultRow[];
for (const row of rawWakeRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxWakeRequestsPerNode) {
truncatedWakeIssueIds.add(normalized.issueId);
continue;
}
const rows = wakeRequestsByIssueId.get(normalized.issueId) ?? [];
rows.push(normalized);
wakeRequestsByIssueId.set(normalized.issueId, rows);
}
const activityTargetIssueIdSql = sql<string>`
coalesce(
CASE WHEN activity.entity_type = 'issue' THEN activity.entity_id ELSE NULL END,
activity.details ->> 'issueId',
activity.details ->> 'rootIssueId'
)
`;
const activityActionValues = sql.join(
ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
);
const rawActivityRows = Array.from(await db.execute(sql`
WITH activity_rows AS (
SELECT
${activityTargetIssueIdSql} AS "issueId",
activity.action,
activity.entity_type AS "entityType",
activity.entity_id AS "entityId",
activity.agent_id AS "agentId",
activity.run_id AS "runId",
activity.details,
activity.created_at AS "createdAt",
row_number() OVER (
PARTITION BY ${activityTargetIssueIdSql}
ORDER BY activity.created_at DESC, activity.id DESC
)::int AS "rowNumber"
FROM activity_log activity
WHERE activity.company_id = ${issue.companyId}
AND activity.created_at >= ${sinceIso}::timestamptz
AND activity.action IN (${activityActionValues})
AND ${activityTargetIssueIdSql} IN (${nodeIdValues})
)
SELECT *
FROM activity_rows
WHERE "rowNumber" <= ${maxActivityRecordsPerNode + 1}
ORDER BY "issueId" ASC, "createdAt" DESC
`)) as IssueSubtreeDiagnosticsActivityResultRow[];
for (const row of rawActivityRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxActivityRecordsPerNode) {
truncatedActivityIssueIds.add(normalized.issueId);
continue;
}
const rows = activityRecordsByIssueId.get(normalized.issueId) ?? [];
rows.push(normalized);
activityRecordsByIssueId.set(normalized.issueId, rows);
}
}
return {
nodes,
blockersByIssueId,
readinessByIssueId: readiness,
wakeRequestsByIssueId,
activityRecordsByIssueId,
truncatedNodes,
truncatedDepth,
truncatedBlockerIssueIds,
truncatedWakeIssueIds,
truncatedActivityIssueIds,
caps: {
maxDepth,
maxNodes,
maxBlockersPerNode,
maxWakeRequestsPerNode,
maxActivityRecordsPerNode,
lookbackDays,
},
};
},
getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => {
const issue = await dbOrTx
.select({ id: issues.id, companyId: issues.companyId })

View File

@ -274,6 +274,54 @@ Security and bounds:
- Activity records are limited to wake defer/suppression actions and exact allowlisted fields such as `rootIssueId`, `holdId`, `source`, `requestedReason`, and `previousReason`.
- Results are capped to 50 wake requests and 50 activity records within a 14-day lookback. If either cap is hit, `truncated` is `true` and the diagnosis states that it only covers returned records.
### Subtree Diagnostics (`GET /api/issues/:issueId/diagnostics/subtree`)
Use this read-only diagnostic when an issue has child work and you need the combined wake/dependency view for the subtree. Read top-level `diagnosis` first; `likelyReason` is the same value. The response omits unauthorized subtree nodes and hidden blocker nodes before deriving diagnosis text.
```json
{
"issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null },
"diagnosis": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.",
"likelyReason": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.",
"nodes": [
{
"issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null },
"parentId": null,
"depth": 0,
"diagnosis": "PAP-99 is blocked by PAP-80, which is in_progress.",
"likelyReason": "PAP-99 is blocked by PAP-80, which is in_progress.",
"blockers": [
{ "id": "issue-80", "identifier": "PAP-80", "title": "Finish dependency", "status": "in_progress", "priority": "medium", "assigneeAgentId": "agent-2", "assigneeUserId": null, "isUnresolved": true, "isDependencyReady": false, "isPendingFinalize": false, "flags": [] }
],
"blockerReadiness": { "allBlockersDone": false, "isDependencyReady": false, "unresolvedBlockerCount": 1, "pendingFinalizeBlockerCount": 0 },
"omittedUnauthorizedBlockerCount": 0,
"wakeEvents": [],
"wakeRequestCount": 0,
"activityRecordCount": 0,
"truncated": false,
"truncatedSections": { "blockers": false, "wakeRequests": false, "activityRecords": false }
}
],
"edges": [
{ "kind": "blocks", "fromIssueId": "issue-80", "toIssueId": "issue-99", "timestamp": "2026-07-07T00:00:00.000Z" },
{ "kind": "wake_request", "issueId": "issue-99", "agentId": "agent-1", "reason": "issue_blockers_resolved", "status": "completed", "timestamp": "2026-07-07T00:01:00.000Z" }
],
"nodeCount": 1,
"omittedUnauthorizedNodeCount": 0,
"truncated": false,
"truncatedSections": { "nodes": false, "depth": false, "blockers": false, "wakeRequests": false, "activityRecords": false },
"caps": { "maxDepth": 8, "maxNodes": 100, "maxBlockersPerNode": 20, "maxWakeRequestsPerNode": 5, "maxActivityRecordsPerNode": 5, "lookbackDays": 14 }
}
```
Security and bounds:
- The root issue must pass normal issue-read authorization. Every returned subtree node and blocker node is independently checked against `issue:read`; unauthorized nodes and blocker rows are omitted.
- `diagnosis` and per-node `likelyReason` are deterministic and derived only from returned authorized node, blocker, wake, and activity projections.
- Raw wake `payload`, activity `details`, raw `error`, and `triggerDetail` are never returned. Wake fields use the same coarse projections as wake diagnostics.
- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for internal wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`.
- The subtree walk is capped to depth 8 and 100 nodes with a cycle guard. Per-node blockers, wake requests, and activity records are also capped. Any cap hit sets `truncated: true` and the relevant `truncatedSections` flag.
### Execution Policy Fields On An Issue
When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`:
@ -1012,6 +1060,7 @@ Terminal states: `done`, `cancelled`
| GET | `/api/issues/:issueId/heartbeat-context` | Compact context for heartbeat: issue state, ancestor summaries, comment cursor |
| GET | `/api/issues/:issueId/diagnostics/blockers` | Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags |
| GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference |
| GET | `/api/issues/:issueId/diagnostics/subtree` | Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis` |
| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) |
| PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) |
| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. |