Fix wake diagnostics low-trust identifier redaction (#9133)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-06 21:45:23 -07:00 committed by GitHub
parent 295294d7ce
commit 31a0080e61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1187 additions and 1 deletions

View File

@ -646,6 +646,11 @@ export type {
IssueBlockerDiagnosticNode,
IssueBlockerDiagnosticsReadiness,
IssueBlockerDiagnosticsResponse,
IssueWakeDiagnosticActivityRecord,
IssueWakeDiagnosticEvent,
IssueWakeDiagnosticWakeFailureClass,
IssueWakeDiagnosticWakeRequest,
IssueWakeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,

View File

@ -293,6 +293,11 @@ export type {
IssueBlockerDiagnosticNode,
IssueBlockerDiagnosticsReadiness,
IssueBlockerDiagnosticsResponse,
IssueWakeDiagnosticActivityRecord,
IssueWakeDiagnosticEvent,
IssueWakeDiagnosticWakeFailureClass,
IssueWakeDiagnosticWakeRequest,
IssueWakeDiagnosticsResponse,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,

View File

@ -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 =

View File

@ -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<typeof createDb>;
type CompanyRow = typeof companies.$inferSelect;
type AgentRow = typeof agents.$inferSelect;
type ProjectRow = typeof projects.$inferSelect;
type IssueRow = typeof issues.$inferSelect;
function createApp(db: Db, actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
function boardActor(company: CompanyRow): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
companyIds: [company.id],
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
isInstanceAdmin: true,
source: "local_implicit",
};
}
function agentActor(company: CompanyRow, agent: AgentRow, runId: string): Express.Request["actor"] {
return {
type: "agent",
agentId: agent.id,
companyId: company.id,
runId,
source: "agent_jwt",
};
}
async function seedCompany(db: Db, label = "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<string, unknown> = {}) {
const [agent] = await db.insert(agents).values({
companyId,
name: `Agent ${randomUUID().slice(0, 6)}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
}).returning();
return agent!;
}
async function seedProject(db: Db, companyId: string, name: string) {
const [project] = await db.insert(projects).values({
companyId,
name,
status: "in_progress",
}).returning();
return project!;
}
async function seedIssue(
db: Db,
input: {
companyId: string;
projectId?: string | null;
title: string;
status?: string;
assigneeAgentId?: string | null;
parentId?: string | null;
},
) {
const [issue] = await db.insert(issues).values({
companyId: input.companyId,
projectId: input.projectId ?? null,
parentId: input.parentId ?? null,
title: input.title,
status: input.status ?? "todo",
priority: "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
responsibleUserId: "board-user",
}).returning();
return issue!;
}
async function blockIssue(db: Db, companyId: string, blockerIssueId: string, blockedIssueId: string) {
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
}
async function 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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
});
});

View File

@ -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<string, unknown> | 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<string, unknown> | 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);

View File

@ -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",

View File

@ -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<string, unknown> | 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 })

View File

@ -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. |