forked from farhoodlabs/paperclip
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`.   ## 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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user