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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-11-06 13:03:07 -08:00
1 parent c3712bc677
commit f87f874d29
5 files changed
+106 -16

No files matched your search

+3 -1
View File
@@ -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.
+2 -2
View File
@@ -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<AdapterAuthSessionOwnerResponse>(
`/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<ClaudeSetupTokenSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
{ cache: "no-store", ...options },
@@ -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<void>(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",
+8 -5
View File
@@ -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);
@@ -3,6 +3,34 @@ import { useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
const pendingCancellations = new Map<string, Promise<unknown>>();
const sessionCancellations = new Map<string, Promise<unknown>>();
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<unknown>[] = [], 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 };