diff --git a/docs/design/2026-08-11-transactional-same-session-refresh.md b/docs/design/2026-08-11-transactional-same-session-refresh.md index 6dda31705f0..4280bdb149a 100644 --- a/docs/design/2026-08-11-transactional-same-session-refresh.md +++ b/docs/design/2026-08-11-transactional-same-session-refresh.md @@ -16,7 +16,7 @@ Restore identity includes the normalized session and workspace, the effective re A same-session request waits for the source runner to be ready and free of local, restored, or observed work before it starts. This wait does not consume the restore budget. The budget starts with the raw RPC, and signal, lifecycle, navigation, resync, or environment changes can still cancel the intent. Resync remains authoritative and continues through its existing destructive recovery path for this change. -Source-bound branch, create, attach, and legacy restore operations exclude ordinary restores. The exclusion follows the raw operation rather than an outer action timeout: a timed-out create keeps restores blocked until its raw request settles, and a late successful create is detached once. A blocked controlled target is still routed through the coordinator so it publishes a terminal failed transition and does not leave the host waiting indefinitely. +Source-bound branch, create, attach, and legacy restore operations exclude ordinary restores. The exclusion follows the raw operation rather than an outer action timeout: a timed-out create keeps restores blocked until its raw request settles, and a late successful create is detached once. A controlled target discovered during a source-bound operation remains pending and is retried once the final source-bound operation settles, so a transient interlock cannot permanently drop the host's desired target. ## Cursor capture and integrity @@ -40,6 +40,12 @@ Raw `clientId` props are desired input rather than committed owner state. A mode The commit CAS includes the source object's current client ID. If SDK prompt-admission self-heal updates that ID in place, the prepared candidate is discarded and the healed source remains active. Failures publish one recoverable transition failure while leaving source connection, transcript, prompt, metadata, and controls usable; they never rewrite the source as missing or disconnected. +The committed client ID is also the recovery identity. Once a modern rebind commits, later renders cannot restore the initial prop into the committed client ref; subsequent ring or epoch recovery therefore requests the attachment that actually owns the current runner. Legacy daemons still mirror the prop because they do not support transactional client ownership. + +All terminal intent paths retire a prepared candidate and release its source-tail capture. A raw restore timeout may retain the capture only until that raw request settles, allowing an exact-shape retry to adopt its result without leaving event capture enabled after the intent has otherwise failed. + +Bounded load responses carry the same event epoch as full load responses. The bridge snapshots the replay watermark and epoch together and returns them only if both remain unchanged through the persisted-page read, preserving the provider's same-epoch commit check. + ## Verification and risks Unit coverage checks delayed success and failure, local and observer prompt gating, response completeness, partial or degraded replay, epoch and tail gaps, cursor catch-up, client-ID rebind, in-place self-heal, late cleanup, and cross-session arbitration. SDK tests cover epoch and replay-integrity propagation. A real-daemon JSDOM test withholds an already-completed same-session load response, sends live source work during the hold, and verifies atomic replay-plus-tail commit without loss or duplication; structured timeout and client-ID rebind paths verify source preservation and transcript continuity. diff --git a/integration-tests/cli/qwen-serve-webui-same-session-refresh.test.ts b/integration-tests/cli/qwen-serve-webui-same-session-refresh.test.ts index 080f6c6b457..8845550f2fd 100644 --- a/integration-tests/cli/qwen-serve-webui-same-session-refresh.test.ts +++ b/integration-tests/cli/qwen-serve-webui-same-session-refresh.test.ts @@ -152,6 +152,7 @@ describe('qwen serve WebUI transactional same-session refresh', () => { token: activeDaemon!.token, sessionId: source.sessionId, workspaceCwd: resolvedWorkspace, + historyPageSize: 100, ...(clientId ? { clientId } : {}), }, createElement(Harness), diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index ef3b1921389..bad8a8a5e8c 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4364,6 +4364,7 @@ describe('createAcpSessionBridge', () => { attached: true, historyHasMore: true, lastEventId: loaded.lastEventId, + eventEpoch: loaded.eventEpoch, compactedReplay: [ { type: 'session_update', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index cad083f2b42..f10d29069b2 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5399,6 +5399,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { for (let attempt = 0; attempt < 2; attempt++) { try { const lastEventId = entry.events.lastEventId; + const eventEpoch = entry.events.epoch; const seenCursors = new Set(); let emptyPageCount = 0; let cursor: string | undefined; @@ -5436,12 +5437,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if ( byId.get(entry.sessionId) === entry && !entry.promptActive && + entry.events.epoch === eventEpoch && entry.events.lastEventId === lastEventId ) { return { compactedReplay: page.events, liveJournal: [], lastEventId, + eventEpoch, ...(page.partial === true ? { partial: true as const } : {}), ...(page.replayError !== undefined ? { replayError: page.replayError } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index e1947bd7666..9b04b4a05cf 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -10911,8 +10911,12 @@ describe('DaemonSessionProvider', () => { expect(connection?.sessionTransition).toBeUndefined(); }); - it('publishes a controlled failure while session creation is in flight', async () => { + it('opens the latest controlled target after session creation settles', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['client_identity'], @@ -10938,6 +10942,10 @@ describe('DaemonSessionProvider', () => { }); source.client!.createOrAttachSession = vi.fn(() => created.promise); sdkMocks.MockDaemonSessionClient.load.mockClear(); + const target = createDeferred(); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async () => target.promise, + ); let create!: Promise; act(() => { create = requireActions(actions).createSession(); @@ -10957,19 +10965,35 @@ describe('DaemonSessionProvider', () => { status: 'connected', sessionId: 'session-a', clientId: 'client-a', - sessionTransition: { - phase: 'failed', - origin: 'controlled', - targetSessionId: 'session-b', - }, }); + expect(connection?.sessionTransition).toBeUndefined(); expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); - created.resolve( - createMockSession({ sessionId: 'created-session', clientId: 'created' }), + await act(async () => { + created.resolve( + createMockSession({ + sessionId: 'created-session', + clientId: 'created', + }), + ); + await create; + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + expect(sdkMocks.MockDaemonSessionClient.load.mock.calls[0]?.[1]).toBe( + 'session-b', ); - await expect(create).resolves.toMatchObject({ - sessionId: 'created-session', + + await act(async () => { + target.resolve( + createMockSession({ sessionId: 'session-b', clientId: 'client-b' }), + ); + await flushPromises(); + }); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + clientId: 'client-b', }); }); @@ -14723,6 +14747,92 @@ describe('DaemonSessionProvider', () => { }); }); + it('starts a queued refresh after both an observed turn and shell finish', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const observed = createDeferred(); + const terminal = createDeferred(); + const shellResult = createDeferred(); + const source = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + lastEventId: 0, + shellCommand: vi.fn(async () => shellResult.promise), + events: async function* sourceEvents() { + yield { v: 1, type: 'replay_complete', data: {} }; + yield { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'remote prompt' }, + }, + }, + }; + observed.resolve(); + await terminal.promise; + yield { + id: 2, + v: 1, + type: 'turn_complete', + data: { promptId: 'remote-prompt', stopReason: 'end_turn' }, + }; + await new Promise(() => undefined); + }, + }); + sdkMocks.sessions.push(source); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await observed.promise; + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + lastEventId: 2, + replaySnapshot: createTextReplaySnapshot('refreshed transcript'), + }), + ); + let shell!: Promise; + act(() => { + shell = requireActions(actions).sendShellCommand('echo held'); + }); + let refresh!: Promise; + act(() => { + refresh = requireActions(actions).loadSession('session-a'); + }); + await act(async () => flushPromises()); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + + await act(async () => { + terminal.resolve(); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).not.toHaveBeenCalled(); + + await act(async () => { + shellResult.resolve(undefined); + await shell; + await refresh; + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce(); + }); + it('keeps the transcript while a same-session resume fails', async () => { vi.stubGlobal( 'fetch', @@ -15399,6 +15509,94 @@ describe('DaemonSessionProvider', () => { ).toContain('late captured tail'); }); + it('retires a prepared candidate when an event-driven deadline wins', async () => { + const detachFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal('fetch', detachFetch); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const emitThird = createDeferred(); + const emitFourth = createDeferred(); + const serializedAfterFailure = vi.fn(() => ({ + id: 4, + v: 1, + type: 'available_commands_update', + data: { availableCommands: [] }, + })); + const source = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + lastEventId: 2, + replaySnapshot: createTextReplaySnapshot('A transcript'), + events: async function* sourceEvents() { + yield { v: 1, type: 'replay_complete', data: {} }; + await emitThird.promise; + yield { + id: 3, + v: 1, + type: 'available_commands_update', + data: { availableCommands: [] }, + }; + await emitFourth.promise; + yield { + id: 4, + v: 1, + type: 'available_commands_update', + data: { availableCommands: [] }, + toJSON: serializedAfterFailure, + } as DaemonEvent; + await new Promise(() => undefined); + }, + }); + sdkMocks.sessions.push(source); + let actions: DaemonSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + lastEventId: 4, + replaySnapshot: createTextReplaySnapshot('replacement'), + }), + ); + vi.useFakeTimers(); + const startedAt = Date.now(); + let refresh!: Promise; + act(() => { + refresh = requireActions(actions) + .loadSession('session-a') + .catch((error: unknown) => error); + }); + await act(async () => flushPromises()); + vi.setSystemTime(startedAt + 75_000); + + await act(async () => { + emitThird.resolve(); + await flushPromises(); + }); + await expect(refresh).resolves.toMatchObject({ + message: 'Session transition timed out before restore started', + }); + expect(detachFetch).toHaveBeenCalledOnce(); + + await act(async () => { + emitFourth.resolve(); + await flushPromises(); + }); + expect(serializedAfterFailure).not.toHaveBeenCalled(); + }); + it('commits a controlled clientId rebind without replacing the transcript', async () => { const detachFetch = vi.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => @@ -15472,6 +15670,100 @@ describe('DaemonSessionProvider', () => { expect(new Headers(init?.headers).get('X-Qwen-Client-Id')).toBe('client-a'); }); + it('keeps the committed clientId for a later recovery request', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['client_identity'], + }); + const requestResync = createDeferred(); + const candidateStreamStarted = createDeferred(); + const source = createMockSession({ + sessionId: 'session-a', + clientId: 'client-a', + lastEventId: 2, + replaySnapshot: createTextReplaySnapshot('A transcript'), + events: async function* sourceEvents(opts = {}) { + yield { v: 1, type: 'replay_complete', data: {} }; + await new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + const candidate = createMockSession({ + sessionId: 'session-a', + clientId: 'client-b', + lastEventId: 2, + events: async function* candidateEvents() { + candidateStreamStarted.resolve(); + await requestResync.promise; + yield { + id: 3, + v: 1, + type: 'state_resync_required', + data: { reason: 'ring_evicted' }, + }; + }, + }); + sdkMocks.sessions.push(source); + sdkMocks.MockDaemonSessionClient.resume.mockResolvedValueOnce(candidate); + function Harness() { + useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + clientId: 'client-a', + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + root?.render( + + + , + ); + await flushPromises(); + }); + await candidateStreamStarted.promise; + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + clientId: 'client-b', + lastEventId: 3, + replaySnapshot: createTextReplaySnapshot('resynced transcript'), + }), + ); + const resumeCallsBeforeResync = + sdkMocks.MockDaemonSessionClient.resume.mock.calls.length; + await act(async () => { + requestResync.resolve(); + await flushPromises(); + }); + await vi.waitFor(() => + expect(sdkMocks.MockDaemonSessionClient.resume).toHaveBeenCalledTimes( + resumeCallsBeforeResync + 1, + ), + ); + expect(sdkMocks.MockDaemonSessionClient.resume.mock.calls.at(-1)?.[3]).toBe( + 'client-b', + ); + }); + it('keeps the old client when a controlled clientId rebind fails', async () => { const detachFetch = vi.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 0f5ca1c9f17..064c4d4158e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -925,6 +925,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const pumpTransitionRef = useRef<() => void>(() => undefined); const lifecycleRef = useRef(0); const sourceBoundOperationCountRef = useRef(0); + const controlledRetryPendingRef = useRef(false); const cancelTransitionRef = useRef<(reason: string) => void>(() => undefined); const controlledTransitionOriginRef = useRef(false); const transcriptHistoryRef = useRef({ @@ -935,6 +936,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }); const [transcriptHistoryState, setTranscriptHistoryState] = useState(transcriptHistoryRef.current); + const [controlledRetryNonce, setControlledRetryNonce] = useState(0); const eventStreamRef = useRef< | { sessionId: string; @@ -1014,7 +1016,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { !knownCapabilities.features.includes(CLIENT_IDENTITY_FEATURE) ? clientId : initialClientIdDependencyRef.current; - if (legacyClientIdDependency) { + if ( + knownCapabilities && + !knownCapabilities.features.includes(CLIENT_IDENTITY_FEATURE) && + legacyClientIdDependency + ) { clientIdRef.current = getStableClientId(legacyClientIdDependency); } const setConnectionSynchronous = useCallback( @@ -1079,15 +1085,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (mountedRef.current || mountGeneration.current !== generation) { return; } - lifecycleRef.current += 1; - const intent = desiredTransitionRef.current; - desiredTransitionRef.current = undefined; - if (intent) { - if (intent.timeout !== undefined) clearTimeout(intent.timeout); - intent.reject( - new DOMException('Session transition interrupted', 'AbortError'), - ); - } + cancelTransitionRef.current('Session transition interrupted'); liveJournalRepairRef.current?.controller?.abort(); liveJournalRepairRef.current = undefined; tryLiveJournalRepairRef.current = undefined; @@ -1855,7 +1853,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { activePromptsRef.current.has(`${activeSession.sessionId}:shell`); hasCurrentSessionActivePrompt = hasSessionActivePrompt; hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt; - runnerActiveTurn = hasSessionActivePrompt(); + runnerActiveTurn = false; setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle'); const pendingLoad = pendingSessionLoadRef.current; @@ -2725,10 +2723,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { )) ) { lastPromptTerminalEventId = event.id; - runnerActiveTurn = hasSessionActivePrompt(); - if (!runnerActiveTurn) { - queueMicrotask(pumpTransitionRef.current); - } + runnerActiveTurn = false; + queueMicrotask(pumpTransitionRef.current); } const pendingRepair = liveJournalRepairRef.current; if ( @@ -3418,16 +3414,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { [addNotice, setConnectionSynchronous], ); - const exposeCrossSessionFailure = useCallback( - (intent: CrossSessionIntent, error: unknown) => { - if (desiredTransitionRef.current !== intent) return; - desiredTransitionRef.current = undefined; - if (mountedRef.current) publishCrossSessionFailure(intent, error); - settleCrossSessionIntent(intent, error); - }, - [publishCrossSessionFailure], - ); - const retireAttachment = useCallback( (session: DaemonSessionClient, intent: CrossSessionIntent) => { const clientId = session.clientId || intent.targetClientId; @@ -3444,6 +3430,38 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { [], ); + const cleanupTransitionArtifacts = useCallback( + ( + intent: CrossSessionIntent, + options: { preserveInFlightCapture?: boolean } = {}, + ) => { + const candidate = intent.candidate; + intent.candidate = undefined; + if (candidate) retireAttachment(candidate, intent); + if (options.preserveInFlightCapture) return; + const control = runnerControlRef.current; + if (intent.capture && control?.capture === intent.capture) { + control.capture = undefined; + } + }, + [retireAttachment], + ); + + const exposeCrossSessionFailure = useCallback( + ( + intent: CrossSessionIntent, + error: unknown, + options?: { preserveInFlightCapture?: boolean }, + ) => { + if (desiredTransitionRef.current !== intent) return; + cleanupTransitionArtifacts(intent, options); + desiredTransitionRef.current = undefined; + if (mountedRef.current) publishCrossSessionFailure(intent, error); + settleCrossSessionIntent(intent, error); + }, + [cleanupTransitionArtifacts, publishCrossSessionFailure], + ); + const armTransitionDeadline = useCallback( (intent: CrossSessionIntent, capabilities: DaemonCapabilities) => { if (intent.deadlineStarted) return; @@ -3453,25 +3471,16 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (timeoutMs === undefined) return; intent.deadlineAt = Date.now() + timeoutMs; intent.timeout = setTimeout(() => { - if (intent.candidate) { - retireAttachment(intent.candidate, intent); - intent.candidate = undefined; - } - const control = runnerControlRef.current; - if ( - rawTransitionRef.current !== intent && - control && - control.capture === intent.capture - ) { - control.capture = undefined; - } exposeCrossSessionFailure( intent, new Error('Session transition timed out'), + { + preserveInFlightCapture: rawTransitionRef.current === intent, + }, ); }, timeoutMs); }, - [exposeCrossSessionFailure, retireAttachment], + [exposeCrossSessionFailure], ); const commitCrossSession = useCallback( @@ -3785,6 +3794,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { exposeCrossSessionFailure( intent, new Error('Session transition timed out before restore started'), + { + preserveInFlightCapture: rawTransitionRef.current === intent, + }, ); return; } @@ -3805,7 +3817,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { snapshot.clientId !== intent.sourceClientId || snapshot.eventEpoch !== candidate.eventEpoch ) { - retireAttachment(candidate, intent); exposeCrossSessionFailure( intent, new Error( @@ -3816,7 +3827,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { return; } if (intent.deadlineAt !== undefined && Date.now() >= intent.deadlineAt) { - retireAttachment(candidate, intent); exposeCrossSessionFailure( intent, new Error('Session transition timed out'), @@ -3844,7 +3854,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { intent.capture, ) ) { - retireAttachment(candidate, intent); exposeCrossSessionFailure( intent, new Error('Session refresh failed integrity validation'), @@ -4082,14 +4091,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const intent = desiredTransitionRef.current; desiredTransitionRef.current = undefined; if (intent) { - if (intent.candidate) retireAttachment(intent.candidate, intent); - if ( - rawTransitionRef.current !== intent && - intent.capture && - runnerControlRef.current?.capture === intent.capture - ) { - runnerControlRef.current.capture = undefined; - } + cleanupTransitionArtifacts(intent, { + preserveInFlightCapture: rawTransitionRef.current === intent, + }); settleCrossSessionIntent( intent, new DOMException(reason, 'AbortError'), @@ -4102,7 +4106,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { return next; }); }, - [retireAttachment, setConnectionSynchronous], + [cleanupTransitionArtifacts, setConnectionSynchronous], ); cancelTransitionRef.current = cancelCrossSessionTransition; @@ -4217,19 +4221,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { return current.promise; } if (current) { - if (current.candidate) { - retireAttachment(current.candidate, current); - current.candidate = undefined; - } - const control = runnerControlRef.current; - if ( - rawTransitionRef.current !== current && - current.capture && - control && - control.capture === current.capture - ) { - control.capture = undefined; - } + cleanupTransitionArtifacts(current, { + preserveInFlightCapture: rawTransitionRef.current === current, + }); settleCrossSessionIntent( current, new DOMException( @@ -4270,15 +4264,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const abort = () => { if (desiredTransitionRef.current !== intent) return; desiredTransitionRef.current = undefined; - if (intent.candidate) retireAttachment(intent.candidate, intent); - const control = runnerControlRef.current; - if ( - rawTransitionRef.current !== intent && - control && - control.capture === intent.capture - ) { - control.capture = undefined; - } + cleanupTransitionArtifacts(intent, { + preserveInFlightCapture: rawTransitionRef.current === intent, + }); settleCrossSessionIntent( intent, request.signal?.reason ?? @@ -4318,10 +4306,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { [ armTransitionDeadline, clientId, + cleanupTransitionArtifacts, publishCrossSessionFailure, resolvedBaseUrl, resolvedToken, - retireAttachment, setConnectionSynchronous, ], ); @@ -4420,8 +4408,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { desiredTransitionRef.current?.sameLogical === false, isSourceBoundOperationInFlight: () => sourceBoundOperationCountRef.current > 0, - setSourceBoundOperationInFlight: (inFlight) => - (sourceBoundOperationCountRef.current += inFlight ? 1 : -1), + setSourceBoundOperationInFlight: (inFlight) => { + sourceBoundOperationCountRef.current += inFlight ? 1 : -1; + if ( + !inFlight && + sourceBoundOperationCountRef.current === 0 && + controlledRetryPendingRef.current + ) { + controlledRetryPendingRef.current = false; + queueMicrotask(() => { + if (mountedRef.current) { + setControlledRetryNonce((nonce) => nonce + 1); + } + }); + } + }, getTransitionOrigin: () => { const controlled = controlledTransitionOriginRef.current; controlledTransitionOriginRef.current = false; @@ -4668,6 +4669,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return; } + if (sessionId && sourceBoundOperationCountRef.current > 0) { + controlledRetryPendingRef.current = true; + return; + } + controlledRetryPendingRef.current = false; lastHandledSessionIdRef.current = sessionId; lastHandledWorkspaceRef.current = targetWorkspaceCwd; lastHandledClientIdRef.current = clientId; @@ -4742,6 +4748,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }, [ actions, clientId, + controlledRetryNonce, resolvedWorkspaceCwd, sessionId, setConnectionSynchronous,