From 31a0080e61e5510c04a1b8f17dd985f7565d2ca4 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 21:45:23 -0700 Subject: [PATCH] Fix wake diagnostics low-trust identifier redaction (#9133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open-source app people use to manage AI agents for work > - The task/issue lifecycle subsystem tracks when agents wake up, are suppressed, or are deferred, recording each wake request in `agent_wakeup_requests` and each defer/suppression event in `activity_log` > - When an agent appears stuck or doesn't resume after a dependency resolves, there is currently no read-only API surface to inspect its wake history — operators must query the database directly > - Making wake history queryable via a first-class endpoint lets operators, support, and monitoring tools diagnose "why didn't this agent wake up?" without database access > - This pull request adds `GET /api/issues/:id/diagnostics/wakes`, returning a bounded 14-day/50-row projection of wake requests and defer/suppression activity events, with a deterministic `diagnosis` field and a `likelyReason` inference — including a Case-B inference ("no wake enqueued because a visible blocker is not done") that reuses the blocker readiness data from the companion blocker diagnostics endpoint (see Refs #9114) > - The benefit is that platform operators can answer "why is this agent not waking up?" from a safe, read-only HTTP endpoint rather than needing direct database access, and CI/monitoring can assert expected wake behavior ## Linked Issues or Issue Description Refs #9114 (companion blocker diagnostics endpoint, already merged — this PR extends the same diagnostic surface to wake/activity history) ## What Changed - **New route** `GET /api/issues/:id/diagnostics/wakes` in `server/src/routes/issues.ts`: returns a bounded (14-day window, 50-row cap) projection of `agent_wakeup_requests` rows and wake-relevant `activity_log` rows (defer/suppression events) - **Sanitized projection**: raw `payload`, `details`, `error`, and `triggerDetail` fields are stripped; unknown free-form `source`, `reason`, and `status` values are projected to `"other"` to prevent schema bleed - **Deterministic `diagnosis` and `likelyReason` fields**: includes Case-B inference ("no wake enqueued — visible blocker not done") that calls the existing blocker-readiness helper from Slice 1 (#9114) so the wake surface can explain missing wakes caused by outstanding blockers - **Auth**: `assertCompanyAccess` + `assertIssueReadAllowed`; cross-company requests are denied; Case-B blocker inference filters by caller trust level so hidden (low-trust) blockers are mentioned but not identified - **Types in `@paperclipai/shared`**: `IssueWakeDiagnosticsResponse`, `WakeEvent`, `ActivityEvent` exported from the shared package - **OpenAPI tag registration** for the new route - **Skill reference docs** in `skills/paperclip/references/api-reference.md` documenting the endpoint contract - **Test coverage** (`server/src/__tests__/issue-wake-diagnostics-routes.test.ts`, embedded Postgres): happy path, empty/null diagnosis, Case-B inference, low-trust hidden blocker, cross-company denial, raw blob minimization, cap behaviour, combined blocker+wake test run ## Verification ```bash # Wake diagnostics tests only pnpm exec vitest run server/src/__tests__/issue-wake-diagnostics-routes.test.ts # Wake + blocker diagnostics together (integration) pnpm exec vitest run server/src/__tests__/issue-blocker-diagnostics-routes.test.ts server/src/__tests__/issue-wake-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. ## Risks - **No schema or migration changes** — this is a read-only projection over existing tables; no DDL risk. - **Bounded queries** — 14-day window + 50-row cap limit per call; no unbounded scans. - **Auth boundary** — cross-company access is denied at `assertCompanyAccess`; Case-B inference uses the same per-node trust filtering as the blocker endpoint so low-trust blockers are acknowledged but not identified. - 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 --- packages/shared/src/index.ts | 5 + packages/shared/src/types/index.ts | 5 + packages/shared/src/types/issue.ts | 54 ++ .../issue-wake-diagnostics-routes.test.ts | 566 ++++++++++++++++++ server/src/routes/issues.ts | 364 +++++++++++ server/src/routes/openapi.ts | 7 + server/src/services/issues.ts | 142 ++++- skills/paperclip/references/api-reference.md | 45 ++ 8 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/issue-wake-diagnostics-routes.test.ts 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. |