mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## 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>
631 lines
19 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|