diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 64b2b314a6..2a536d8f5c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -646,6 +646,11 @@ export type { IssueBlockerDiagnosticNode, IssueBlockerDiagnosticsReadiness, IssueBlockerDiagnosticsResponse, + IssueWakeDiagnosticActivityRecord, + IssueWakeDiagnosticEvent, + IssueWakeDiagnosticWakeFailureClass, + IssueWakeDiagnosticWakeRequest, + IssueWakeDiagnosticsResponse, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 020a4fd952..281ca5ba08 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -293,6 +293,11 @@ export type { IssueBlockerDiagnosticNode, IssueBlockerDiagnosticsReadiness, IssueBlockerDiagnosticsResponse, + IssueWakeDiagnosticActivityRecord, + IssueWakeDiagnosticEvent, + IssueWakeDiagnosticWakeFailureClass, + IssueWakeDiagnosticWakeRequest, + IssueWakeDiagnosticsResponse, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 55dda24b27..16981e9299 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -253,6 +253,60 @@ export interface IssueBlockerDiagnosticsResponse { }; } +export type IssueWakeDiagnosticWakeFailureClass = "failed" | "cancelled" | "skipped"; + +export interface IssueWakeDiagnosticWakeRequest { + kind: "wake_request"; + agentId: string | null; + source: string; + reason: string | null; + status: string; + coalescedCount: number; + runId: string | null; + requestedAt: string; + claimedAt: string | null; + finishedAt: string | null; + failureClass: IssueWakeDiagnosticWakeFailureClass | null; +} + +export interface IssueWakeDiagnosticActivityRecord { + kind: "activity"; + action: string; + entityType: string; + agentId: string | null; + runId: string | null; + createdAt: string; + source: string | null; + requestedReason: string | null; + previousReason: string | null; + rootIssueId: string | null; + holdId: string | null; + summary: string; +} + +export type IssueWakeDiagnosticEvent = + | IssueWakeDiagnosticWakeRequest + | IssueWakeDiagnosticActivityRecord; + +export interface IssueWakeDiagnosticsResponse { + issue: IssueBlockerDiagnosticIssueSummary; + diagnosis: string | null; + likelyReason: string | null; + events: IssueWakeDiagnosticEvent[]; + wakeRequestCount: number; + activityRecordCount: number; + truncated: boolean; + truncatedSections: { + wakeRequests: boolean; + activityRecords: boolean; + }; + caps: { + maxWakeRequests: number; + maxActivityRecords: number; + lookbackDays: number; + }; +} + export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention"; export type IssueBlockerAttentionReason = diff --git a/server/src/__tests__/issue-wake-diagnostics-routes.test.ts b/server/src/__tests__/issue-wake-diagnostics-routes.test.ts new file mode 100644 index 0000000000..e361a51239 --- /dev/null +++ b/server/src/__tests__/issue-wake-diagnostics-routes.test.ts @@ -0,0 +1,566 @@ +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, + 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 wake diagnostic route tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +type Db = ReturnType; +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 = "Wake Diagnostics") { + const nonce = randomUUID().slice(0, 8); + const [company] = await db.insert(companies).values({ + name: `${label} ${nonce}`, + issuePrefix: `WD${nonce.slice(0, 4).toUpperCase()}`, + defaultResponsibleUserId: "board-user", + }).returning(); + return company!; +} + +async function seedAgent(db: Db, companyId: string, permissions: Record = {}) { + 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 attachLowTrustRun(db: Db, fixture: { + company: CompanyRow; + agent: AgentRow; + allowedProject: ProjectRow; + root: IssueRow; + visibleBlocker: IssueRow; +}) { + const executionPolicy = { + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: fixture.company.id, + projectIds: [fixture.allowedProject.id], + rootIssueId: fixture.root.id, + issueIds: [fixture.root.id, fixture.visibleBlocker.id], + allowedAgentIds: [], + }, + }, + }; + await db.update(agents).set({ + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: executionPolicy.authorizationPolicy, + }, + }).where(eq(agents.id, fixture.agent.id)); + fixture.agent.permissions = { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: executionPolicy.authorizationPolicy, + }; + + const [run] = await db.insert(heartbeatRuns).values({ + companyId: fixture.company.id, + agentId: fixture.agent.id, + status: "running", + contextSnapshot: { + issueId: fixture.root.id, + executionPolicy, + }, + }).returning(); + await db.update(issues).set({ + assigneeAgentId: fixture.agent.id, + checkoutRunId: run!.id, + executionRunId: run!.id, + executionPolicy, + }).where(eq(issues.id, fixture.root.id)); + return run!; +} + +describeEmbeddedPostgres("issue wake diagnostics route", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-wake-diagnostics-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(agentWakeupRequests); + 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 recent wake rows newest-first with a deterministic diagnosis", async () => { + const company = await seedCompany(db); + const agent = await seedAgent(db, company.id); + const project = await seedProject(db, company.id, "Core"); + const issue = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Wake target", + status: "todo", + assigneeAgentId: agent.id, + }); + const wakeRunId = randomUUID(); + + await db.insert(agentWakeupRequests).values({ + companyId: company.id, + agentId: agent.id, + source: "automation", + reason: "issue_blockers_resolved", + status: "completed", + coalescedCount: 2, + payload: { issueId: issue.id, rawMarker: "SHOULD_NOT_LEAK" }, + runId: wakeRunId, + requestedAt: new Date(Date.now() - 10_000), + claimedAt: new Date(Date.now() - 9_000), + finishedAt: new Date(Date.now() - 1_000), + }); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${issue.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + diagnosis: expect.stringContaining("completed for issue_blockers_resolved"), + likelyReason: expect.stringContaining("completed for issue_blockers_resolved"), + wakeRequestCount: 1, + activityRecordCount: 0, + truncated: false, + caps: { maxWakeRequests: 50, maxActivityRecords: 50, lookbackDays: 14 }, + }); + expect(res.body.events).toHaveLength(1); + expect(res.body.events[0]).toMatchObject({ + kind: "wake_request", + agentId: agent.id, + runId: wakeRunId, + source: "automation", + reason: "issue_blockers_resolved", + status: "completed", + coalescedCount: 2, + failureClass: null, + }); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain("SHOULD_NOT_LEAK"); + 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 an unblocked issue with no wake history", 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 issue", + status: "todo", + }); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${issue.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.diagnosis).toBeNull(); + expect(res.body.likelyReason).toBeNull(); + expect(res.body.events).toEqual([]); + expect(res.body.wakeRequestCount).toBe(0); + expect(res.body.activityRecordCount).toBe(0); + }); + + it("infers Case-B never-enqueued blockers-resolved wake from visible blocker state", 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 blocker = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Unfinished blocker", + status: "in_progress", + }); + await blockIssue(db, company.id, blocker.id, root.id); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${root.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.events).toEqual([]); + expect(res.body.diagnosis).toContain("No wake row exists"); + expect(res.body.diagnosis).toContain("Unfinished blocker"); + expect(res.body.diagnosis).toContain("in_progress"); + expect(res.body.diagnosis).toContain("issue_blockers_resolved has not fired"); + }); + + it("omits hidden blocker state from Case-B diagnosis for boundary-scoped agents", async () => { + const company = await seedCompany(db); + const agent = await seedAgent(db, company.id); + const allowedProject = await seedProject(db, company.id, "Allowed"); + const hiddenProject = await seedProject(db, company.id, "Hidden"); + const hiddenMarker = `HIDDEN-WAKE-BLOCKER-${randomUUID()}`; + const root = await seedIssue(db, { + companyId: company.id, + projectId: allowedProject.id, + title: "Scoped root", + status: "blocked", + }); + const visibleBlocker = await seedIssue(db, { + companyId: company.id, + projectId: allowedProject.id, + title: "Visible blocker", + status: "in_progress", + }); + const hiddenBlocker = await seedIssue(db, { + companyId: company.id, + projectId: hiddenProject.id, + title: hiddenMarker, + status: "cancelled", + }); + await blockIssue(db, company.id, visibleBlocker.id, root.id); + await blockIssue(db, company.id, hiddenBlocker.id, root.id); + const run = await attachLowTrustRun(db, { company, agent, allowedProject, root, visibleBlocker }); + + const res = await request(createApp(db, agentActor(company, agent, run.id))) + .get(`/api/issues/${root.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.diagnosis).toContain("authorization boundary"); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain(hiddenBlocker.id); + expect(serialized).not.toContain(hiddenMarker); + expect(serialized).not.toContain("cancelled"); + + const hiddenAgent = await seedAgent(db, company.id); + const wakeRunId = randomUUID(); + const [activityRun] = await db.insert(heartbeatRuns).values({ + companyId: company.id, + agentId: hiddenAgent.id, + status: "succeeded", + }).returning(); + const activityRunId = activityRun!.id; + const holdId = randomUUID(); + await db.insert(agentWakeupRequests).values({ + companyId: company.id, + agentId: hiddenAgent.id, + source: "automation", + reason: "issue_blockers_resolved", + status: "completed", + coalescedCount: 0, + payload: { issueId: root.id }, + runId: wakeRunId, + requestedAt: new Date(Date.now() - 5_000), + claimedAt: new Date(Date.now() - 4_000), + finishedAt: new Date(Date.now() - 3_000), + }); + await db.insert(activityLog).values({ + companyId: company.id, + actorType: "system", + actorId: "system", + action: "issue.tree_hold_wakeup_deferred", + entityType: "issue", + entityId: root.id, + agentId: hiddenAgent.id, + runId: activityRunId, + details: { + rootIssueId: root.id, + agentId: hiddenAgent.id, + holdId, + source: "automation", + requestedReason: "issue_blockers_resolved", + }, + createdAt: new Date(Date.now() - 1_000), + }); + + const resWithEvents = await request(createApp(db, agentActor(company, agent, run.id))) + .get(`/api/issues/${root.id}/diagnostics/wakes`); + + expect(resWithEvents.status, JSON.stringify(resWithEvents.body)).toBe(200); + expect(resWithEvents.body.events).toHaveLength(2); + const activityEvent = resWithEvents.body.events.find((event: { kind: string }) => event.kind === "activity"); + const wakeEvent = resWithEvents.body.events.find((event: { kind: string }) => event.kind === "wake_request"); + expect(activityEvent).toMatchObject({ + kind: "activity", + agentId: null, + runId: null, + holdId: null, + }); + expect(wakeEvent).toMatchObject({ + kind: "wake_request", + agentId: null, + runId: null, + }); + const serializedWithEvents = JSON.stringify(resWithEvents.body); + expect(serializedWithEvents).not.toContain(hiddenBlocker.id); + expect(serializedWithEvents).not.toContain(hiddenMarker); + expect(serializedWithEvents).not.toContain(hiddenAgent.id); + expect(serializedWithEvents).not.toContain(wakeRunId); + expect(serializedWithEvents).not.toContain(activityRunId); + expect(serializedWithEvents).not.toContain(holdId); + }); + + 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/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); + + it("projects activity records and wake failures without raw blobs", async () => { + const company = await seedCompany(db); + const agent = await seedAgent(db, company.id); + const project = await seedProject(db, company.id, "Core"); + const issue = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Held issue", + status: "todo", + assigneeAgentId: agent.id, + }); + const rawMarker = `RAW-DETAIL-${randomUUID()}`; + const [activityRun] = await db.insert(heartbeatRuns).values({ + companyId: company.id, + agentId: agent.id, + status: "succeeded", + }).returning(); + const activityRunId = activityRun!.id; + + await db.insert(agentWakeupRequests).values({ + companyId: company.id, + agentId: agent.id, + source: "automation", + reason: "unknown-private-reason", + status: "failed", + payload: { issueId: issue.id, privateValue: rawMarker }, + error: `secret stack ${rawMarker}`, + requestedAt: new Date(Date.now() - 60_000), + }); + await db.insert(activityLog).values({ + companyId: company.id, + actorType: "system", + actorId: "system", + action: "issue.tree_hold_wakeup_deferred", + entityType: "issue", + entityId: issue.id, + agentId: agent.id, + runId: activityRunId, + details: { + rootIssueId: issue.id, + holdId: "hold-safe", + source: "automation", + requestedReason: "issue_commented", + triggerDetail: rawMarker, + secret: rawMarker, + }, + createdAt: new Date(Date.now() - 1_000), + }); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${issue.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.diagnosis).toContain("deferred by an active issue-tree hold"); + expect(res.body.events).toHaveLength(2); + expect(res.body.events[0]).toMatchObject({ + kind: "activity", + action: "issue.tree_hold_wakeup_deferred", + source: "automation", + requestedReason: "issue_commented", + agentId: agent.id, + runId: activityRunId, + holdId: "hold-safe", + summary: "Wake was deferred because an active issue-tree hold was present.", + }); + expect(res.body.events[1]).toMatchObject({ + kind: "wake_request", + reason: "other", + status: "failed", + failureClass: "failed", + }); + 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("caps wake output and reports truncation", async () => { + const company = await seedCompany(db); + const agent = await seedAgent(db, company.id); + const project = await seedProject(db, company.id, "Core"); + const issue = await seedIssue(db, { + companyId: company.id, + projectId: project.id, + title: "Noisy issue", + status: "todo", + assigneeAgentId: agent.id, + }); + const wakeRows = []; + for (let index = 0; index < 51; index += 1) { + wakeRows.push({ + companyId: company.id, + agentId: agent.id, + source: "automation", + reason: "issue_commented", + status: "completed", + payload: { issueId: issue.id }, + requestedAt: new Date(Date.now() - index * 1_000), + }); + } + await db.insert(agentWakeupRequests).values(wakeRows); + + const res = await request(createApp(db, boardActor(company))) + .get(`/api/issues/${issue.id}/diagnostics/wakes`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.events).toHaveLength(50); + expect(res.body.wakeRequestCount).toBe(50); + expect(res.body.truncated).toBe(true); + expect(res.body.truncatedSections).toEqual({ wakeRequests: true, activityRecords: false }); + expect(res.body.diagnosis).toContain("truncated to 50 wake requests"); + expect(res.body.caps).toEqual({ maxWakeRequests: 50, maxActivityRecords: 50, lookbackDays: 14 }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 935a0365d5..f0aaf95618 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -69,6 +69,11 @@ import { type IssueBlockerDiagnosticNode, type IssueBlockerDiagnosticsReadiness, type IssueBlockerDiagnosticsResponse, + type IssueWakeDiagnosticActivityRecord, + type IssueWakeDiagnosticEvent, + type IssueWakeDiagnosticWakeFailureClass, + type IssueWakeDiagnosticWakeRequest, + type IssueWakeDiagnosticsResponse, type IssueRelationIssueSummary, type IssueWatchdogDiscoveryKind, type SourceTrustMetadata, @@ -135,6 +140,9 @@ import { feedbackService } from "../services/feedback.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS, + ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, + ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, + ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, readAcceptedPlanConfirmationTarget, } from "../services/issues.js"; import { authorizationDeniedDetails } from "../services/authorization.js"; @@ -926,6 +934,313 @@ function buildIssueBlockerDiagnosis(input: { return null; } +const ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES = new Set([ + "timer", + "assignment", + "on_demand", + "automation", +]); + +const ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS = new Set([ + "issue_assigned", + "issue_blockers_resolved", + "issue_commented", + "issue_comment_mentioned", + "issue_dependencies_blocked", + "issue_tree_hold_active", + "missing_issue_comment", + "process_lost_retry", + "run_liveness_continuation", + "heartbeat.disabled", + "heartbeat.timer.no_actionable_work", + "heartbeat.wakeOnDemand.disabled", +]); + +const ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES = new Set([ + "queued", + "claimed", + "coalesced", + "skipped", + "completed", + "failed", + "cancelled", + "deferred_issue_execution", +]); + +function dateToIso(value: Date | string | null | undefined) { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function projectWakeDiagnosticSource(value: string | null) { + if (!value) return null; + return ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES.has(value) ? value : "other"; +} + +function projectWakeDiagnosticReason(value: string | null) { + if (!value) return null; + return ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS.has(value) ? value : "other"; +} + +function projectWakeDiagnosticStatus(value: string) { + return ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES.has(value) ? value : "other"; +} + +function wakeFailureClass( + status: string, + rawError: string | null, +): IssueWakeDiagnosticWakeFailureClass | null { + if (status === "failed" || rawError) return "failed"; + if (status === "cancelled") return "cancelled"; + if (status === "skipped") return "skipped"; + return null; +} + +function projectIssueWakeRequest(row: { + 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; +}, options: { includeInternalIds: boolean }): IssueWakeDiagnosticWakeRequest { + const status = projectWakeDiagnosticStatus(row.status); + return { + kind: "wake_request", + agentId: options.includeInternalIds ? row.agentId : null, + source: projectWakeDiagnosticSource(row.source) ?? "other", + reason: projectWakeDiagnosticReason(row.reason), + status, + coalescedCount: row.coalescedCount, + runId: options.includeInternalIds ? row.runId : null, + requestedAt: dateToIso(row.requestedAt)!, + claimedAt: dateToIso(row.claimedAt), + finishedAt: dateToIso(row.finishedAt), + failureClass: wakeFailureClass(status, row.error), + }; +} + +function wakeDiagnosticActivityAction(action: string) { + return action === "issue.tree_hold_wakeup_deferred" ? action : "other"; +} + +function wakeDiagnosticActivityEntityType(entityType: string) { + return entityType === "issue" || entityType === "agent_wakeup_request" ? entityType : "other"; +} + +function projectIssueWakeActivityRecord( + row: { + action: string; + entityType: string; + entityId: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date | string; + }, + issueId: string, + options: { includeInternalIds: boolean }, +): IssueWakeDiagnosticActivityRecord { + const details = row.details && typeof row.details === "object" ? row.details : {}; + const action = wakeDiagnosticActivityAction(row.action); + const rootIssueId = readNonEmptyString(details["rootIssueId"]); + const detailIssueId = readNonEmptyString(details["issueId"]); + const projectedRootIssueId = + rootIssueId === issueId || detailIssueId === issueId || (row.entityType === "issue" && row.entityId === issueId) + ? issueId + : null; + + return { + kind: "activity", + action, + entityType: wakeDiagnosticActivityEntityType(row.entityType), + agentId: options.includeInternalIds ? row.agentId ?? readNonEmptyString(details["agentId"]) : null, + runId: options.includeInternalIds ? row.runId : null, + createdAt: dateToIso(row.createdAt)!, + source: projectWakeDiagnosticSource(readNonEmptyString(details["source"])), + requestedReason: projectWakeDiagnosticReason(readNonEmptyString(details["requestedReason"])), + previousReason: projectWakeDiagnosticReason(readNonEmptyString(details["previousReason"])), + rootIssueId: projectedRootIssueId, + holdId: options.includeInternalIds ? readNonEmptyString(details["holdId"]) : null, + summary: action === "issue.tree_hold_wakeup_deferred" + ? "Wake was deferred because an active issue-tree hold was present." + : "Wake-related activity was recorded.", + }; +} + +function issueWakeDiagnosticEventTimestamp(event: IssueWakeDiagnosticEvent) { + const timestamp = event.kind === "wake_request" ? event.requestedAt : event.createdAt; + return new Date(timestamp).getTime(); +} + +function wakeDiagnosticReasonPhrase(reason: string | null) { + return reason ? ` for ${reason}` : ""; +} + +function buildIssueWakeDiagnosis(input: { + issue: IssueBlockerDiagnosticIssueSummary; + events: IssueWakeDiagnosticEvent[]; + blockerDiagnostics: IssueBlockerDiagnosticsResponse; + truncated: boolean; +}) { + 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 + } days, so the diagnosis only covers returned records.`; + } + + const latest = input.events[0]; + if (latest?.kind === "activity" && latest.action === "issue.tree_hold_wakeup_deferred") { + return `The most recent wake-related activity for ${blockerDiagnosticLabel( + input.issue, + )} was deferred by an active issue-tree hold.`; + } + if (latest?.kind === "wake_request") { + if (latest.status === "deferred_issue_execution") { + return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is deferred${wakeDiagnosticReasonPhrase( + latest.reason, + )}.`; + } + if (latest.status === "failed") { + return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} failed${wakeDiagnosticReasonPhrase( + latest.reason, + )}; raw error text is withheld.`; + } + if (latest.status === "skipped" || latest.status === "cancelled" || latest.status === "coalesced") { + const coalesced = + latest.coalescedCount > 0 ? ` and coalesced ${latest.coalescedCount} additional request(s)` : ""; + return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} was ${latest.status}${wakeDiagnosticReasonPhrase( + latest.reason, + )}${coalesced}.`; + } + if (latest.status === "queued" || latest.status === "claimed") { + return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is currently ${latest.status}${wakeDiagnosticReasonPhrase( + latest.reason, + )}.`; + } + if (latest.status === "completed") { + return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} completed${wakeDiagnosticReasonPhrase( + latest.reason, + )}.`; + } + } + + if (input.events.length > 0) return null; + + const blockerDiagnostics = input.blockerDiagnostics; + if (blockerDiagnostics.truncated) { + return `No wake rows are visible for ${blockerDiagnosticLabel( + input.issue, + )} in the bounded window, and blocker diagnostics are truncated, so no wake cause is inferred.`; + } + if ((blockerDiagnostics.omittedUnauthorizedBlockerCount ?? 0) > 0) { + return `No wake rows are visible for ${blockerDiagnosticLabel( + input.issue, + )} in the bounded window, and one or more blockers are outside this actor's authorization boundary.`; + } + if (input.issue.status !== "blocked" || blockerDiagnostics.blockers.length === 0) return null; + + const pendingFinalize = blockerDiagnostics.blockers.find((blocker) => blocker.isPendingFinalize); + if (pendingFinalize) { + return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel( + input.issue, + )} is waiting for ${blockerDiagnosticLabel(pendingFinalize)} to finish workspace finalization, so issue_blockers_resolved has not fired.`; + } + + const cancelled = blockerDiagnostics.blockers.find((blocker) => blocker.status === "cancelled"); + if (cancelled) { + return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel( + input.issue, + )} is blocked by ${blockerDiagnosticLabel(cancelled)}, which is cancelled; cancelled blockers do not fire issue_blockers_resolved.`; + } + + const unresolved = blockerDiagnostics.blockers.find((blocker) => blocker.isUnresolved); + if (unresolved) { + return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel( + input.issue, + )} is blocked by ${blockerDiagnosticLabel(unresolved)}, which is ${unresolved.status}, so issue_blockers_resolved has not fired.`; + } + + if (blockerDiagnostics.readiness?.isDependencyReady) { + return `No wake row exists for ${blockerDiagnosticLabel( + input.issue, + )} in the bounded window. All visible blockers are resolved, but the issue is still blocked; this is likely a stale blocker hold or an older wake outside the lookback window.`; + } + + return null; +} + +function buildIssueWakeDiagnosticsResponse(input: { + issue: IssueBlockerDiagnosticReadableIssue; + wakeRequests: Array<{ + 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; + }>; + activityRecords: Array<{ + action: string; + entityType: string; + entityId: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date | string; + }>; + blockerDiagnostics: IssueBlockerDiagnosticsResponse; + truncatedWakeRequests: boolean; + truncatedActivityRecords: boolean; + includeInternalIds: boolean; +}): IssueWakeDiagnosticsResponse { + const issue = toIssueBlockerDiagnosticSummary(input.issue); + const events: IssueWakeDiagnosticEvent[] = [ + ...input.wakeRequests.map((record) => + projectIssueWakeRequest(record, { includeInternalIds: input.includeInternalIds }), + ), + ...input.activityRecords.map((record) => + projectIssueWakeActivityRecord(record, issue.id, { includeInternalIds: input.includeInternalIds }), + ), + ].sort((left, right) => issueWakeDiagnosticEventTimestamp(right) - issueWakeDiagnosticEventTimestamp(left)); + const truncated = input.truncatedWakeRequests || input.truncatedActivityRecords; + const diagnosis = buildIssueWakeDiagnosis({ + issue, + events, + blockerDiagnostics: input.blockerDiagnostics, + truncated, + }); + + return { + issue, + diagnosis, + likelyReason: diagnosis, + events, + wakeRequestCount: input.wakeRequests.length, + activityRecordCount: input.activityRecords.length, + truncated, + truncatedSections: { + wakeRequests: input.truncatedWakeRequests, + activityRecords: input.truncatedActivityRecords, + }, + caps: { + maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, + maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, + lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, + }, + }; +} + const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE = @@ -3824,6 +4139,55 @@ export function issueRoutes( res.json(response); }); + router.get("/issues/:id/diagnostics/wakes", 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 [wakeDiagnostic, blockerDiagnostic, includeInternalIds] = await Promise.all([ + svc.getWakeDiagnostics(issue.id), + svc.getBlockerDiagnostics(issue.id), + actorCanReadCompanyScope(req, issue.companyId), + ]); + const visibleBlockers = await filterIssuesForActor(req, blockerDiagnostic.blockers); + const blockerResponse = buildIssueBlockerDiagnosticsResponse({ + issue, + blockers: blockerDiagnostic.blockers, + visibleBlockers, + readiness: blockerDiagnostic.readiness, + truncated: blockerDiagnostic.truncated, + }); + const response = buildIssueWakeDiagnosticsResponse({ + issue, + wakeRequests: wakeDiagnostic.wakeRequests, + activityRecords: wakeDiagnostic.activityRecords, + blockerDiagnostics: blockerResponse, + truncatedWakeRequests: wakeDiagnostic.truncatedWakeRequests, + truncatedActivityRecords: wakeDiagnostic.truncatedActivityRecords, + includeInternalIds, + }); + + logger.info( + { + companyId: issue.companyId, + issueId: issue.id, + actorType: req.actor.type, + wakeRequestCount: response.wakeRequestCount, + activityRecordCount: response.activityRecordCount, + internalIdsIncluded: includeInternalIds, + truncated: response.truncated, + }, + "issue wake 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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5079433b0a..d7cbf4ddff 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4963,6 +4963,13 @@ registerCurrentRoute({ summary: "Get blocker diagnostics for an issue", }); +registerCurrentRoute({ + method: "get", + path: "/api/issues/{id}/diagnostics/wakes", + tags: ["issues"], + summary: "Get wake diagnostics for an issue", +}); + registerCurrentRoute({ method: "get", path: "/api/issues/{id}/recovery-actions", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5f00917ebd..fc5ce3081c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,6 +1,6 @@ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; -import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -101,6 +101,9 @@ const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; export const ISSUE_LIST_DEFAULT_LIMIT = 500; export const ISSUE_LIST_MAX_LIMIT = 1000; 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; const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; @@ -115,6 +118,25 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8; const DELETED_ISSUE_COMMENT_BODY = ""; +const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = ["issue.tree_hold_wakeup_deferred"] as const; + +function wakeRequestTargetsIssue(issueId: string) { + return sql`( + ${agentWakeupRequests.payload} ->> 'issueId' = ${issueId} + or ${agentWakeupRequests.payload} ->> 'taskId' = ${issueId} + or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issueId} + or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issueId} + )`; +} + +function wakeDiagnosticActivityTargetsIssue(issueId: string) { + return sql`( + (${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId}) + or ${activityLog.details} ->> 'issueId' = ${issueId} + or ${activityLog.details} ->> 'rootIssueId' = ${issueId} + )`; +} + function assertTransition(from: string, to: string) { if (from === to) return; if (!ALL_ISSUE_STATUSES.includes(to)) { @@ -505,6 +527,27 @@ type IssueBlockerDiagnosticsIssueRow = { assigneeAgentId: string | null; assigneeUserId: string | null; }; +type IssueWakeDiagnosticsWakeRequestRow = { + agentId: string; + source: string; + reason: string | null; + status: string; + coalescedCount: number; + runId: string | null; + requestedAt: Date; + claimedAt: Date | null; + finishedAt: Date | null; + error: string | null; +}; +type IssueWakeDiagnosticsActivityRow = { + action: string; + entityType: string; + entityId: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date; +}; export type IssueDependencyReadiness = { issueId: string; blockerIssueIds: string[]; @@ -4912,6 +4955,103 @@ export function issueService(db: Db) { }; }, + getWakeDiagnostics: async ( + issueId: string, + opts?: { + maxWakeRequests?: number; + maxActivityRecords?: 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 maxWakeRequests = Math.max( + 0, + Math.min( + opts?.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, + ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, + ), + ); + const maxActivityRecords = Math.max( + 0, + Math.min( + opts?.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, + ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, + ), + ); + 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 wakeRows = await db + .select({ + agentId: agentWakeupRequests.agentId, + source: agentWakeupRequests.source, + reason: agentWakeupRequests.reason, + status: agentWakeupRequests.status, + coalescedCount: agentWakeupRequests.coalescedCount, + runId: agentWakeupRequests.runId, + requestedAt: agentWakeupRequests.requestedAt, + claimedAt: agentWakeupRequests.claimedAt, + finishedAt: agentWakeupRequests.finishedAt, + error: agentWakeupRequests.error, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, issue.companyId), + gte(agentWakeupRequests.requestedAt, since), + wakeRequestTargetsIssue(issue.id), + ), + ) + .orderBy(desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.createdAt)) + .limit(maxWakeRequests + 1); + + const activityRows = await db + .select({ + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + agentId: activityLog.agentId, + runId: activityLog.runId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, issue.companyId), + gte(activityLog.createdAt, since), + inArray(activityLog.action, [...ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS]), + wakeDiagnosticActivityTargetsIssue(issue.id), + ), + ) + .orderBy(desc(activityLog.createdAt)) + .limit(maxActivityRecords + 1); + + return { + wakeRequests: wakeRows.slice(0, maxWakeRequests) as IssueWakeDiagnosticsWakeRequestRow[], + activityRecords: activityRows.slice(0, maxActivityRecords) as IssueWakeDiagnosticsActivityRow[], + truncatedWakeRequests: wakeRows.length > maxWakeRequests, + truncatedActivityRecords: activityRows.length > maxActivityRecords, + caps: { + maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, + maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, + lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, + }, + }; + }, + getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => { const issue = await dbOrTx .select({ id: issues.id, companyId: issues.companyId }) diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 93f611556d..38fcacb538 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -230,6 +230,50 @@ Security and bounds: - If blockers are omitted or the result is truncated, `readiness` is `null` and `diagnosis` does not mention hidden blocker ids, statuses, assignees, or reasons. - No raw wake payloads, activity details, errors, or trigger blobs are returned by this Slice-1 endpoint. +### Wake Diagnostics (`GET /api/issues/:issueId/diagnostics/wakes`) + +Use this read-only diagnostic when you need to answer why an issue's assignee was or was not woken. Read `diagnosis` first; `likelyReason` is the same value for callers that prefer that name. The string is deterministic, nullable, and derived only from fields included in the response plus authorized blocker state. + +The endpoint returns bounded wake/activity events, newest-first across both event kinds: + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "likelyReason": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "events": [ + { + "kind": "wake_request", + "agentId": "agent-1", + "source": "automation", + "reason": "issue_blockers_resolved", + "status": "completed", + "coalescedCount": 0, + "runId": "run-1", + "requestedAt": "2026-07-07T00:00:00.000Z", + "claimedAt": "2026-07-07T00:00:01.000Z", + "finishedAt": "2026-07-07T00:00:10.000Z", + "failureClass": null + } + ], + "wakeRequestCount": 1, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "wakeRequests": false, "activityRecords": false }, + "caps": { "maxWakeRequests": 50, "maxActivityRecords": 50, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization, and Case-B blocker inference uses the same per-blocker authorization rules as blocker diagnostics. +- Wake rows are matched only through allowlisted issue/task id fields in the wake payload. Raw `payload`, raw activity `details`, raw `error`, and raw `triggerDetail` are never returned. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- Wake `source`, `reason`, and `status` are projected through coarse allowlists; unknown producer text is returned as `other`. +- Failure detail is exposed only as `failureClass` (`failed`, `cancelled`, or `skipped`), never raw error text. +- 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. + ### Execution Policy Fields On An Issue When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`: @@ -967,6 +1011,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId` | Issue details + ancestors | | 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 | | 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. |