Add issue controls and retry-now recovery (#5426)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Issue operators need clear controls for execution settings, model
overrides, and recovery retries
> - Existing issue properties hid useful adapter override state and did
not expose a board-triggered retry for scheduled heartbeat recovery
> - Scheduled retries also need to respect the same safety gates as
normal execution instead of bypassing budget, review, pause, dependency,
or terminal-state checks
> - This pull request adds the issue property controls and retry-now
surfaces together because they share the issue details/properties UI
> - The benefit is that operators can inspect and adjust issue execution
settings and safely trigger pending scheduled recovery without hidden
control-plane behavior

## What Changed

- Adds editable issue assignee model override controls in
`IssueProperties`, with focused coverage.
- Removes the stale workspace tasks link from issue properties.
- Adds a scheduled retry `retry-now` backend path and shared response
types.
- Adds main-pane and properties-pane scheduled retry UI, backed by a
shared `useRetryNowMutation` hook.
- Adds suppression coverage for budget hard stops, review participant
changes, subtree pause holds, unresolved blockers, terminal issues, and
company scoping.
- Updates the `IssueProperties` test harness with toast actions required
by the retry-now hook.

## Verification

- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx
ui/src/components/IssueScheduledRetryCard.test.tsx` — 31 passed.
- `pnpm exec vitest run
server/src/__tests__/issue-scheduled-retry-routes.test.ts` — exited 0,
but this host skipped the embedded Postgres route tests with: `Postgres
init script exited with code null. Please check the logs for extra info.
The data directory might already exist.`
- Pairwise merge check against the assigned-backlog PR branch completed
without conflicts via `git merge --no-commit --no-ff` in a temporary
worktree.

### Visual verification screenshots

Storybook story: `Product/Issue Scheduled retry surfaces /
ScheduledRetrySurfaces`.

![Scheduled retry card and issue properties rows -
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/62fb566f357312b43b9162af02252d0175530a8f/docs/assets/pr-5426/scheduled-retry-story-desktop.png)

![Scheduled retry card and issue properties rows -
mobile](https://raw.githubusercontent.com/paperclipai/paperclip/62fb566f357312b43b9162af02252d0175530a8f/docs/assets/pr-5426/scheduled-retry-story-mobile.png)

## Risks

- Medium: this touches issue execution/retry behavior, so CI should run
the embedded Postgres route tests on a host that can initialize
Postgres.
- Low-to-medium UI risk around duplicated retry-now entry points; both
surfaces share one mutation hook to keep behavior consistent.

> 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 coding agent, GPT-5 model family (`gpt-5`), tool-enabled
Paperclip heartbeat environment. Context window and internal reasoning
mode are not exposed by the runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-07 12:23:13 -05:00
committed by GitHub
parent d0e9cc76f2
commit 772fc92619
18 changed files with 2269 additions and 117 deletions
+134 -14
View File
@@ -18,6 +18,8 @@ import { IssueProperties } from "./IssueProperties";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
adapterModels: vi.fn(),
adapterModelProfiles: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
@@ -34,10 +36,6 @@ const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@@ -60,8 +58,8 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: vi.fn() }),
}));
vi.mock("../hooks/useProjectOrder", () => ({
@@ -353,6 +351,8 @@ describe("IssueProperties", () => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
@@ -362,7 +362,6 @@ describe("IssueProperties", () => {
color: "#6366f1",
}));
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
});
afterEach(() => {
@@ -578,9 +577,8 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => {
it("shows only the workspace detail link for non-default workspaces", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
@@ -596,14 +594,10 @@ describe("IssueProperties", () => {
await flush();
await flush();
const tasksLink = Array.from(container.querySelectorAll("a")).find(
(link) => link.textContent?.includes("View workspace tasks"),
);
const workspaceLink = Array.from(container.querySelectorAll("a")).find(
(link) => link.textContent?.trim() === "View workspace",
);
expect(tasksLink).not.toBeUndefined();
expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues");
expect(container.textContent).not.toContain("View workspace tasks");
expect(workspaceLink).not.toBeUndefined();
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
@@ -806,6 +800,132 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("hides model options when the issue uses the assignee default", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: null,
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Model lane");
expect(container.textContent).not.toContain("Codex options");
act(() => root.unmount());
});
it("edits existing custom assignee model options from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.5", label: "GPT-5.5" },
{ id: "gpt-5.4", label: "GPT-5.4" },
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
modelReasoningEffort: "high",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
await flush();
expect(container.textContent).toContain("Custom · gpt-5.4 · high");
expect(container.textContent).toContain("Model lane");
const modelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("GPT-5.5"));
expect(modelButton).not.toBeUndefined();
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.5",
modelReasoningEffort: "high",
},
},
});
act(() => root.unmount());
});
it("clears existing assignee adapter overrides from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
const clearButton = container.querySelector('button[aria-label="Clear adapter options"]');
expect(clearButton).not.toBeNull();
await act(async () => {
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null });
act(() => root.unmount());
});
it("shows a checkmark on selected labels in the picker", async () => {
mockIssuesApi.listLabels.mockResolvedValue([
createLabel(),