forked from farhoodlabs/paperclip
772fc92619
## 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>
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
|
import type { IssueRetryNowOutcome, IssueRetryNowResponse } from "@paperclipai/shared";
|
|
import { ApiError } from "../api/client";
|
|
import { issuesApi } from "../api/issues";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
export type RetryNowError = {
|
|
message: string;
|
|
outcomeMessage: string | null;
|
|
status: number | null;
|
|
};
|
|
|
|
function readErrorMessage(error: unknown): string {
|
|
if (error instanceof ApiError) {
|
|
if (typeof error.message === "string" && error.message.trim().length > 0) return error.message;
|
|
return `Request failed (${error.status})`;
|
|
}
|
|
if (error instanceof Error && error.message) return error.message;
|
|
return "The request failed. Try again in a moment.";
|
|
}
|
|
|
|
export const RETRY_NOW_OUTCOME_HEADLINE: Record<IssueRetryNowOutcome, string> = {
|
|
promoted: "Retry promoted",
|
|
already_promoted: "Retry already running",
|
|
no_scheduled_retry: "No scheduled retry",
|
|
gate_suppressed: "Couldn't retry now",
|
|
};
|
|
|
|
export function useRetryNowMutation(
|
|
issueId: string | null | undefined,
|
|
): UseMutationResult<IssueRetryNowResponse, unknown, void, unknown> & {
|
|
lastError: RetryNowError | null;
|
|
} {
|
|
const queryClient = useQueryClient();
|
|
const { pushToast } = useToastActions();
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () => {
|
|
if (!issueId) throw new Error("Missing issue id");
|
|
return issuesApi.retryScheduledRetryNow(issueId);
|
|
},
|
|
onSuccess: (response) => {
|
|
if (issueId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
|
|
}
|
|
if (response.outcome === "promoted") {
|
|
pushToast({
|
|
title: RETRY_NOW_OUTCOME_HEADLINE.promoted,
|
|
body: response.message,
|
|
tone: "success",
|
|
});
|
|
} else if (response.outcome === "gate_suppressed") {
|
|
pushToast({
|
|
title: RETRY_NOW_OUTCOME_HEADLINE.gate_suppressed,
|
|
body: response.message,
|
|
tone: "error",
|
|
});
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
pushToast({
|
|
title: "Couldn't retry now",
|
|
body: readErrorMessage(error),
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
const reset = mutation.reset;
|
|
const wrappedReset = useCallback(() => reset(), [reset]);
|
|
|
|
const lastError: RetryNowError | null = (() => {
|
|
if (mutation.error) {
|
|
const apiError = mutation.error instanceof ApiError ? mutation.error : null;
|
|
return {
|
|
message: readErrorMessage(mutation.error),
|
|
outcomeMessage: null,
|
|
status: apiError?.status ?? null,
|
|
};
|
|
}
|
|
if (mutation.data && mutation.data.outcome === "gate_suppressed") {
|
|
return {
|
|
message: mutation.data.message,
|
|
outcomeMessage: mutation.data.message,
|
|
status: null,
|
|
};
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
return {
|
|
...mutation,
|
|
reset: wrappedReset,
|
|
lastError,
|
|
} as UseMutationResult<IssueRetryNowResponse, unknown, void, unknown> & {
|
|
lastError: RetryNowError | null;
|
|
};
|
|
}
|