Files
PaperClipAI/server/src/__tests__/issue-liveness.test.ts
678728f650 feat: maintained in_review review-path contract + stalled-review actions (#10675)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents move issues to `in_review` and rely on a "review path" (an
interaction, an approval, a monitor, or a named reviewer) to tell them
who decides next.
> - That review path can silently disappear. A user comment supersedes
the pending interaction, a monitor is exhausted, or a run ends without
restoring a path. The issue then sits in `in_review` with nobody
reviewing it and no visible action.
> - Such issues become invisible zombies. Nobody knows a decision is
owed, so the work stalls forever.
> - This pull request makes the review path a maintained invariant,
exposes a `reviewAttention` surface, and gives every stalled review
three inline actions in the UI.
> - The benefit is that an `in_review` issue always shows who reviews
it, or shows an amber "nobody is reviewing this" notice with one-click
Approve, Request changes, and Send back to work.

## Linked Issues or Issue Description

This pull request describes the problem inline. The tracking issue is
internal.

**Subsystem affected**

The review and attention loop that agents and humans share: the
`in_review` status, the `reviewAttention` surface, the /decisions
attention feed, and the issue-page review panel.

**Problem or motivation**

Agent-owned issues in `in_review` can lose their last review path. A
user comment supersedes the pending interaction. A monitor is exhausted.
A run ends without restoring a path. The issue then sits in `in_review`
with no reviewer and no visible action. It becomes an invisible zombie
and the work never progresses.

**Proposed solution**

Maintain the review path as a server invariant. Expose a
`reviewAttention` field that says what is under review, who decides, and
since when. Render a persistent review panel on the issue page and
inline actions on the /decisions feed. Keep human PATCHes into
`in_review` ungated, but record the requesting user so the panel never
renders empty.

**Alternatives considered**

A pure background auto-recovery sweep. This stays opt-in and is not
enough on its own, because it is invisible to the human. A bare status
banner. This is rejected, because it gives no action to resolve the
stall.

**Roadmap alignment**

This improves the core review and attention loop that both agents and
humans use every day.

## What Changed

- **Server — maintained review-path invariant:** when an issue enters or
sits in `in_review`, the server derives and persists a review path
(interaction, approval, monitor, or the requesting user) and recovers a
stale path with one bounded wake instead of leaving the issue pathless.
- **Server — `reviewAttention` surface:** a new field describes what is
under review (bound target with links), who decides, since when, and
whether the review is stalled. Stalled agent-assigned reviews are now
included in the attention feed.
- **Server — inline stalled-review decisions:** secured routes let a
permitted responder Approve (→ `done`), Request changes (→ `todo` + wake
carrying the note), or Send back to work (→ `todo` + wake) directly from
the attention feed.
- **Server — resume-intent wake:** an `in_review -> todo` transition now
wakes the assigned agent so a resumed review is not dropped.
- **Server — user-entry symmetry:** user PATCHes into `in_review` stay
ungated (no 422 for humans) and record the requesting user, who becomes
the named responder when no other path exists.
- **UI — review panel:** a persistent `IssueReviewPanel` renders above
the thread whenever status is `in_review`. The covered state shows the
bound target, responder, and outcomes and hoists the pending
interaction/approval card. The stalled state shows the amber notice plus
the three actions.
- **UI — decisions card actions:** the same three actions render inline
on the /decisions `AttentionQueueRow`.
- **UI — responsive fix:** the stalled action row stacks to full-width
buttons at phone width and returns to a horizontal row at `sm` and up.
New 390px stories capture the phone layout.

## Verification

- `cd ui && npx vitest run src/components/IssueReviewPanel.test.tsx
src/components/AttentionQueueRow.test.tsx src/lib/attention.test.ts
src/api/issues.test.ts` — 91 tests pass.
- Server suites added and updated: `issue-review-attention`,
`issue-stalled-review-decision-routes`, `review-path-recovery`,
`recovery-observability`, and related route/liveness tests (run by CI).
- A designer reviewed the UI at 390px and desktop in light and dark
themes on both the issue-page panel and the /decisions card. The stalled
action row stacks cleanly at phone width with no overlap and keeps the
horizontal row on desktop.

## Risks

- **Migration:** adds migration `0200` (next after master `0199`, no
renumber). It extends the agent-wakeup-requests schema and is additive.
- **Behavioral shift:** `in_review -> todo` now dispatches a wake. This
is intended (resume intent) and covered by tests.
- **Authz:** the inline decision routes are permission-gated. Only a
permitted responder sees and can trigger the actions.
- Overall risk is moderate and contained to the review and attention
loop.

## Model Used

- Claude, Opus 4.8 (`claude-opus-4-8`), extended thinking, tool use and
code execution.

## 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>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 13:54:40 -05:00

631 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { classifyIssueGraphLiveness } from "../services/issue-liveness.ts";
const companyId = "company-1";
const managerId = "manager-1";
const coderId = "coder-1";
const blockerId = "blocker-1";
const blockedId = "blocked-1";
function issue(overrides: Record<string, unknown> = {}) {
return {
id: blockedId,
companyId,
identifier: "PAP-1703",
title: "Parent work",
status: "blocked",
assigneeAgentId: coderId,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionState: null,
...overrides,
};
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: coderId,
companyId,
name: "Coder",
role: "engineer",
title: null,
status: "idle",
reportsTo: managerId,
...overrides,
};
}
const manager = agent({
id: managerId,
name: "CTO",
role: "cto",
reportsTo: null,
});
const blocks = [{ companyId, blockerIssueId: blockerId, blockedIssueId: blockedId }];
describe("issue graph liveness classifier", () => {
it("detects a PAP-1703-style blocked chain with an unassigned blocker and stable incident key", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Missing unblock work",
status: "todo",
assigneeAgentId: null,
}),
],
relations: blocks,
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
identifier: "PAP-1703",
state: "blocked_by_unassigned_issue",
recoveryIssueId: blockerId,
recommendedOwnerAgentId: managerId,
dependencyPath: [
expect.objectContaining({ issueId: blockedId }),
expect.objectContaining({ issueId: blockerId }),
],
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_unassigned_issue:${blockerId}`,
});
});
it("does not use free-form executive role or name matching for recovery ownership", () => {
const rootAgentId = "root-agent";
const spoofedExecutiveId = "spoofed-executive";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
assigneeAgentId: null,
createdByAgentId: null,
}),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Missing unblock work",
status: "todo",
assigneeAgentId: null,
createdByAgentId: null,
}),
],
relations: blocks,
agents: [
agent({
id: spoofedExecutiveId,
name: "Chief Executive Recovery",
role: "cto",
title: "CEO",
reportsTo: rootAgentId,
}),
agent({
id: rootAgentId,
name: "Root Operator",
role: "operator",
title: null,
reportsTo: null,
}),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]?.recommendedOwnerAgentId).toBe(rootAgentId);
expect(findings[0]?.recommendedOwnerCandidates[0]).toMatchObject({
agentId: rootAgentId,
reason: "root_agent",
sourceIssueId: blockerId,
});
expect(findings[0]?.recommendedOwnerCandidateAgentIds).toEqual([
rootAgentId,
spoofedExecutiveId,
]);
});
it("does not flag a live blocked chain with an active assignee and wake path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Live unblock work",
status: "todo",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
queuedWakeRequests: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "queued" }],
});
expect(findings).toEqual([]);
});
it("detects an assigned backlog blocker leaf with no action path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Parked assigned unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
identifier: "PAP-1703",
state: "blocked_by_assigned_backlog_issue",
recoveryIssueId: blockerId,
recommendedOwnerAgentId: "blocker-agent",
dependencyPath: [
expect.objectContaining({ issueId: blockedId }),
expect.objectContaining({ issueId: blockerId, status: "backlog" }),
],
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_assigned_backlog_issue:${blockerId}`,
});
});
it("does not flag an assigned backlog blocker that has an explicit waiting path", () => {
const backlogBlocker = issue({
id: blockerId,
identifier: "PAP-1704",
title: "Explicitly parked unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
});
const baseInput = {
issues: [issue(), backlogBlocker],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
};
expect(classifyIssueGraphLiveness({
...baseInput,
issues: [issue(), { ...backlogBlocker, assigneeAgentId: null, assigneeUserId: "board-user-1" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
activeRuns: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "running" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
openRecoveryIssues: [{ companyId, issueId: blockerId, status: "todo" }],
})).toEqual([]);
});
it("does not flag an unassigned blocker that already has an active execution path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Unassigned but already running",
status: "todo",
assigneeAgentId: null,
}),
],
relations: blocks,
agents: [agent(), manager],
activeRuns: [{ companyId, issueId: blockerId, agentId: coderId, status: "running" }],
});
expect(findings).toEqual([]);
});
it("detects cancelled blockers and uninvokable blocker assignees deterministically", () => {
const cancelled = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Cancelled unblock work",
status: "cancelled",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })],
});
expect(cancelled[0]?.state).toBe("blocked_by_cancelled_issue");
const paused = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Paused unblock work",
status: "todo",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })],
});
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
});
it("detects a cancelled blocker on an assigned todo source", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({ status: "todo" }),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Cancelled unblock work",
status: "cancelled",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Cancelled owner" })],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
state: "blocked_by_cancelled_issue",
recoveryIssueId: blockerId,
});
});
it("prefers the blocker finding for an in-review source with a cancelled blocker", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({ status: "in_review" }),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Cancelled unblock work",
status: "cancelled",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Cancelled owner" })],
});
expect(findings).toHaveLength(1);
expect(findings[0]?.state).toBe("blocked_by_cancelled_issue");
});
it("detects blocker assignees under terminated org ancestors as uninvokable", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Invalid tree unblock work",
status: "todo",
assigneeAgentId: "qa-2",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" }),
agent({ id: "cto-2", name: "CTO 2", status: "terminated", reportsTo: "ceo-2" }),
agent({ id: "ceo-2", name: "CEO 2", status: "terminated", reportsTo: null }),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "blocked_by_uninvokable_assignee",
reason: "PAP-1703 is blocked by PAP-1704, but its assignee is in an invalid org chain.",
recommendedOwnerAgentId: managerId,
});
});
it("detects invalid in_review execution participant", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue({
status: "in_review",
executionState: {
status: "pending",
currentStageId: "stage-1",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "missing-agent" },
returnAssignee: { type: "agent", agentId: coderId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
}),
],
relations: [],
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "invalid_review_participant",
incidentKey: `harness_liveness:${companyId}:${blockedId}:invalid_review_participant:missing-agent`,
});
});
it("detects the PAP-2239-style blocked chain at the first stalled in_review leaf without duplicate findings", () => {
const phaseIssueId = "phase-issue-1";
const reviewLeafId = "review-leaf-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: "pap-2239",
identifier: "PAP-2239",
title: "External object reference project",
status: "blocked",
}),
issue({
id: phaseIssueId,
identifier: "PAP-2276",
title: "UX acceptance review phase",
status: "blocked",
assigneeAgentId: coderId,
}),
issue({
id: reviewLeafId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [
{ companyId, blockerIssueId: phaseIssueId, blockedIssueId: "pap-2239" },
{ companyId, blockerIssueId: reviewLeafId, blockedIssueId: phaseIssueId },
],
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: "pap-2239",
identifier: "PAP-2239",
state: "in_review_without_action_path",
recoveryIssueId: reviewLeafId,
recommendedOwnerAgentId: coderId,
dependencyPath: [
expect.objectContaining({ issueId: "pap-2239" }),
expect.objectContaining({ issueId: phaseIssueId }),
expect.objectContaining({ issueId: reviewLeafId }),
],
incidentKey: `harness_liveness:${companyId}:pap-2239:in_review_without_action_path:${reviewLeafId}`,
});
});
it("skips paused stalled review assignees when choosing recovery owner candidates", () => {
const reviewIssueId = "review-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [],
agents: [agent({ status: "paused" }), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "in_review_without_action_path",
recommendedOwnerAgentId: managerId,
});
expect(findings[0]?.recommendedOwnerCandidates).toEqual([
{
agentId: managerId,
reason: "assignee_reporting_chain",
sourceIssueId: reviewIssueId,
},
]);
});
it("does not flag healthy in_review issues with an explicit action path", () => {
const reviewIssueId = "review-1";
const baseReviewIssue = issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
});
const cases = [
{
name: "typed agent participant",
issue: {
...baseReviewIssue,
executionState: {
status: "pending",
currentParticipant: { type: "agent", agentId: coderId },
},
},
},
{
name: "typed user participant",
issue: {
...baseReviewIssue,
executionState: {
status: "pending",
currentParticipant: { type: "user", userId: "board-user-1" },
},
},
},
{
name: "user owner",
issue: { ...baseReviewIssue, assigneeAgentId: null, assigneeUserId: "board-user-1" },
},
{
name: "active run",
issue: baseReviewIssue,
activeRuns: [{ companyId, issueId: reviewIssueId, agentId: coderId, status: "running" }],
},
{
name: "queued wake",
issue: baseReviewIssue,
queuedWakeRequests: [{ companyId, issueId: reviewIssueId, agentId: coderId, status: "queued" }],
},
{
name: "pending interaction",
issue: baseReviewIssue,
pendingInteractions: [{ companyId, issueId: reviewIssueId, status: "pending" }],
},
{
name: "pending approval",
issue: baseReviewIssue,
pendingApprovals: [{ companyId, issueId: reviewIssueId, status: "pending" }],
},
{
name: "open recovery issue",
issue: baseReviewIssue,
openRecoveryIssues: [{ companyId, issueId: reviewIssueId, status: "todo" }],
},
];
for (const testCase of cases) {
const findings = classifyIssueGraphLiveness({
issues: [testCase.issue],
relations: [],
agents: [agent(), manager],
activeRuns: testCase.activeRuns,
queuedWakeRequests: testCase.queuedWakeRequests,
pendingInteractions: testCase.pendingInteractions,
pendingApprovals: testCase.pendingApprovals,
openRecoveryIssues: testCase.openRecoveryIssues,
});
expect(findings, testCase.name).toEqual([]);
}
});
it("does not treat a participant retained after changes are requested as an active review path", () => {
const reviewIssueId = "review-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: {
status: "changes_requested",
currentParticipant: { type: "agent", agentId: coderId },
},
}),
],
relations: [],
agents: [agent(), manager],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: reviewIssueId,
state: "in_review_without_action_path",
});
});
it("still flags a stalled in_review issue when its blocker has an active run", () => {
const reviewIssueId = "review-1";
const activeBlockerId = "active-blocker-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
issue({
id: activeBlockerId,
identifier: "PAP-2280",
title: "Active blocker",
status: "in_progress",
assigneeAgentId: coderId,
}),
],
relations: [{ companyId, blockerIssueId: activeBlockerId, blockedIssueId: reviewIssueId }],
agents: [agent(), manager],
activeRuns: [{ companyId, issueId: activeBlockerId, agentId: coderId, status: "running" }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: reviewIssueId,
state: "in_review_without_action_path",
recoveryIssueId: reviewIssueId,
});
});
it("ignores cross-company waiting paths for stalled in_review issues", () => {
const reviewIssueId = "review-1";
const findings = classifyIssueGraphLiveness({
issues: [
issue({
id: reviewIssueId,
identifier: "PAP-2279",
title: "Screenshot acceptance review",
status: "in_review",
assigneeAgentId: coderId,
executionState: null,
}),
],
relations: [],
agents: [agent(), manager],
pendingInteractions: [{ companyId: "other-company", issueId: reviewIssueId, status: "pending" }],
openRecoveryIssues: [{ companyId: "other-company", issueId: reviewIssueId, status: "todo" }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "in_review_without_action_path",
recoveryIssueId: reviewIssueId,
});
});
});