mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Recovery schedules bounded retries after a provider disconnects. > - A stopped run can still hold its environment lease while cleanup runs. > - Retrying before that lease is released cancels the new run before it starts and spends another retry. > - Restoring the task can also send the worker repair instructions from an already resolved recovery action. > - This pull request preserves the waiting retry and removes settled recovery instructions from later wakes. > - The task can continue after cleanup without an operator repairing the same incident again. ## Linked Issues or Issue Description **What happened?** A legacy conversation run disconnected while it was doing ordinary work. Its environment cleanup took longer than the retry delay. Two retries were cancelled before dispatch with `execution_reconciliation_required`. Those cancellations exhausted the failure budget. After an operator restored the task, the wake still told the original worker to repair the runtime and hand the task back to itself. **Expected behavior** Cleanup waits preserve the pending attempt. The same retry can continue after ownership is released, subject to all current gates. Once a recovery action is resolved or cancelled, subsequent task wakes omit its repair instructions. **Steps to reproduce** 1. Fail a legacy conversation run while its environment lease remains in `pending_cleanup`. 2. Schedule a bounded retry and run promotion before cleanup releases that lease. 3. Repeat the scheduler sweep. Before this fix, retries promote and then cancel without starting. 4. Resolve a stranded-task recovery action and build the restored task wake with that action ID. Before this fix, the wake still includes the settled repair instructions. **Paperclip version or commit** Reproduced with database regressions against `ceabc3bc880` on master. Related: #15019 restores a skipped assignment handoff after lease release. #15235 filters stale handoff evidence in the recovery sweep. This change preserves an existing scheduled conversation retry and corrects restored wake content. It does not create a new handoff wake. ## What Changed - Keep an unstarted legacy conversation retry on the same durable row while prior execution ownership remains active. Recheck after 30 seconds without increasing retry accounting. - Return a queued retry to scheduled state if it encounters that hold at the claim gate. Retain its issue claim and publish the status change. - Record one local lifecycle diagnostic per blocking run. Remove that wait marker on promotion. - Include recovery action metadata only while the referenced action is active or escalated. - Return an explicit `waiting` response and the saved schedule when Retry now meets cleanup. Show the wait inline without a false success or disabled button. - Add database, rendered-prompt, route, and UI regressions. Document the execution and run-log contracts. ## Verification - Five cleanup and restored-wake regressions fail against the original production code. The Retry now route and UI regressions also fail before their correction. - Related retry, dispatch, stale-queue, and recovery suites: 321 tests pass across seven files. All ten focused cleanup/restored-wake cases pass after rebase. The final dispatch adjustment passes all 46 adapter tests. - Retry now routes and affected UI suites: all 46 tests pass. The tests cover repeated clicks, the saved schedule, unchanged accounting, promotion after release, and no false success or error state. - `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, and `pnpm check:token-gates` pass. - Full local `pnpm test:run` was started and then stopped after the final commit passed all GitHub CI test shards. No complete local full-suite result is claimed; CI supplies the complete test result for the final commit. - Final head `6f1058a332c039e33c4f002b296d20a5554e760e`: all 55 GitHub checks are green or intentionally skipped. Apex review is 5/5 after two reviews, with no unresolved threads. The PR has no merge conflicts. ## Risks The wait applies only to unstarted legacy conversation retries. Native runs and non-conversation execution keep their existing recovery rules. Cleanup must actually release ownership before execution can resume. The wait does not fix a cleanup service that never finishes. Promotion and dispatch still enforce cancellation, reassignment, pause, budget, and reconciliation gates. No schema change is required. The Retry now response adds a `waiting` outcome; the shared contract and all three UI controls handle it. ## Model Used OpenAI Codex based on GPT-6, with repository analysis, tool use, and local code execution. The runtime does not expose the precise serving model ID or context window. ## 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 (focused suites; complete final-head test coverage in CI) - [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>
106 lines
3.4 KiB
TypeScript
106 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",
|
|
waiting: "Retry waiting",
|
|
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;
|
|
};
|
|
}
|