Files
PaperClipAI/ui/src/components/IssueBlockedNotice.test.tsx
T
cb0009b097 fix: preserve recovery retries across restarts (#11817)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The control plane must keep each active issue on a clear execution
or recovery path.
> - A missing issue disposition can require more than one bounded repair
attempt.
> - A server restart could lose that repair path or move source
ownership to the recovery owner.
> - A parked or expired retry could also make the user interface show a
false healthy state.
> - Concurrent recovery loops must not schedule the same repair attempt
twice.
> - This pull request keeps retry state durable, makes scheduling
atomic, and keeps source ownership stable.
> - The benefit is that recovery continues after a restart and operators
see the correct state.

## Linked Issues or Issue Description

**What happened?**

A run that ended without a valid issue disposition could lose its repair
path after a server restart. Manager recovery could also change the
source owner. In addition, a parked or expired retry could make the
issue look healthy when no active work existed. Concurrent
reconciliation could also schedule the same repair attempt twice.

**Expected behavior**

Paperclip must keep bounded source and manager repair attempts across
restarts. Recovery ownership must stay separate from source issue
ownership. The server and user interface must report only a live retry
as active work. Each repair attempt must be scheduled at most once per
company.

**Steps to reproduce**

1. Start an agent run on an issue.
2. End the run without a valid issue disposition.
3. Let the first repair attempt schedule a retry.
4. Restart the server, let the retry time pass without a live run, or
start two reconciliation loops together.
5. Observe that the repair path can stop, the issue can show a false
healthy state, or duplicate retries can be created.

**Paperclip version or commit**

The problem existed on `master` before candidate head
`d8e620fe86bade7df18decac332007f5821ae04f`.

**Deployment mode**

The problem affects self-hosted servers and local builds that use
automatic recovery.

## What Changed

- Persist bounded source-owner and manager repair lineages with stable
fingerprints and retry limits.
- Resume incomplete disposition repairs after a server restart.
- Keep recovery ownership separate from source issue ownership and
enforce source mutation authority.
- Project live retry evidence into issue and blocker summaries.
- Show recovery owner, return owner, attempt count, and retry state in
the board user interface.
- Treat expired or parked retries as attention states unless a queued or
running attempt exists.
- Atomically deduplicate disposition-repair wake requests with a
company-scoped partial unique index.
- Reuse the winning run when concurrent reconciliation loses the
uniqueness race, without duplicate scheduling activity.
- Honor disabled on-demand wake policy before recovery scheduling and
again before delayed retry promotion.
- Keep the new index migration safe for lagging seeded databases that
already contain the index.
- Add server and user interface tests for recovery, restart, ownership,
retry, concurrency, and blocker states.
- Update the implementation and execution semantics documents.

## Verification

- Focused server recovery and ownership suites: 282 tests passed on the
repaired base candidate.
- Focused user interface recovery suites: 128 tests passed on the
repaired base candidate.
- Atomic-deduplication schema and recovery suites: 111 tests passed on
the first Greptile repair.
- Recovery and scheduled-retry wake-policy suites: 126 tests passed at
`d8e620fe86bade7df18decac332007f5821ae04f`.
- The exact lagging-source migration-order test passed after the index
migration became idempotent: 1 test passed and 62 unrelated tests were
skipped.
- `@paperclipai/db` and `@paperclipai/server` typechecks passed at the
current head.
- Migration generation and migration safety checks passed for migration
`0226_tan_colossus.sql`.
- `pnpm check:token-gates` passed on the repaired base candidate.
- `pnpm -r typecheck` passed on the repaired base candidate.
- `pnpm build` passed on the repaired base candidate.
- `pnpm test:run` passed 4,540 tests on the repaired base candidate.
Four fixed-port cases met listeners that already existed on the host.
- The two unchanged fixed-port files passed in an isolated network
namespace: 129 tests passed and 27 tests were skipped.
- Independent Security and QA reviews approved
`63c0423aab54c66f2293a20b0fb3f3b013ee3ba8`; exact-head re-review is
required after automated checks settle on
`d8e620fe86bade7df18decac332007f5821ae04f`.

## Risks

- Recovery orchestration affects issue liveness and ownership. The new
paths use bounded attempts, stable fingerprints, row locks, authority
checks, and database uniqueness.
- A conservative attention state can show more warnings when a scheduled
retry has no queued or running attempt. It does not hide stopped work.
- Migration `0226_tan_colossus.sql` creates a partial unique index on a
known-large table. Migrations run transactionally, so `CONCURRENTLY` is
unavailable. The matching disposition-repair key namespace is introduced
by this release, so deployed databases have no matching rows before the
index is added.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex from the GPT-5 model family used agentic reasoning, tool
use, and code execution. The runtime did not expose the exact model ID
or context window.
- Anthropic Claude Opus 5 used a 1M context window, tool use, and code
execution for part of the user interface repair, as recorded in the
commit history.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:09:42 -05:00

913 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type {
IssueRecoveryAction,
IssueRetryNowOutcome,
IssueScheduledRetry,
} from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { deriveRecoveryCardState } from "./IssueRecoveryActionCard";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
retryScheduledRetryNow: retryNowMock,
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
const maybePromise = result as unknown as PromiseLike<unknown>;
if (result && typeof maybePromise.then === "function") {
throw new TypeError("This test act shim only supports synchronous callbacks.");
}
return result as T;
}
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
const baseRetry: IssueScheduledRetry = {
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "agent-1",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: "2026-04-19T20:00:00.000Z",
scheduledRetryAttempt: 1,
scheduledRetryReason: "max_turns_continuation",
retryExhaustedReason: null,
error: null,
errorCode: null,
};
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
return {
outcome,
message:
outcome === "promoted"
? "Promoted scheduled retry"
: outcome === "already_promoted"
? "Scheduled retry already promoted"
: outcome === "no_scheduled_retry"
? "No scheduled retry"
: "Promotion suppressed by gate",
scheduledRetry:
outcome === "promoted" || outcome === "already_promoted"
? { ...baseRetry, status: "queued" as const }
: null,
};
}
beforeEach(() => {
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
retryNowMock.mockReset();
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
dateNowSpy?.mockRestore();
dateNowSpy = null;
});
function withProviders(node: ReactNode) {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
return (
<MemoryRouter>
<QueryClientProvider client={client}>
<ToastProvider>{node}</ToastProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(withProviders(element)));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
expect(node.textContent).toContain("This task still needs a next step.");
expect(node.textContent).toContain(
"A run finished successfully, but the task is still open. Paperclip needs someone to choose what happens next.",
);
expect(node.textContent).toContain("Mark it done or cancelled.");
expect(node.textContent).toContain("Send it for review or ask for input.");
expect(node.textContent).toContain("Record what is blocking it and who owns that blocker.");
expect(node.textContent).toContain("Delegate follow-up work or queue a continuation.");
expect(node.textContent).toContain("Asked CodexCoder to choose the next step");
expect(node.textContent).toContain("Detected progress: Updated the plan and left follow-up work.");
expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).toBeNull();
// No live continuation ⇒ the alarm stays exactly as it was; the calm
// in-flight line must not appear alongside it.
expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull();
});
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
expect(button).not.toBeNull();
expect(node.textContent).toContain("Retry now starts that follow-up immediately.");
act(() => {
button!.click();
});
await vi.waitFor(() => {
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
expect(button!.disabled).toBe(true);
});
});
it("replaces the alarm with the calm in-flight line while a live continuation is running the issue", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
liveRunId: "87654321-dddd-eeee-ffff-123456789abc",
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
// The amber alarm and every remediation bullet are gone...
expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull();
expect(node.textContent).not.toContain("This task still needs a next step.");
expect(node.textContent).not.toContain("Mark it done or cancelled.");
expect(node.querySelector(".bg-amber-50\\/90")).toBeNull();
// ...replaced by one quiet line that links the live run.
const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]');
expect(calm).not.toBeNull();
expect(calm!.getAttribute("data-successful-run-handoff")).toBe("in_flight");
expect(node.textContent).toContain(
"A correction run is in progress — the agent is working. This alert returns if the run stops without choosing a next step.",
);
const runLink = calm!.querySelector("a");
expect(runLink?.getAttribute("href")).toBe(
"/agents/agent-1/runs/87654321-dddd-eeee-ffff-123456789abc",
);
expect(runLink?.textContent).toBe("run 87654321");
});
it("shows the calm in-flight line when the live-run set includes this issue", () => {
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
liveIssueIds={new Set(["issue-1"])}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull();
const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]');
expect(calm).not.toBeNull();
// No `liveRunId` on the payload — the copy stands alone, with no run link.
expect(calm!.querySelector("a")).toBeNull();
expect(node.textContent).toContain("A correction run is in progress");
});
it("omits the run link but keeps the calm copy when the live run has no known agent", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
liveRunId: "87654321-dddd-eeee-ffff-123456789abc",
sourceRunId: null,
correctiveRunId: null,
assigneeAgentId: null,
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]');
expect(calm).not.toBeNull();
expect(calm!.querySelector("a")).toBeNull();
expect(calm!.textContent).toContain("run 87654321");
});
it("stays silent when the handoff is not required at all", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
successfulRunHandoff={{
state: "resolved",
required: false,
hasLiveContinuation: true,
liveRunId: "87654321-dddd-eeee-ffff-123456789abc",
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull();
expect(node.textContent).toBe("");
});
it("keeps the next-step notice and retry-now when the only continuation is an unpromoted scheduled retry", () => {
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).not.toBeNull();
// The carve-out wins over the calm line: the alarm is the only thing that
// keeps "Retry now" reachable, so it must not be quieted or duplicated.
expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull();
expect(node.textContent).toContain("This task still needs a next step.");
});
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
<IssueBlockedNotice
issueStatus="done"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toBe("");
});
it("does not render when the issue is cancelled even if blockers remain", () => {
const node = render(
<IssueBlockedNotice
issueStatus="cancelled"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toBe("");
});
it("keeps the amber notice when a covered chain has no confirmed live blocker", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["unrelated-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-1",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull();
// Rule C: a `blocked` issue with an unresolved blocker suppresses
// comment-driven reopening.
expect(node.querySelector('[data-blocker-attention-state="covered"]')).not.toBeNull();
expect(node.textContent).toContain("A message won’t restart this task yet");
});
it("sorts same-status live-work steps with numeric identifier ordering", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["blocker-11"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 3,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-11",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-10",
identifier: "TASK-10",
title: "Tenth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-9",
identifier: "TASK-9",
title: "Ninth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toContain("Waiting on live work");
expect(node.textContent).toContain(
"This task resumes automatically when the chain is done.",
);
const stepLinks = Array.from(
node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a'),
).map((link) => link.textContent ?? "");
expect(stepLinks[0]).toContain("TASK-9");
expect(stepLinks[1]).toContain("TASK-10");
expect(stepLinks[2]).toContain("TASK-11");
const runningStep = node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a')[2];
if (!runningStep) throw new Error("Expected a running live-work step.");
expect(runningStep.querySelector('svg[aria-label="In Progress status"]')).not.toBeNull();
expect(node.querySelector('[data-testid="issue-blocked-notice-now-running"]')).toBeNull();
});
it("explains a human message won't reopen a blocked issue and names the unresolved leaf (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
agentName="CodexCoder"
blockers={[
{
id: "blocker-1",
identifier: "PAP-500",
title: "Server work in flight",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toContain("A message won’t restart this task yet");
expect(node.textContent).toContain("Comments still notify CodexCoder for questions or triage");
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("Still blocked by");
expect(suppressed!.textContent).toContain("PAP-500");
expect(suppressed!.textContent).toContain("(in progress)");
});
it("names the deepest unresolved terminal leaf, not the direct blocker (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-600",
title: "Waiting in review",
status: "in_review",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-1",
identifier: "PAP-777",
title: "Actual work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
],
},
]}
/>,
);
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("PAP-777");
expect(suppressed!.textContent).not.toContain("PAP-600");
});
it("names one leaf blocker when several keep a comment from reopening (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-501",
title: "First",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-2",
identifier: "PAP-502",
title: "Second",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("PAP-501");
expect(suppressed!.textContent).toContain("and 1 other task");
expect(suppressed!.textContent).not.toContain("PAP-502");
});
it("does not suppress reopening when a blocked issue has no unresolved blockers (Rule B path)", () => {
const node = render(<IssueBlockedNotice issueStatus="blocked" blockers={[]} />);
expect(node.textContent).not.toBe("");
expect(node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]')).toBeNull();
});
it("shows external now-running blockers beneath the label on a separate line", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["terminal-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-99",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Queued dependency",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-live",
identifier: "TASK-99",
title: "External running task",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
],
},
]}
allBlockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Queued dependency",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
const nowRunning = node.querySelector('[data-testid="issue-blocked-notice-now-running"]');
expect(nowRunning).not.toBeNull();
expect(nowRunning!.children[0]?.textContent?.trim()).toBe("Now running");
expect(nowRunning!.children[1]?.querySelector("a")?.textContent).toContain("TASK-99");
const stepText = node.querySelector('[data-testid="issue-blocked-notice-steps"]')?.textContent;
expect(stepText).not.toContain("TASK-99");
});
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-1",
companyId: "co-1",
sourceIssueId: "blocker-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_state",
fingerprint: "fp-1",
evidence: {},
nextAction: "choose disposition",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.getAttribute("data-recovery-kind")).toBe("missing_disposition");
expect(indicator?.textContent).toContain("Recovery needed");
});
it("labels a workspace_validation blocker recovery distinctly", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-2",
identifier: "PAP-409",
title: "Workspace cwd lost git context",
status: "blocked",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-2",
companyId: "co-1",
sourceIssueId: "blocker-2",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: "fp-2",
evidence: {
latestRunErrorCode: "workspace_validation_failed",
},
nextAction:
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.getAttribute("data-recovery-kind")).toBe("workspace_validation");
expect(indicator?.textContent).toContain("Workspace recovery needed");
});
describe("owner-sticky retry lineage", () => {
function buildDispositionRepairAction(
wakePolicy: Record<string, unknown>,
overrides: Partial<IssueRecoveryAction> = {},
): IssueRecoveryAction {
return {
id: "rec-3",
companyId: "co-1",
sourceIssueId: "blocker-3",
recoveryIssueId: null,
kind: "deliberate_wait_without_target",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-owner",
ownerUserId: null,
previousOwnerAgentId: "agent-owner",
returnOwnerAgentId: "agent-owner",
cause: "deliberate_wait_without_target",
fingerprint: "fp-3",
evidence: {},
nextAction: "Record a durable disposition.",
wakePolicy,
monitorPolicy: null,
attemptCount: 2,
maxAttempts: 5,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-04-18T19:00:00.000Z",
updatedAt: "2026-04-18T19:00:00.000Z",
...overrides,
};
}
function renderBlockerChip(
action: IssueRecoveryAction,
scheduledRetry?: IssueScheduledRetry | null,
) {
return render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-3",
identifier: "PAP-777",
title: "Waiting on nothing",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: action,
scheduledRetry,
},
]}
/>,
).querySelector('[data-testid="issue-blocked-notice-recovery-indicator"]');
}
it("reports the same liveness state as the source task's recovery card", () => {
const liveAction = buildDispositionRepairAction({
type: "bounded_owner_disposition_repair",
retryAgentId: "agent-owner",
attempt: 2,
maxAttempts: 5,
// SYSTEM_NOW is 2026-04-18T20:00:00Z; three minutes out.
retryAt: "2026-04-18T20:03:00.000Z",
scheduledRunId: "run-b1",
});
const chip = renderBlockerChip(liveAction);
expect(chip?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(chip?.getAttribute("data-recovery-lane")).toBe("source_owner");
expect(chip?.textContent).toContain("Recovery in progress · 2/5");
expect(chip?.getAttribute("title")).toContain("Attempt 2 of 5 · next try in 3m");
// The card the blocker links to must not contradict the chip.
const cardState = deriveRecoveryCardState(liveAction);
expect(cardState).toBe(chip?.getAttribute("data-recovery-state"));
});
it("keeps the blocker chip in progress while the named retry run is live", () => {
const liveAction = buildDispositionRepairAction({
type: "bounded_owner_disposition_repair",
retryAgentId: "agent-owner",
attempt: 3,
maxAttempts: 5,
retryAt: "2026-04-18T19:58:00.000Z",
scheduledRunId: "run-b2",
});
const scheduledRetry: IssueScheduledRetry = {
...baseRetry,
runId: "run-b2",
status: "running",
scheduledRetryAt: "2026-04-18T19:58:00.000Z",
scheduledRetryAttempt: 3,
scheduledRetryReason: "issue_disposition_repair",
};
const chip = renderBlockerChip(liveAction, scheduledRetry);
expect(chip?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(chip?.textContent).toContain("Recovery in progress · 3/5");
expect(chip?.getAttribute("title")).toContain("Attempt 3 of 5 · attempt running now");
expect(deriveRecoveryCardState(liveAction, { scheduledRetry })).toBe("in_progress");
});
it("escalates the blocker chip in step with the card when retries are exhausted", () => {
const exhaustedAction = buildDispositionRepairAction(
{
type: "bounded_owner_disposition_repair",
retryAgentId: "agent-owner",
attempt: 5,
maxAttempts: 5,
retryAt: "2026-04-18T19:59:00.000Z",
},
{ attemptCount: 5 },
);
const chip = renderBlockerChip(exhaustedAction);
expect(chip?.getAttribute("data-recovery-state")).toBe("needed");
expect(chip?.textContent).toContain("Recovery needed");
expect(deriveRecoveryCardState(exhaustedAction)).toBe("needed");
});
});
});