mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Legacy sandbox runs copy the agent's workspace files back to the host when the run settles. > - When this copy-back fails one time, the server keeps the sandbox and puts a board-owned repair hold on the task. > - The hold blocks new messages. Only an operator API runbook can clear it. The board shows that runbook text, so users do not know what to do. > - Many copy-back failures are transient: a dropped transfer, a provider error, or a merge lock that another restore holds. > - This pull request retries those failures automatically while the run still has its merge baseline and before any host file changes. > - The benefit is that most failures repair themselves, and the remaining holds explain the situation in plain language. ## Linked Issues or Issue Description Refs #14590 (related: no-replay recovery holds) **What happened?** A legacy sandbox run finished its work, but one workspace copy-back attempt failed. The task then showed "Recovery needed" with operator-only steps ("verify its exact stop receipt … record workspaceRepairEvidence"). Messages from the user stayed saved and were not delivered. **What did you expect?** Paperclip retries a transient copy-back failure automatically. If a repair is still necessary, the task says clearly what happened and who must act. **Steps to reproduce** 1. Run an agent in a legacy sandbox environment. 2. Make the workspace copy-back fail one time, for example with a dropped transfer or a held merge lock. 3. Open the task. It is blocked behind a repair hold, and new messages wait. **Additional context** Separately, distinct live events can render toasts with the same title and body (for example, two runs of the same agent that both succeed). The toast list showed both, and the desktop app forwards each one as a notification. ## What Changed - `packages/adapter-utils/src/sandbox-managed-runtime.ts`: retry the workspace copy-back up to three times in total, with a 2 s and then a 5 s backoff. A retry occurs only when the failure is `restore_failed` or `restore_lock_timeout` and no host workspace file was written. The merge now marks the first host write in `beforeApply`, which runs under the merge lock before any write. - Unsafe archives, permission failures, and failures after the merge started writing are never retried. Each retry uses a new temp directory and a new imported Git ref. - New optional `workspaceRestoreRetryDelaysMs` input, so tests can shorten the backoff. Nested repository restores receive the same value. - `ui/src/components/ExecutionBlockerNotice.tsx`: for a workspace repair hold, the notice now says what happened in plain language. The stored operator runbook text moves under "Technical details". - `ui/src/context/ToastContext.tsx`: also dedupe toasts on their visible tone, title, and body inside the existing 3.5 s window. - `doc/workspace-restore-locks.md`: document the automatic retry. ## Verification - `pnpm --filter @paperclipai/adapter-utils exec vitest run src/sandbox-managed-runtime.test.ts src/workspace-restore` (218 tests pass). New tests cover: - a retry after a dropped transfer, - a retry after a merge-lock timeout, - a stop after three attempts, - no retry for an unsafe archive, a permission failure, or a failure after the merge started writing. - The existing step-diagnostic test now uses failures that persist across retries. It still expects one diagnostic per failed step. - `pnpm --filter @paperclipai/ui exec vitest run src/context/ToastContext.test.tsx src/components/ExecutionBlockerNotice.test.tsx src/context/LiveUpdatesProvider` (120 tests pass). - `tsc --noEmit` for adapter-utils passes. `node scripts/check-token-gates.mjs` is clean. - Not fully run: the full adapter-utils suite has two failures in `github-launcher*.test.ts`. They read the managed GitHub credentials of the local sandbox environment and do not touch restore code. UI `tsc -b` reports five errors in `PluginOrganizationSwitcher.tsx`, which is not changed here, because the plugin SDK is not built in a fresh install. ## Risks - A copy-back that fails before the merge can take about 7 s longer before it reports a failure. Lock timeouts can take up to about 97 s in total (three 30 s lock waits plus backoff). Settlement already waits for the copy-back. - The retry logic depends on `beforeApply` running before the first host write in `mergeDirectoryWithBaseline`. That is true today, and a comment marks it. - A Git bundle import before the merge can leave unreferenced Git objects after a retry. They are harmless, and the existing ref cleanup still runs on each attempt. - Content-based toast dedupe can hide a second toast with identical text inside 3.5 s. That is the intended behavior. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`, Anthropic) in the Claude Agent SDK, with tool use (shell, file edit, code search) and a subagent for code exploration. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Paperclip Agent <agent@paperclip.ing> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
5.8 KiB
TypeScript
224 lines
5.8 KiB
TypeScript
import type { AgentAppearance } from "@paperclipai/shared";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
export type ToastTone = "info" | "success" | "warn" | "error";
|
|
|
|
export interface ToastAction {
|
|
label: string;
|
|
/** Navigate on click (mutually exclusive with `onClick`). */
|
|
href?: string;
|
|
/** Run a callback on click, e.g. an undo (mutually exclusive with `href`). */
|
|
onClick?: () => void;
|
|
}
|
|
|
|
export type ToastActor = {
|
|
type: "user" | "agent";
|
|
id: string;
|
|
name: string;
|
|
image?: string | null;
|
|
appearance?: AgentAppearance | null;
|
|
};
|
|
|
|
export interface ToastInput {
|
|
id?: string;
|
|
dedupeKey?: string;
|
|
title: string;
|
|
body?: string;
|
|
tone?: ToastTone;
|
|
ttlMs?: number;
|
|
action?: ToastAction;
|
|
actor?: ToastActor;
|
|
}
|
|
|
|
export interface ToastItem {
|
|
id: string;
|
|
title: string;
|
|
body?: string;
|
|
tone: ToastTone;
|
|
ttlMs: number;
|
|
action?: ToastAction;
|
|
actor?: ToastActor;
|
|
createdAt: number;
|
|
}
|
|
|
|
interface ToastActionsContextValue {
|
|
pushToast: (input: ToastInput) => string | null;
|
|
dismissToast: (id: string) => void;
|
|
clearToasts: () => void;
|
|
}
|
|
|
|
interface ToastContextValue extends ToastActionsContextValue {
|
|
toasts: ToastItem[];
|
|
}
|
|
|
|
const DEFAULT_TTL_BY_TONE: Record<ToastTone, number> = {
|
|
info: 4000,
|
|
success: 3500,
|
|
warn: 8000,
|
|
error: 10000,
|
|
};
|
|
const MIN_TTL_MS = 1500;
|
|
const MAX_TTL_MS = 15000;
|
|
const MAX_TOASTS = 5;
|
|
const DEDUPE_WINDOW_MS = 3500;
|
|
const DEDUPE_MAX_AGE_MS = 20000;
|
|
|
|
const ToastStateContext = createContext<ToastItem[] | null>(null);
|
|
const ToastActionsContext = createContext<ToastActionsContextValue | null>(null);
|
|
|
|
function normalizeTtl(value: number | undefined, tone: ToastTone) {
|
|
const fallback = DEFAULT_TTL_BY_TONE[tone];
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, Math.floor(value)));
|
|
}
|
|
|
|
function generateToastId() {
|
|
return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
const timersRef = useRef(new Map<string, number>());
|
|
const dedupeRef = useRef(new Map<string, number>());
|
|
|
|
const clearTimer = useCallback((id: string) => {
|
|
const handle = timersRef.current.get(id);
|
|
if (handle !== undefined) {
|
|
window.clearTimeout(handle);
|
|
timersRef.current.delete(id);
|
|
}
|
|
}, []);
|
|
|
|
const dismissToast = useCallback(
|
|
(id: string) => {
|
|
clearTimer(id);
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
},
|
|
[clearTimer],
|
|
);
|
|
|
|
const clearToasts = useCallback(() => {
|
|
for (const handle of timersRef.current.values()) {
|
|
window.clearTimeout(handle);
|
|
}
|
|
timersRef.current.clear();
|
|
setToasts([]);
|
|
}, []);
|
|
|
|
const pushToast = useCallback(
|
|
(input: ToastInput) => {
|
|
const now = Date.now();
|
|
const tone = input.tone ?? "info";
|
|
const ttlMs = normalizeTtl(input.ttlMs, tone);
|
|
const dedupeKey =
|
|
input.dedupeKey ?? input.id ?? `${tone}|${input.title}|${input.body ?? ""}|${input.action?.href ?? ""}`;
|
|
|
|
for (const [key, ts] of dedupeRef.current.entries()) {
|
|
if (now - ts > DEDUPE_MAX_AGE_MS) {
|
|
dedupeRef.current.delete(key);
|
|
}
|
|
}
|
|
|
|
// Distinct events (two runs, two status changes) can render the same
|
|
// visible text. Showing it twice reads as a duplicate, so match on that too.
|
|
const contentKey = `content|${tone}|${input.title}|${input.body ?? ""}`;
|
|
const recentlyShown = [dedupeKey, contentKey].some((key) => {
|
|
const lastSeen = dedupeRef.current.get(key);
|
|
return lastSeen !== undefined && now - lastSeen < DEDUPE_WINDOW_MS;
|
|
});
|
|
if (recentlyShown) {
|
|
return null;
|
|
}
|
|
dedupeRef.current.set(dedupeKey, now);
|
|
dedupeRef.current.set(contentKey, now);
|
|
|
|
const id = input.id ?? generateToastId();
|
|
clearTimer(id);
|
|
|
|
setToasts((prev) => {
|
|
const nextToast: ToastItem = {
|
|
id,
|
|
title: input.title,
|
|
body: input.body,
|
|
tone,
|
|
ttlMs,
|
|
action: input.action,
|
|
actor: input.actor,
|
|
createdAt: now,
|
|
};
|
|
|
|
const withoutCurrent = prev.filter((toast) => toast.id !== id);
|
|
return [nextToast, ...withoutCurrent].slice(0, MAX_TOASTS);
|
|
});
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
dismissToast(id);
|
|
}, ttlMs);
|
|
timersRef.current.set(id, timeout);
|
|
return id;
|
|
},
|
|
[clearTimer, dismissToast],
|
|
);
|
|
|
|
useEffect(() => () => {
|
|
for (const handle of timersRef.current.values()) {
|
|
window.clearTimeout(handle);
|
|
}
|
|
timersRef.current.clear();
|
|
}, []);
|
|
|
|
const actions = useMemo<ToastActionsContextValue>(
|
|
() => ({
|
|
pushToast,
|
|
dismissToast,
|
|
clearToasts,
|
|
}),
|
|
[pushToast, dismissToast, clearToasts],
|
|
);
|
|
|
|
return (
|
|
<ToastActionsContext.Provider value={actions}>
|
|
<ToastStateContext.Provider value={toasts}>{children}</ToastStateContext.Provider>
|
|
</ToastActionsContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToastState() {
|
|
const context = useContext(ToastStateContext);
|
|
if (!context) {
|
|
throw new Error("useToastState must be used within a ToastProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export function useToastActions() {
|
|
const context = useContext(ToastActionsContext);
|
|
if (!context) {
|
|
throw new Error("useToastActions must be used within a ToastProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export function useOptionalToastActions() {
|
|
return useContext(ToastActionsContext);
|
|
}
|
|
|
|
export function useOptionalToastState() {
|
|
return useContext(ToastStateContext);
|
|
}
|
|
|
|
export function useToast() {
|
|
const toasts = useToastState();
|
|
const actions = useToastActions();
|
|
return useMemo<ToastContextValue>(() => ({ toasts, ...actions }), [toasts, actions]);
|
|
}
|