From f87f874d294aefba14f28e84250d8cbcb03b3ee3 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 6 Nov 2026 13:03:07 -0800 Subject: [PATCH] Release abandoned sessions after aborted resume reads An aborted owner read rejects before returning its session ID. Discover that session through a separate fresh cleanup read, cancel it, and wait for cleanup before replacement. Cover Back both before panel mount and while the panel owns the pending read. Deduplicate overlapping cancellation requests from the wizard and login panel. The new regressions reject on AbortSignal cancellation and hold discovery and release. Both providers reproduced the missing cleanup before this fix. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 4 +- ui/src/api/agents.ts | 4 +- ui/src/components/OnboardingWizard.test.tsx | 62 +++++++++++++++++++ ui/src/components/OnboardingWizard.tsx | 13 ++-- .../components/useAdapterLoginCancellation.ts | 39 +++++++++--- 5 files changed, 106 insertions(+), 16 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index ac1ebdc2a9f..2ea4f268145 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -42,7 +42,9 @@ mode's form is shown below. Advanced opens the existing compatible-connection picker and provider setup. Execution environment selection lives in Configure. Back from subscription sign-in cancels that attempt and pending prompt or status -reads. A session returned after Back is released instead of reopening sign-in. +reads. A session returned after Back is released instead of reopening sign-in. If an +aborted read hides the session ID, a separate owner lookup releases the attempt +before a replacement can start. Selecting a subscription again starts a fresh attempt. Reloading the page can still resume a sign-in that was not cancelled. diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0de5f60a0d5..f192d65bc4c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -346,7 +346,7 @@ export const agentsApi = { // Reads the caller's active login session for one company and adapter, with // no session id, so the browser rediscovers its own session after a reload // with no local state. A 404 means no active session for the caller. - getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal }) => + getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal; fresh?: boolean }) => api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`, { cache: "no-store", ...options }, @@ -386,7 +386,7 @@ export const agentsApi = { // Reads the caller's active Claude setup-token login session, with no // session id, so the browser rediscovers its own session after a reload with // no local state. A 404 means no active session for the caller. - getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal }) => + getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal; fresh?: boolean }) => api.get( `/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`, { cache: "no-store", ...options }, diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 9dc2e1d9648..a8cd96d090f 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -4014,6 +4014,68 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( } finally { await act(async () => root.unmount()); } }); + it.each([ + ["claude_local", false], ["codex_local", false], ["claude_local", true], ["codex_local", true], + ] as const)("discovers and releases %s after its resume read rejects on abort (panel mounted: %s)", async (adapterType, panelMounted) => { + const claude = adapterType === "claude_local"; + const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession; + const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin; + const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin; + const session = { sessionId: "abandoned-session", environmentId: "env-sandbox-1", status: "waiting_for_user", expiresAt: null, + panelMode: claude ? "submitted_browser_code" : "displayed_code", + aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true } }; + let serverActive = true; + let aborts = 0; + let finishDiscovery!: () => void; + let finishCancellation = () => {}; + const discovery = new Promise(resolve => { finishDiscovery = resolve; }); + activeApi.mockImplementation((...args: unknown[]) => { + const options = args.at(-1) as { signal?: AbortSignal; fresh?: boolean }; + if (options?.fresh) return discovery.then(() => serverActive ? session : null); + if (!serverActive) return Promise.resolve(null); + return new Promise((_resolve, reject) => options?.signal?.addEventListener("abort", () => { + aborts++; + reject(new DOMException("The request was aborted", "AbortError")); + }, { once: true })); + }); + cancelApi.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { serverActive = false; resolve({}); }; })); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType }); + const matchMedia = window.matchMedia; + const restoreMotion = () => Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: matchMedia }); + try { + if (panelMounted) await pickSource(claude ? /Claude/ : /OpenAI/); + else { + Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, + value: (media: string) => ({ matches: false, media, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false }) }); + const tile = [...document.body.querySelectorAll('[role="radio"]')].find(tile => (claude ? /Claude/ : /OpenAI/).test(tile.textContent ?? ""))!; + await act(async () => (tile as HTMLElement).click()); + expect(activeApi).toHaveBeenCalledTimes(1); + restoreMotion(); + } + await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(aborts).toBeGreaterThan(0); + await vi.waitFor(() => expect((activeApi.mock.calls as unknown[][]).some(call => (call.at(-1) as { fresh?: boolean })?.fresh)).toBe(true)); + expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0); + await pickSource(claude ? /Claude/ : /OpenAI/); + expect(startApi).not.toHaveBeenCalled(); + await act(async () => finishDiscovery()); + await vi.waitFor(() => expect(cancelApi).toHaveBeenCalled()); + expect(startApi).not.toHaveBeenCalled(); + await act(async () => finishCancellation()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(serverActive).toBe(false); + expect(startApi).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + restoreMotion(); + finishDiscovery(); + finishCancellation(); + await act(async () => root.unmount()); + } + }); + it("cancels claude_local on Back and starts a fresh sign-in when selected again", async () => { const session = { sessionId: "claude-session-1", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 29a147f8903..312e373a16a 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,3 +1,4 @@ +import { cancelAdapterLoginSession, cancelPendingAdapterLogin } from "./useAdapterLoginCancellation"; import { useQueryView } from "@/components/QueryView"; import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup"; @@ -1161,10 +1162,8 @@ function OnboardingWizardInner({ ? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!, { signal }) : await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType, { signal }); if (session && dismissedLoginResumes.current.has(loginResumeKey)) { - const cancel = adapterCaps.login?.panelMode === "submitted_browser_code" - ? agentsApi.cancelClaudeSetupTokenLogin(createdCompanyId!, session.sessionId) - : agentsApi.cancelAdapterAuthLogin(createdCompanyId!, adapterType, session.sessionId); - await cancel.catch(() => {}); + await cancelAdapterLoginSession(createdCompanyId!, adapterType, + adapterCaps.login?.panelMode === "submitted_browser_code", session.sessionId); return null; } return session; @@ -1567,7 +1566,11 @@ function OnboardingWizardInner({ function unwindConnectStep() { localLogin.cancel(); dismissedLoginResumes.current.add(loginResumeKey); - if (createdCompanyId) void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) }); + if (createdCompanyId) { + void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) }); + if (canShowAdapterLogin && credentialMode !== "api") void cancelPendingAdapterLogin(createdCompanyId, adapterType, + adapterCaps.login?.panelMode === "submitted_browser_code"); + } connectAttemptRef.current++; hiringAgentRef.current = null; setLoading(false); diff --git a/ui/src/components/useAdapterLoginCancellation.ts b/ui/src/components/useAdapterLoginCancellation.ts index b385a9cf635..90286a6932b 100644 --- a/ui/src/components/useAdapterLoginCancellation.ts +++ b/ui/src/components/useAdapterLoginCancellation.ts @@ -3,6 +3,34 @@ import { useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; const pendingCancellations = new Map>(); +const sessionCancellations = new Map>(); + +export function cancelAdapterLoginSession(companyId: string, adapterType: string, submittedCode: boolean, sessionId: string) { + const key = JSON.stringify([companyId, adapterType, sessionId]); + const previous = sessionCancellations.get(key); + if (previous) return previous; + const request = (submittedCode + ? agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId) + : agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId)).catch(() => {}); + sessionCancellations.set(key, request); + void request.then(() => { if (sessionCancellations.get(key) === request) sessionCancellations.delete(key); }); + return request; +} + +export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, pending: Promise[] = [], discover = true) { + const key = JSON.stringify([companyId, adapterType]); + const previous = pendingCancellations.get(key); + const cleanup = Promise.allSettled([...(previous ? [previous] : []), ...pending]).then(async () => { + if (!discover) return; + const session = submittedCode + ? await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { fresh: true }) + : await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { fresh: true }); + if (session) await cancelAdapterLoginSession(companyId, adapterType, submittedCode, session.sessionId); + }).catch(() => {}); + pendingCancellations.set(key, cleanup); + void cleanup.then(() => { if (pendingCancellations.get(key) === cleanup) pendingCancellations.delete(key); }); + return cleanup; +} export function useAdapterLoginCancellation(active: boolean, companyId: string, adapterType: string, submittedCode: boolean, sessionId: string | null) { const queryClient = useQueryClient(); @@ -14,9 +42,7 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, const cancel = useCallback((id: string) => { const previous = cancelled.current.get(id); if (previous) return previous; - const request = (submittedCode - ? agentsApi.cancelClaudeSetupTokenLogin(companyId, id) - : agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id)).catch(() => {}); + const request = cancelAdapterLoginSession(companyId, adapterType, submittedCode, id); cancelled.current.set(id, request); return request; }, [companyId, adapterType, submittedCode]); @@ -45,11 +71,8 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, ? [["claude-setup-token-active-session", companyId], ["claude-setup-token-status", companyId], ["claude-setup-token-prompt", companyId]] : [["adapter-login-active-session", companyId, adapterType], ["adapter-login-status", companyId, adapterType]]; for (const queryKey of keys) void queryClient.cancelQueries({ queryKey }); - const cleanup = Promise.allSettled([...requests.current, ...(sessionId ? [cancel(sessionId)] : [])]); - pendingCancellations.set(ownerKey, cleanup); - void cleanup.then(() => { - if (pendingCancellations.get(ownerKey) === cleanup) pendingCancellations.delete(ownerKey); - }); + void cancelPendingAdapterLogin(companyId, adapterType, submittedCode, + [...requests.current, ...(sessionId ? [cancel(sessionId)] : [])], !sessionId); }, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey]); return { accept, isActive: () => scope.current.active };