From ac909c8d837621b5b64c70a18af05fed7eb10fad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:39:26 -0700 Subject: [PATCH 01/55] fix(mobile): stop reporting delivered chat messages as "Message not sent" (#9792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): stop reporting delivered chat messages as "Message not sent" A relay drop or response timeout while terminal.send is in flight rejects the RPC even though the request usually already reached the desktop — only the ack was lost. The chat composer treated every failure as definite, showing "Message not sent" and keeping the draft for a message that is visibly in the transcript after resync (and baiting a duplicate send). Mark transport failures that happen after the request frame hit the wire as delivery-unknown, and hold those sends instead of erroring: when the transcript echo lands the draft clears silently; only if no echo arrives within 20s is the failure surfaced. Failures before the frame was written (and host rejections) still error immediately. * fix(mobile): close delivery ambiguity races * fix(mobile): harden ambiguous send reconciliation * test(mobile): assert ambiguity deadline boundary --- .../session/mobile-native-chat-send.test.ts | 54 +++- mobile/src/session/mobile-native-chat-send.ts | 26 +- .../use-mobile-native-chat-controller.ts | 21 +- .../use-mobile-native-chat-drafts.test.ts | 251 ++++++++++++++++++ .../session/use-mobile-native-chat-drafts.ts | 151 ++++++++++- .../rpc-client-delivery-ambiguity.test.ts | 160 +++++++++++ mobile/src/transport/rpc-client.ts | 19 +- .../src/transport/rpc-delivery-ambiguity.ts | 15 ++ 8 files changed, 679 insertions(+), 18 deletions(-) create mode 100644 mobile/src/transport/rpc-client-delivery-ambiguity.test.ts create mode 100644 mobile/src/transport/rpc-delivery-ambiguity.ts diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 01f4d5289ba..2eea33ed41b 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { + sendMobileNativeChatMessage, + sendMobileNativeChatMessageWithOutcome +} from './mobile-native-chat-send' function clientWithResponse(response: unknown): RpcClient { return { @@ -56,6 +60,54 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports a definite rejection when the RPC failed before the frame was written', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('Connection interrupted')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('rejected') + }) + + it('reports an unknown outcome when the RPC failed after the frame hit the wire', async () => { + const client = { + sendRequest: vi + .fn() + .mockRejectedValue(markRpcDeliveryUnknown(new Error('Connection interrupted'))) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('unknown') + // The boolean wrapper still treats unknown as not-accepted. + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('reports acceptance and host rejection as definite outcomes', async () => { + const accepted = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + await expect( + sendMobileNativeChatMessageWithOutcome({ client: accepted, terminal: 'term', text: 'hi' }) + ).resolves.toBe('accepted') + + const rejected = clientWithResponse({ + id: 'request', + ok: false, + error: { message: 'no pane' }, + _meta: { runtimeId: 'runtime' } + }) + await expect( + sendMobileNativeChatMessageWithOutcome({ client: rejected, terminal: 'term', text: 'hi' }) + ).resolves.toBe('rejected') + }) + it('sends a single non-submitting Escape for prompt cancellation', async () => { const client = clientWithResponse({ id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 4cf1776843e..64de7624c49 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,4 +1,5 @@ import type { RpcClient } from '../transport/rpc-client' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' type MobileTerminalClient = { @@ -6,13 +7,22 @@ type MobileTerminalClient = { type: 'mobile' } -export async function sendMobileNativeChatMessage(args: { +type MobileNativeChatSendArgs = { client: RpcClient terminal: string text: string enter?: boolean mobileClient?: MobileTerminalClient -}): Promise { +} + +/** 'unknown' = the RPC failed after the request hit the wire (relay drop or + * response timeout) — the desktop may have delivered the text and only the ack + * was lost, so callers must not present it as a definite send failure. */ +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' + +export async function sendMobileNativeChatMessageWithOutcome( + args: MobileNativeChatSendArgs +): Promise { try { const response = await args.client.sendRequest('terminal.send', { terminal: args.terminal, @@ -20,8 +30,14 @@ export async function sendMobileNativeChatMessage(args: { enter: args.enter ?? true, ...(args.mobileClient ? { client: args.mobileClient } : {}) }) - return isTerminalSendRpcAccepted(response) - } catch { - return false + return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' + } catch (error) { + return isRpcDeliveryUnknown(error) ? 'unknown' : 'rejected' } } + +export async function sendMobileNativeChatMessage( + args: MobileNativeChatSendArgs +): Promise { + return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c5d771b3635..603073b1452 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -17,7 +17,10 @@ import { detectAgentPermission } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { + sendMobileNativeChatMessage, + sendMobileNativeChatMessageWithOutcome +} from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' @@ -110,7 +113,8 @@ export function useMobileNativeChatController(args: { setComposerText: setChatComposerText, pending: chatPending, captureSendOrigin, - acceptSend + acceptSend, + holdUnconfirmedSend } = useMobileNativeChatDrafts({ hostId, worktreeId, @@ -224,7 +228,7 @@ export function useMobileNativeChatController(args: { onSendError('Message not sent (disconnected)') return false } - const accepted = await sendMobileNativeChatMessage({ + const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, text, @@ -232,7 +236,15 @@ export function useMobileNativeChatController(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) - if (!accepted) { + if (outcome === 'unknown') { + // Why: an ack-lost send usually WAS delivered (issue seen on cellular + // relay) — verify via the transcript echo instead of a false "not sent". + holdUnconfirmedSend(origin, text, () => + onSendError('Delivery unconfirmed — check chat before retrying') + ) + return true + } + if (outcome === 'rejected') { onSendError('Message not sent') return false } @@ -245,6 +257,7 @@ export function useMobileNativeChatController(args: { captureSendOrigin, client, deviceTokenRef, + holdUnconfirmedSend, nativeChatInputLeaseReady, onSendError ] diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 1505c9d1dc4..6ef6f0663a7 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -173,6 +173,257 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe('new edit') }) + it('clears the draft when an unconfirmed send lands in the transcript', async () => { + vi.useFakeTimers() + try { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + expect(state?.composerText).toBe('ping') + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.composerText).toBe('') + + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears immediately when the transcript echo beat the ambiguous RPC rejection', async () => { + vi.useFakeTimers() + try { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + + expect(state?.composerText).toBe('') + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('surfaces uncertainty and keeps the draft when no echo lands before the deadline', async () => { + vi.useFakeTimers() + try { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + + act(() => vi.advanceTimersByTime(19_999)) + expect(onUnconfirmed).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(onUnconfirmed).toHaveBeenCalledTimes(1) + expect(state?.composerText).toBe('ping') + } finally { + vi.useRealTimers() + } + }) + + it('does not confirm an unconfirmed send against an older identical turn', async () => { + vi.useFakeTimers() + try { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.composerText).toBe('ping') + + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('does not confirm an unconfirmed send when pagination prepends an older identical turn', async () => { + vi.useFakeTimers() + try { + await mount('a') + const anchor = assistantTextMessage('anchor', 'working') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', messages: [anchor] })) + ) + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('older', 'ping'), anchor] + }) + ) + ) + expect(state?.composerText).toBe('ping') + + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('requires one new transcript echo per repeated unconfirmed send', async () => { + vi.useFakeTimers() + try { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const firstUnconfirmed = vi.fn() + const secondUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed) + state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] }) + ) + ) + act(() => vi.advanceTimersByTime(30_000)) + + expect(firstUnconfirmed).not.toHaveBeenCalled() + expect(secondUnconfirmed).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('does not retain a deadline when an ambiguous send settles after unmount', async () => { + vi.useFakeTimers() + try { + await mount('a') + const origin = state?.captureSendOrigin('ping') + const holdUnconfirmedSend = state?.holdUnconfirmedSend + const onUnconfirmed = vi.fn() + act(() => renderer?.unmount()) + renderer = null + + act(() => { + if (origin) { + holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed) + } + }) + + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not erase newer edits when an unconfirmed send lands', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'submitted', vi.fn()) + } + }) + act(() => state?.setComposerText('new edit')) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'submitted')] }) + ) + ) + expect(state?.composerText).toBe('new edit') + }) + + it('does not confirm an old session send from an identical turn in its replacement', async () => { + vi.useFakeTimers() + try { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'replacement', + messages: [userTextMessage('replacement-message', 'ping')] + }) + ) + ) + + expect(state?.composerText).toBe('ping') + act(() => vi.advanceTimersByTime(30_000)) + expect(onUnconfirmed).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('accepts and clears the first send before a provider session id exists', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index ddd730e227c..aa954d52699 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -11,10 +11,24 @@ export type MobileNativeChatSendOrigin = { pendingKey: string | null normalizedText: string baselineOccurrences: number + baselineTailMessageId: string | null } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +// How long an ack-lost send waits for its transcript echo before the UI surfaces +// that delivery remains unconfirmed. +const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 + +type UnconfirmedSend = { + draftKey: string + pendingKey: string | null + text: string + normalizedText: string + baselineTailMessageId: string | null + deadline: ReturnType | null +} + function normalizedUserText(message: NativeChatMessage): string | null { if (message.role !== 'user') { return null @@ -37,6 +51,43 @@ function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: return count } +function findLandedUnconfirmedSends( + messages: readonly NativeChatMessage[], + entries: readonly UnconfirmedSend[] +): UnconfirmedSend[] { + // Why: pagination prepends old equal text; only unclaimed matches after each captured tail prove new echoes. + const messageIndexById = new Map() + const userMessagesByText = new Map>() + for (const [index, message] of messages.entries()) { + messageIndexById.set(message.id, index) + const text = normalizedUserText(message) + if (text) { + const current = userMessagesByText.get(text) ?? [] + current.push({ id: message.id, index }) + userMessagesByText.set(text, current) + } + } + + const claimedMessageIds = new Set() + const landed: UnconfirmedSend[] = [] + for (const entry of entries) { + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + if (tailIndex === undefined) { + continue + } + const echo = userMessagesByText + .get(entry.normalizedText) + ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id)) + if (echo) { + claimedMessageIds.add(echo.id) + landed.push(entry) + } + } + return landed +} + export function useMobileNativeChatDrafts(args: { hostId: string worktreeId: string @@ -49,6 +100,11 @@ export function useMobileNativeChatDrafts(args: { pending: MobileNativeChatPendingMessage[] captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void + holdUnconfirmedSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void + ) => void } { const { hostId, worktreeId, tabId, sessionId, messages } = args const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null @@ -60,6 +116,11 @@ export function useMobileNativeChatDrafts(args: { const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages + const activeDraftKeyRef = useRef(draftKey) + activeDraftKeyRef.current = draftKey + const activePendingKeyRef = useRef(pendingKey) + activePendingKeyRef.current = pendingKey + const mountedRef = useRef(false) const setComposerText: Dispatch> = useCallback( (value) => { @@ -81,11 +142,13 @@ export function useMobileNativeChatDrafts(args: { return null } const normalizedText = text.trim() + const currentMessages = messagesRef.current return { draftKey, pendingKey, normalizedText, - baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + baselineOccurrences: countUserTextOccurrences(currentMessages, normalizedText), + baselineTailMessageId: currentMessages[currentMessages.length - 1]?.id ?? null } }, [draftKey, pendingKey] @@ -122,6 +185,89 @@ export function useMobileNativeChatDrafts(args: { }) }, []) + // Why: a relay drop mid-send loses only the ack in the common case — the + // desktop already delivered the message. Hold the send instead of claiming + // failure (which baits a duplicate): clear the draft when the transcript echo + // lands, and surface the uncertainty if the deadline passes without one. + const unconfirmedRef = useRef([]) + const holdUnconfirmedSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + if (!mountedRef.current) { + return + } + const isActiveTranscript = + activeDraftKeyRef.current === origin.draftKey && + (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) + const entry: UnconfirmedSend = { + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + deadline: null + } + // Why: the transcript event can beat the lost RPC acknowledgement. + if ( + isActiveTranscript && + findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 + ) { + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + return + } + entry.deadline = setTimeout(() => { + unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) + onUnconfirmed() + }, UNCONFIRMED_SEND_DEADLINE_MS) + unconfirmedRef.current = [...unconfirmedRef.current, entry] + }, + [] + ) + + useEffect(() => { + if (!draftKey || unconfirmedRef.current.length === 0) { + return + } + const relevant = unconfirmedRef.current.filter( + (entry) => + entry.draftKey === draftKey && + (entry.pendingKey === null || entry.pendingKey === pendingKey) + ) + const landed = findLandedUnconfirmedSends(messages, relevant) + if (landed.length === 0) { + return + } + const landedSet = new Set(landed) + unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) + for (const entry of landed) { + if (entry.deadline !== null) { + clearTimeout(entry.deadline) + } + // Same guard as acceptSend: never erase edits typed after the send began. + setDrafts((previous) => + (previous[entry.draftKey] ?? '').trim() === entry.text.trim() + ? { ...previous, [entry.draftKey]: '' } + : previous + ) + } + }, [messages, draftKey, pendingKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + for (const entry of unconfirmedRef.current) { + if (entry.deadline !== null) { + clearTimeout(entry.deadline) + } + } + unconfirmedRef.current = [] + } + }, []) + const pending = pendingKey ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES @@ -160,6 +306,7 @@ export function useMobileNativeChatDrafts(args: { setComposerText, pending, captureSendOrigin, - acceptSend + acceptSend, + holdUnconfirmedSend } } diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts new file mode 100644 index 00000000000..6fb0f7d3610 --- /dev/null +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + this.readyState = MockWebSocket.CLOSED + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } + + drop(): void { + this.close() + this.onclose?.() + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function hasSentRequest(socket: MockWebSocket, method: string): boolean { + return socket.sent.some( + (payload) => + (JSON.parse(payload.replace(/^encrypted:/, '')) as { method: string }).method === method + ) +} + +function connectAuthenticated(): { client: ReturnType; socket: MockWebSocket } { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + return { client, socket } +} + +// Callers presenting send failures must be able to tell "the host never got the +// frame" from "the frame was written and only the response is missing" — the +// latter must not be reported to the user as a definite failure. +describe('mobile rpc-client delivery ambiguity marking', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('marks in-flight requests as delivery-unknown when the socket drops', async () => { + const { client, socket } = connectAuthenticated() + const requestError = client.sendRequest('terminal.send', { terminal: 't' }).then( + () => null, + (error: Error) => error + ) + await Promise.resolve() + expect(hasSentRequest(socket, 'terminal.send')).toBe(true) + + socket.drop() + + const error = await requestError + expect(error).toMatchObject({ message: 'Connection interrupted' }) + expect(isRpcDeliveryUnknown(error)).toBe(true) + client.close() + }) + + it('marks an in-flight request when the client closes before its response arrives', async () => { + const { client, socket } = connectAuthenticated() + const requestError = client.sendRequest('terminal.send', { terminal: 't' }).then( + () => null, + (error: Error) => error + ) + await Promise.resolve() + expect(hasSentRequest(socket, 'terminal.send')).toBe(true) + + client.close() + + const error = await requestError + expect(error).toMatchObject({ message: 'Client closed' }) + expect(isRpcDeliveryUnknown(error)).toBe(true) + }) + + it('marks timed-out requests as delivery-unknown', async () => { + const { client, socket } = connectAuthenticated() + // Short override so the request times out before the activity probe + // declares the whole socket dead (which is the drop case above). + const requestError = client + .sendRequest('terminal.send', { terminal: 't' }, { timeoutMs: 1_000 }) + .then( + () => null, + (error: Error) => error + ) + await Promise.resolve() + expect(hasSentRequest(socket, 'terminal.send')).toBe(true) + + await vi.advanceTimersByTimeAsync(1_000) + + const error = await requestError + expect(error).toMatchObject({ message: 'Request timed out: terminal.send' }) + expect(isRpcDeliveryUnknown(error)).toBe(true) + client.close() + }) + + it('does not mark requests whose frame never reached the wire', async () => { + const { client, socket } = connectAuthenticated() + // Simulate RN dropping onclose: the socket is dead but state is still 'connected'. + socket.readyState = MockWebSocket.CLOSED + + const error = await client.sendRequest('terminal.send', { terminal: 't' }).then( + () => null, + (caught: Error) => caught + ) + expect(error).toMatchObject({ message: 'Connection interrupted' }) + expect(isRpcDeliveryUnknown(error)).toBe(false) + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 578e1a1ba0b..9afec2bc61b 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -28,6 +28,7 @@ import { updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' import { describeSocketEvent } from './socket-event-debug' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { isRpcResponse } from './rpc-response-shape' import { websocketPayloadToUint8 } from './websocket-payload-bytes' @@ -644,7 +645,7 @@ export function connect( if (intentionallyClosed) { console.log('[net] handleSocketClosed — intentional close') setState('disconnected') - rejectAllPending('Connection closed') + rejectAllPending('Connection closed', { deliveryUnknown: true }) return } console.log('[net] handleSocketClosed → reconnect', { @@ -654,7 +655,7 @@ export function connect( attempt: reconnectAttempt }) emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect') - rejectAllPending('Connection interrupted') + rejectAllPending('Connection interrupted', { deliveryUnknown: true }) setState('reconnecting') scheduleReconnect() } @@ -788,8 +789,12 @@ export function connect( } } - function rejectAllPending(reason: string) { - const error = new Error(reason) + function rejectAllPending(reason: string, options?: { deliveryUnknown?: boolean }) { + // Why: pending entries only exist after a successful socket write, so a close + // here means the host may have processed them — mark the ambiguity for callers. + const error = options?.deliveryUnknown + ? markRpcDeliveryUnknown(new Error(reason)) + : new Error(reason) for (const [id, req] of pending) { pending.delete(id) queueMicrotask(() => req.reject(error)) @@ -985,7 +990,8 @@ export function connect( timeoutMs, state }) - reject(new Error(`Request timed out: ${method}`)) + // Why: the frame was written 30s ago — the host may have processed it. + reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`))) }, timeoutMs) pending.set(id, { @@ -1144,7 +1150,8 @@ export function connect( } sharedKey = null setState('disconnected') - rejectAllPending('Client closed') + // Why: closing the client cannot retract request frames already written. + rejectAllPending('Client closed', { deliveryUnknown: true }) } } } diff --git a/mobile/src/transport/rpc-delivery-ambiguity.ts b/mobile/src/transport/rpc-delivery-ambiguity.ts new file mode 100644 index 00000000000..0684176bbf8 --- /dev/null +++ b/mobile/src/transport/rpc-delivery-ambiguity.ts @@ -0,0 +1,15 @@ +// Why: RPCs are at-most-once — a socket drop or response timeout after the +// request frame was written leaves delivery unknown (the host may have processed +// it and only the ack was lost), unlike a failure before the frame ever left. +const deliveryUnknownErrors = new WeakSet() + +export function markRpcDeliveryUnknown(error: T): T { + deliveryUnknownErrors.add(error) + return error +} + +/** True when the request reached the wire but the RPC failed before a response + * arrived — callers must not present this as a definite send failure. */ +export function isRpcDeliveryUnknown(error: unknown): boolean { + return error instanceof Error && deliveryUnknownErrors.has(error) +} From 96df5f5715709b2209a09d06c0b388b096c21269 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:01:15 -0700 Subject: [PATCH 02/55] fix(editor): don't flag editor-initiated moves as changed-on-disk (#9506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): don't flag editor-initiated moves as changed-on-disk An in-app move/rename (explorer drag-drop, inline rename, tab rename) re-homes the open tab to the new path and carries its unsaved draft forward. The move also physically relocates the file, which the worktree watcher reports as delete(old)+create(new) a few ms later. Because the tab already lives at the new path by then, that create echo was treated as an external write landing on a dirty tab and raised a spurious "changed on disk" banner. Add a short-lived self-move registry (the move analog of the existing self-write registry) stamped at the single remap choke point, and have the external-watch handler recognize the move's own watcher echo: suppress the changed-on-disk mark on the re-homed dirty tab and the tombstone on the source path. Genuine external edits are unaffected. Covered by unit tests for the registry, the remap recorder, and the watch-hook suppression (plus a no-over-suppression guard). Co-authored-by: Orca * refactor(editor): harden move-echo suppression per adversarial review Addresses review findings on the self-move suppression: - Stamp the self-move at the call sites BEFORE the on-disk rename (recordSelfMoveForOpenTabs), not after the tab re-home, so the watcher echo can't win the race. This makes the source-side delete guard actually effective and removes a possible one-frame flash. - Suppress only the move's own create echo, not update events, so a genuine external write to the moved path within the TTL still raises the changed-on-disk banner (closes an over-suppression gap). - Track source/target roles independently per path so an immediate undo can't clobber the original move's still-in-flight stamp. - Raise the registry cap above realistic bulk-move sizes so a large directory move never self-evicts its own not-yet-echoed stamps. Updated + added tests: registry undo/role + cap, the new call-site helper (incl. directory move), and a real-update-still-marks case. Co-authored-by: Orca * fix(editor): make move-echo suppression watcher- and TTL-robust Round-3 hardening after adversarial review: - Suppress the move's own watcher echo regardless of event kind. The main-process watcher coalesces a create+attr-change burst into a lone update, so a create-only gate let the echo through on some hosts and re-exposed the false banner. Suppression is now bounded by the self-move TTL; a genuine write to the exact path within that short window is the documented trade-off (draft is preserved regardless). - Bracket the on-disk rename with the self-move stamp via a single renameOpenTabsPathOnDisk wrapper: stamp before (to beat the watcher), re-stamp on success (a slow SSH/runtime rename can outlive the TTL, so the fresh window must start when the file actually moved), and clear on failure (a rename that never happened must not suppress real events). All move entry points (explorer move, inline/tab rename incl. undo/redo, untitled rename) route through it. - Treat a tab as remote for TTL purposes when it has a runtime owner OR an SSH worktree connection (an SSH tab can carry a null runtime owner). - Registry tracks source/target roles independently per path so an immediate undo can't clobber the original move's in-flight stamp; cap raised above realistic bulk-move sizes. Tests: registry role/clear/cap, the rename wrapper (success re-stamp + failure clear), the call-site helper (dir move + clear), and watch-hook coalescing-robust suppression + post-TTL surfacing. Co-authored-by: Orca * fix(editor): refcount self-move roles so a failed move can't clear a live one Two concurrent moves onto the same destination both stamp that path as a target; if the second rename fails and clears, it must not erase the first (successful) move's still-live target stamp. Reference count each role's registrations and clear only the failed move's own contribution. Adds a regression test for the shared-destination case. Co-authored-by: Orca * fix(editor): give self-move stamps per-registration expiries + retract tokens The refcount model used a single shared expiry scalar per role that only grew via max() and reset at refs=0, so releasing the max-contributing registration left survivors inheriting an over-extended window (and an expired registration could be resurrected by a later stamp on a key the opposite role kept resident). Both over-suppress genuine changes. Model each stamp as an independent registration carrying its own expiry (a list per role). recordSelfMove returns a ticket; clearSelfMove retracts exactly that registration. A role is live while any of its registrations is unexpired, so concurrent stamps, failed-move clears, and expiry are all precise. Wrapper/helper thread the tickets through. Adds regressions for the over-extension and resurrection cases. Co-authored-by: Orca * test(editor): cover source-side self-move guard in the pre-remap ordering Adds the case where the watcher's delete(old) arrives while the tab is still at the old path (before remap re-homes it): with a live self-move source stamp the tombstone must be suppressed. Pairs with the existing naked-delete control (no stamp → deleted) to pin the guard's behavior. Co-authored-by: Orca * docs(editor): document the failed-move suppression window as a bounded trade-off A self-move stamp is placed before the rename and retracted if it fails, so an event consumed during the pre-failure window is swallowed. Note that this only matters for the rare unrelated-dirty-tab-at-destination case and the recoverable missed-source-tombstone case, and that a move has no bytes to echo-verify the way self-writes do. Co-authored-by: Orca * feat(editor): decide move echo vs external write by content identity Replaces the time-bounded self-move suppression heuristic with a correct-by-construction identity check, so a genuine external write to a just-moved path is never swallowed and the move's own echo is never a false conflict — regardless of watcher event-kind coalescing or timing. - Remap now carries the edit-session identity (lastKnownDiskSignature, externalMutation, pendingDiskBaselineVerification) onto the re-homed tab. Previously the close+reopen dropped it, so a moved tab lost its disk baseline (and any pre-existing changed-on-disk conflict silently vanished on move). - On a live self-move-target dirty event the watch hook now reads the destination and compares getDiskBaselineSignature(disk) to the tab's carried baseline: equal => move echo (suppress), differ/binary => genuine write (banner). Autosave is suspended synchronously before the read so a write landing mid-read can't be overwritten; a generation token makes overlapping reads safe. Fails CLOSED (marks changed) on a missing baseline or read error — never blind-suppresses. - The self-move registry now only scopes WHEN to verify. The source-side delete still can't be content-verified (nothing to read), so it stays a bounded, documented suppression. - Trim the verbose Why-comments across these files to 1-2 lines. Adds content-identity verification tests (echo/differ/no-baseline/read- error/binary/autosave-gate) and a remap test for the carried identity; splits the watch-hook suite to stay under the max-lines limit. Co-authored-by: Orca * fix(editor): give live move-verification its own autosave gate The live self-move echo verification reused pendingDiskBaselineVerification as its autosave gate, but that field is also the always-mounted restored-tab conflict scanner's work queue: the scanner scans any pending dirty tab, launches its own read, and clears the flag without checking the live generation — so it could lift the gate mid-read and let autosave overwrite a genuine external write. Give live verification a dedicated pendingLiveDiskVerification field (both suspend autosave; each cleared only by its owner). Transient, not persisted, not carried across a re-home. Co-authored-by: Orca * feat(editor): atomic rekeyOpenFilesForPathChange store action (move restructure stage 1) Foundation for treating an Orca-owned move as an in-place retarget of the open edit session (not close+reopen), per the locked design. One commit-only store update migrates every path-derived id + all id-keyed state (openFiles full-spread, the 6 file-id maps, activeFileId(+byWorktree), tabBarOrder, unified tabs/groups via the now editor-family-widened migrateHydratedEditorTabsAndGroups, pendingEditorReveal, untitled consume). Preflight fails closed on collision (never merges two live sessions) or stale with zero mutation. Not yet wired to a coordinator (stage 4). Stage 1 of 5; behind the shipped content-identity fix. Co-authored-by: Orca * feat(editor): op-scoped in-flight move registry + source integration (stage 2) editor-path-move-inflight.ts tracks Orca-owned moves for the exact duration of the rename+rekey (no TTL): source paths suppress the delete tombstone, target paths latch a destination event seen before the rekey (never suppress). Wired into the watcher's delete filter alongside the old TTL registry (OR fallback) so suppression keeps working until the stage-4 coordinator drives every move through beginEditorPathMove. Stage 2 of 5. Co-authored-by: Orca * feat(editor): move-echo provenance + autosave gate on OpenFile (stage 3 store) Adds pendingSelfMoveEcho {operationId,targetPath} to OpenFile and has the rekey action install it + pendingLiveDiskVerification on dirty autosave-capable destinations (moveOperationId arg), atomically in the same commit that re-homes the tab — so the verify gate survives the rekey and its op-id token supersedes a stale in-flight verification. Replaces the module-scoped generation map (which broke under rekey). Verification-reader wiring + coordinator follow. Co-authored-by: Orca * refactor(editor): remap moves via atomic in-place rekey, not close+reopen (stage 4a) remapOpenEditorTabsForPathChange now builds an owner-aware rekey plan (plain-path id to the first owner, owner-qualified to the rest; previews resolve their source to the moved edit's new id) and applies it via rekeyOpenFilesForPathChange in one commit — preserving the full OpenFile + cursor/view/group/MRU state and closing the close/reopen watcher-race window. Passes moveOperationId through so dirty destinations get the content-verify gate. 4545 tests green. Co-authored-by: Orca * feat(editor): move coordinator drives rename/drag/undo/redo/untitled (stage 4b) executeOpenEditorPathMove is the single transaction for every in-app move: quiesce affected saves -> op-scoped begin (per runtime owner) -> on-disk rename -> atomic in-place rekey (installs the content-verify gate/provenance) -> settle -> re-verify any destination echo latched before the rekey. On failure the store is untouched. Verification now triggers off the on-OpenFile provenance (consumed on resolve) and the watcher latches pre-rekey destination events. Wired into all five call sites; old renameOpenTabsPathOnDisk + separate remap removed from them. 2751 tests green. (TTL self-move registry now dead; removed next.) Co-authored-by: Orca * refactor(editor): remove the dead TTL self-move registry (stage 4c) The coordinator + op-scoped in-flight suppression + on-OpenFile provenance fully replace the time-bounded self-move registry, so delete it and its two helper modules (record-self-move-for-open-tabs, rename-open-editor-tabs-path). The watcher source filter now uses only isActiveMoveSourcePath and the verification trigger only the tab's pendingSelfMoveEcho. Rewrote the self-move test suite onto the new primitives. 7225 tests green. Co-authored-by: Orca * test(editor): end-to-end coordinator move test (stage 4 done) executeOpenEditorPathMove renames on disk, retargets the session in place with draft/dirty/baseline preserved + gate/provenance installed, settles the in-flight transaction; on rename failure the store is byte-identical and the transaction is released. Co-authored-by: Orca * feat(editor): mirror-safe move — detach moved mirrored tab + close-notify host (stage 5) The atomic rekey changes a tab's id, so a moved mirrored tab would be culled by the host snapshot (losing its draft) or resurrect the old path. Ship the safe minimum: the rekey detaches a moved tab from the host mirror (mirroredFromRuntimeSession cleared) and the coordinator close-notifies the host's old-path tab (close intent suppresses re-mirroring). Prevents the data-loss/resurrection; the moved tab becomes companion-local. The full host-rekey path-change protocol (preserving mirror ownership) is a documented follow-up. Co-authored-by: Orca * fix(editor): address review round 1 (4 majors) - Coordinator now propagates the rekey result: a collision/stale AFTER a successful on-disk rename triggers an inverse rename + throws, instead of reporting success with the source tab stranded at a vanished path (#1). - Cross-worktree: affected set spans all worktrees at the source path, sub-ops scoped per (worktree, owner), and the rekey partitions tab/group/tab-bar migration by each file's own worktree (was applied under one scope) (#2). - Diff tabs: single-file unstaged diff tabs are now retargeted on a directory move (rebuild the diff id + relative path) instead of stranding (#3). - Mirror close-notify moved to AFTER a successful rename so a failed rename can't desync the host by closing its authoritative tab (#4). Adds tests: collision->inverse-rename, diff-tab retarget. 6698 tests green. Co-authored-by: Orca * fix(editor): review round 2 (mirror ordering, rollback error, diff sources) - Close the host mirror tab only AFTER the rekey commits (capture pre-rekey resolution first): a rekey collision after a successful rename no longer desyncs the host by closing its authoritative tab (high). - Surface a failed inverse rename instead of swallowing it: the thrown error now states the on-disk move may remain at the new path (high). - Restrict diff-tab retargeting to staged/unstaged (purely path-derived ids); branch/commit diffs carry compare metadata and combined 'Changes' is worktree-rooted, so rebuilding them from path would produce a wrong id (med). Co-authored-by: Orca * fix(editor): resolve the move verify gate proactively (review round 3) The rekey gates every dirty moved tab pending a destination content check, but verification only ran when a watcher event arrived for that path. If the watcher was down, throttled, or the event coalesced away, the gate never cleared and autosave stayed suspended for the tab. The coordinator now drives verification for every tab it gated once the rename has committed, so the gate resolves on its own. That makes the destination-side event latch redundant, so the in-flight tracker is source-only again. Co-authored-by: Orca * fix(editor): review round 4 (gate strand, cross-worktree verify path, leak) - Don't install the move-echo verify gate on a tab already showing the changed-on-disk banner: it's autosave-suspended via externalMutation and verification skips a 'changed' tab, so the gate would strand forever. - Content-verify reads each moved tab's own filePath. A cross-worktree/ floating tab's relativePath is relative to its own root ('../…') and must not be joined onto the initiating worktree path (would read the wrong file and raise a false conflict banner on unsaved work). - Coordinator settles the in-flight source suppression in a finally so a throw between rename and commit can't leak it, and only after the rollback rename so a late forward-rename delete stays suppressed. - Migrate pendingEditorReveal.fileId across the rekey (matcher prefers it). Co-authored-by: Orca * fix(editor): don't consume move-echo provenance in the safety-net verify (round 5) The round-3 proactive post-commit verify ran resolveLiveMoveVerification, which consumed pendingSelfMoveEcho. On FSEvents/SSH the real destination watcher event reliably lands AFTER the fast local read, so it then found no provenance, took the immediate changed-on-disk mark (no baseline check when there is no recent self-write), and raised a false conflict banner on the just-moved dirty tab — the exact data-loss this change removes. The proactive verify is a safety net: it now releases the autosave gate but leaves the provenance, so a later destination event is still recognized as the move's own echo and content-verified. Only a real watcher event consumes the provenance. Keeping it is safe — every watcher consumer verifies by content, which is strictly safer than the immediate mark. Co-authored-by: Orca * fix(editor): scope move rekey to the initiating execution host (round 6) Co-authored-by: Orca * fix(editor): prefix-suppress the move root so late tabs under a dir move aren't flagged (round 8) Co-authored-by: Orca * chore(editor): trim move-fix comments to the why; drop redundant rename quiesce Co-authored-by: Orca * fix(editor): record mirrored-close intent synchronously to close the ghost-tab window Co-authored-by: Orca * fix(editor): use flavor-aware path containment for move selection (Windows/UNC case) Co-authored-by: Orca * fix(editor): reconstruct moved path by segment count (WSL alias / duplicate-separator safe) Co-authored-by: Orca * fix(editor): infer moved-path flavor by syntax, preserving legal POSIX backslashes Co-authored-by: Orca * test(editor): lock POSIX ancestor-backslash destination flavor Co-authored-by: Orca * fix(editor): flavor-aware trailing-separator strip; preserve POSIX literal backslashes Co-authored-by: Orca * fix(editor): flavor-aware separator folding in relative-path recompute (POSIX backslash) Co-authored-by: Orca * perf(editor): keep the fs-watcher delete path O(deletes) when no move is in flight Co-authored-by: Orca * docs(editor): tighten move-fix comments to one-line why-only Co-authored-by: Orca --------- Co-authored-by: Orca --- .../src/components/editor/EditorPanel.tsx | 3 +- .../src/components/editor/editor-autosave.ts | 11 +- .../editor/editor-path-move-inflight.test.ts | 72 ++++ .../editor/editor-path-move-inflight.ts | 80 +++++ .../editor/useUntitledFileRename.ts | 41 +-- .../right-sidebar/useFileExplorerDragDrop.ts | 72 ++-- .../useEditorExternalWatch-self-move.test.ts | 331 ++++++++++++++++++ .../src/hooks/useEditorExternalWatch.test.ts | 2 + .../src/hooks/useEditorExternalWatch.ts | 170 ++++++++- .../lib/execute-open-editor-path-move.test.ts | 208 +++++++++++ .../src/lib/execute-open-editor-path-move.ts | 144 ++++++++ ...p-open-editor-tabs-for-path-change.test.ts | 320 +++++++++++++++++ .../remap-open-editor-tabs-for-path-change.ts | 290 ++++++++++----- src/renderer/src/lib/rename-file.ts | 51 ++- .../runtime/close-mirrored-editor-tab.test.ts | 15 + .../src/runtime/close-mirrored-editor-tab.ts | 22 +- .../slices/editor-rekey-open-files.test.ts | 285 +++++++++++++++ src/renderer/src/store/slices/editor.ts | 225 +++++++++++- 18 files changed, 2127 insertions(+), 215 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-path-move-inflight.test.ts create mode 100644 src/renderer/src/components/editor/editor-path-move-inflight.ts create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts create mode 100644 src/renderer/src/lib/execute-open-editor-path-move.test.ts create mode 100644 src/renderer/src/lib/execute-open-editor-path-move.ts create mode 100644 src/renderer/src/store/slices/editor-rekey-open-files.test.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 932806f58f4..40041410c05 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -60,7 +60,6 @@ function EditorPanelInner({ const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible) const markdownTableOfContentsVisible = useAppStore((s) => s.markdownTableOfContentsVisible) const setMarkdownTableOfContentsVisible = useAppStore((s) => s.setMarkdownTableOfContentsVisible) - const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) const editorDraftSelector = useMemo( () => createEditorPanelDraftSelector(activeFile), @@ -127,7 +126,7 @@ function EditorPanelInner({ requestRenameForFile, closeRenameDialog, handleRenameConfirm - } = useUntitledFileRename({ openFiles, closeFile, openFile, clearUntitled }) + } = useUntitledFileRename({ openFiles, clearUntitled }) useClosedEditorTabCleanup(openFiles) useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview }) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index d2a5034f5cb..01ac40151e5 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -87,9 +87,16 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean { // baseline is still unverified (the conflict may simply not be marked YET). // One predicate so the save-queue gate and the timer scheduler cannot drift. export function isAutosaveSuspendedForFile( - file: Pick + file: Pick< + OpenFile, + 'externalMutation' | 'pendingDiskBaselineVerification' | 'pendingLiveDiskVerification' + > ): boolean { - return file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true + return ( + file.externalMutation === 'changed' || + file.pendingDiskBaselineVerification === true || + file.pendingLiveDiskVerification === true + ) } export function normalizeAutoSaveDelayMs(value: unknown): number { diff --git a/src/renderer/src/components/editor/editor-path-move-inflight.test.ts b/src/renderer/src/components/editor/editor-path-move-inflight.test.ts new file mode 100644 index 00000000000..e7bb67ceff5 --- /dev/null +++ b/src/renderer/src/components/editor/editor-path-move-inflight.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + __activeEditorPathMoveCountForTests, + __clearEditorPathMovesForTests, + beginEditorPathMove, + isActiveMoveSourcePath, + settleEditorPathMove +} from './editor-path-move-inflight' + +describe('editor-path-move-inflight', () => { + afterEach(() => __clearEditorPathMovesForTests()) + + it('marks a source path active only while the operation is in flight', () => { + beginEditorPathMove({ + operationId: 'op-1', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/a.md'] + }) + expect(isActiveMoveSourcePath('wt-1', null, '/repo/a.md')).toBe(true) + + settleEditorPathMove('op-1') + expect(isActiveMoveSourcePath('wt-1', null, '/repo/a.md')).toBe(false) + expect(__activeEditorPathMoveCountForTests()).toBe(0) + }) + + it('prefix-matches a root so a file opened under a moving directory is suppressed', () => { + beginEditorPathMove({ + operationId: 'op-1', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/src'] + }) + // The exact root, and a file that appeared under it mid-move, are both echoes. + expect(isActiveMoveSourcePath('wt-1', null, '/repo/src')).toBe(true) + expect(isActiveMoveSourcePath('wt-1', null, '/repo/src/late.ts')).toBe(true) + // A sibling outside the root is a genuine external delete. + expect(isActiveMoveSourcePath('wt-1', null, '/repo/srcabc.ts')).toBe(false) + expect(isActiveMoveSourcePath('wt-1', null, '/repo/other.ts')).toBe(false) + }) + + it('scopes by worktree and runtime owner', () => { + beginEditorPathMove({ + operationId: 'op-1', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-1', + sourcePaths: ['/repo/a.md'] + }) + expect(isActiveMoveSourcePath('wt-1', 'env-1', '/repo/a.md')).toBe(true) + expect(isActiveMoveSourcePath('wt-1', null, '/repo/a.md')).toBe(false) + expect(isActiveMoveSourcePath('wt-2', 'env-1', '/repo/a.md')).toBe(false) + }) + + it('keeps concurrent operations independent (settling one leaves the other)', () => { + beginEditorPathMove({ + operationId: 'op-1', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/a.md'] + }) + beginEditorPathMove({ + operationId: 'op-2', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/b.md'] + }) + + settleEditorPathMove('op-1') + expect(isActiveMoveSourcePath('wt-1', null, '/repo/a.md')).toBe(false) + expect(isActiveMoveSourcePath('wt-1', null, '/repo/b.md')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/editor-path-move-inflight.ts b/src/renderer/src/components/editor/editor-path-move-inflight.ts new file mode 100644 index 00000000000..ef2744c6354 --- /dev/null +++ b/src/renderer/src/components/editor/editor-path-move-inflight.ts @@ -0,0 +1,80 @@ +import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/file-explorer-paths' + +// Tracks Orca-owned moves in flight, for the rename + rekey duration only — no TTL, which would race a slow SSH rename. +// Source side only: while live, a watcher delete under any source ROOT is the move's own echo (prefix-matched so a file +// opened under a moving directory is covered), not an external delete — don't tombstone. Destination is verified by the coordinator. + +type MoveOperation = { + worktreeId: string + runtimeEnvironmentId: string | null + sourceRoots: string[] +} + +const operations = new Map() + +function owner(runtimeEnvironmentId: string | null | undefined): string | null { + return runtimeEnvironmentId?.trim() || null +} + +function normalize(absolutePath: string): string { + return normalizeAbsolutePathForComparison(absolutePath) +} + +// Both sides are normalized (separators folded, trailing slash trimmed), so a single-separator prefix check is exact. +function isInsideOrEqual(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(`${root}/`) +} + +export function beginEditorPathMove(args: { + operationId: string + worktreeId: string + runtimeEnvironmentId: string | null | undefined + /** Move roots (fromPath); a delete under any of them is suppressed. */ + sourcePaths: readonly string[] +}): void { + operations.set(args.operationId, { + worktreeId: args.worktreeId, + runtimeEnvironmentId: owner(args.runtimeEnvironmentId), + sourceRoots: args.sourcePaths.map(normalize) + }) +} + +export function settleEditorPathMove(operationId: string): void { + operations.delete(operationId) +} + +/** Cheap gate so watcher hot paths can skip per-file work when no move is live. */ +export function hasActiveEditorPathMoves(): boolean { + return operations.size > 0 +} + +/** True when this delete is the source side of a live Orca-owned move. */ +export function isActiveMoveSourcePath( + worktreeId: string, + runtimeEnvironmentId: string | null | undefined, + absolutePath: string +): boolean { + // Runs per deleted open-editor in the fs-watcher hot path; skip the normalize regex when no move is live. + if (operations.size === 0) { + return false + } + const normalizedPath = normalize(absolutePath) + const scopedOwner = owner(runtimeEnvironmentId) + for (const operation of operations.values()) { + if (operation.worktreeId !== worktreeId || operation.runtimeEnvironmentId !== scopedOwner) { + continue + } + if (operation.sourceRoots.some((root) => isInsideOrEqual(root, normalizedPath))) { + return true + } + } + return false +} + +export function __clearEditorPathMovesForTests(): void { + operations.clear() +} + +export function __activeEditorPathMoveCountForTests(): number { + return operations.size +} diff --git a/src/renderer/src/components/editor/useUntitledFileRename.ts b/src/renderer/src/components/editor/useUntitledFileRename.ts index 9718c58c9b0..2453ab7af32 100644 --- a/src/renderer/src/components/editor/useUntitledFileRename.ts +++ b/src/renderer/src/components/editor/useUntitledFileRename.ts @@ -1,29 +1,16 @@ import { useCallback, useState } from 'react' import { getConnectionId } from '@/lib/connection-context' -import { detectLanguage } from '@/lib/language-detect' import { dirname, joinPath } from '@/lib/path' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' -import { - createRuntimePath, - renameRuntimePath, - runtimePathExists -} from '@/runtime/runtime-file-client' +import { createRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' +import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave' import { getUntitledFileRoot } from './untitled-file-rename-path' type UseUntitledFileRenameParams = { openFiles: OpenFile[] - closeFile: (filePath: string) => void - openFile: (file: { - filePath: string - relativePath: string - worktreeId: string - runtimeEnvironmentId?: string | null - language: string - mode: 'edit' - }) => void clearUntitled: (fileId: string) => void } @@ -38,8 +25,6 @@ type UseUntitledFileRenameResult = { export function useUntitledFileRename({ openFiles, - closeFile, - openFile, clearUntitled }: UseUntitledFileRenameParams): UseUntitledFileRenameResult { const [renameDialogFileId, setRenameDialogFileId] = useState(null) @@ -100,24 +85,22 @@ export function useUntitledFileRename({ } try { - await renameRuntimePath(fileContext, oldPath, newPath) + // Retarget the untitled tab in place (the coordinator's rekey consumes + // its untitled status on this explicit rename), instead of close+reopen. + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: oldPath, + toPath: newPath, + worktreeId: renameDialogFile.worktreeId, + worktreePath: worktreeRoot + }) } catch (err) { setRenameError(err instanceof Error ? err.message : 'Failed to rename file') return } - - closeFile(renameDialogFile.id) - openFile({ - filePath: newPath, - relativePath: newRelPath, - worktreeId: renameDialogFile.worktreeId, - runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId, - language: detectLanguage(newRelPath), - mode: 'edit' - }) closeRenameDialog() }, - [clearUntitled, closeFile, closeRenameDialog, openFile, renameDialogFile] + [clearUntitled, closeRenameDialog, renameDialogFile] ) return { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 1e0009ee7b2..d2faa2282b0 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -12,10 +12,8 @@ import { readWorkspaceFileDragPaths, WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' -import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change' -import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' +import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move' import { commitFileExplorerOp } from './fileExplorerUndoRedo' -import { renameRuntimePath } from '@/runtime/runtime-file-client' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' function extractIpcErrorMessage(err: unknown, fallback: string): string { @@ -105,8 +103,6 @@ export function useFileExplorerDragDrop({ refreshDir, scrollRef }: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult { - const openFiles = useAppStore((s) => s.openFiles) - const [isRootDragOver, setIsRootDragOver] = useState(false) const rootDragCounterRef = useRef(0) const [dropTargetDir, setDropTargetDir] = useState(null) @@ -211,50 +207,45 @@ export function useFileExplorerDragDrop({ } const newPath = joinPath(destDir, fileName) - const remapOpenTabsForMovedPath = (fromPath: string, toPath: string): void => - remapOpenEditorTabsForPathChange({ - fromPath, - toPath, - worktreePath, - worktreeId: activeWorktreeId - }) const run = async (): Promise => { - const filesToMove = openFiles.filter((file) => { - if (file.filePath === sourcePath) { - return true - } - return ( - file.filePath.startsWith(`${sourcePath}/`) || - file.filePath.startsWith(`${sourcePath}\\`) - ) - }) - - // Why: a file move changes the write target path. Let any in-flight - // autosave settle first, then carry draft state forward to the new tab - // id so explorer drag-and-drop does not silently drop unsaved edits. - await Promise.all(filesToMove.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) - + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const fileContext = { + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } + // The coordinator quiesces saves, retargets the open sessions in place, + // and settles the move as one transaction (was: quiesce + rename + remap). try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const fileContext = { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: sourcePath, + toPath: newPath, worktreeId: activeWorktreeId, - worktreePath, - connectionId - } - await renameRuntimePath(fileContext, sourcePath, newPath) - + worktreePath + }) commitFileExplorerOp({ undo: async () => { - await renameRuntimePath(fileContext, newPath, sourcePath) + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: newPath, + toPath: sourcePath, + worktreeId: activeWorktreeId, + worktreePath + }) await Promise.all([refreshDir(destDir), refreshDir(sourceDir)]) - remapOpenTabsForMovedPath(newPath, sourcePath) }, redo: async () => { - await renameRuntimePath(fileContext, sourcePath, newPath) + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: sourcePath, + toPath: newPath, + worktreeId: activeWorktreeId, + worktreePath + }) await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) - remapOpenTabsForMovedPath(sourcePath, newPath) } }) } catch (err) { @@ -262,11 +253,10 @@ export function useFileExplorerDragDrop({ return } await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) - remapOpenTabsForMovedPath(sourcePath, newPath) } void run() }, - [worktreePath, activeWorktreeId, openFiles, refreshDir] + [worktreePath, activeWorktreeId, refreshDir] ) const clearNativeDragState = useCallback(() => { diff --git a/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts new file mode 100644 index 00000000000..a9fbd0e2c61 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as EditorAutosaveModule from '@/components/editor/editor-autosave' +import type { FsChangedPayload } from '../../../shared/types' + +vi.mock('@/store', () => ({ + useAppStore: { getState: vi.fn() } +})) +vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + notifyEditorExternalFileChange: vi.fn(), + getOpenFilesForExternalFileChange: vi.fn(() => []) + } +}) + +import { + createExternalWatchEventHandler, + verifyLatchedMoveDestinations +} from './useEditorExternalWatch' +import { useAppStore } from '@/store' +import { getOpenFilesForExternalFileChange } from '@/components/editor/editor-autosave' +import { __clearSelfWriteRegistryForTests } from '@/components/editor/editor-self-write-registry' +import { + __clearEditorPathMovesForTests, + beginEditorPathMove, + settleEditorPathMove +} from '@/components/editor/editor-path-move-inflight' +import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' + +const findTarget = (worktreePath: string, runtimeEnvironmentId: string | null = null) => + worktreePath === '/repo' + ? { worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined, runtimeEnvironmentId } + : undefined + +function payload(events: FsChangedPayload['events']): FsChangedPayload { + return { worktreePath: '/repo', events } +} + +describe('self-move source suppression (in-flight)', () => { + const setExternalMutation = vi.fn() + const fileNotes = { + id: 'file-notes', + worktreeId: 'wt-1', + worktreePath: '/repo', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + mode: 'edit' as const, + isDirty: false + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [fileNotes], + setExternalMutation + } as never) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + __clearSelfWriteRegistryForTests() + __clearEditorPathMovesForTests() + }) + + function beginMove(): void { + beginEditorPathMove({ + operationId: 'op-1', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/notes.md'] + }) + } + + it('does not tombstone the source of an in-flight move (tab still at old path)', () => { + beginMove() + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'deleted') + expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'renamed') + dispose() + }) + + it('does not tombstone a tab opened UNDER a moving directory mid-rename', () => { + // A directory move registers the root; a file opened under it during the + // in-flight window is not in the registered tab list but must still be + // recognized as the move's own echo (prefix match) — no false conflict. + const lateTab = { + id: 'file-late', + worktreeId: 'wt-1', + worktreePath: '/repo', + filePath: '/repo/src/late.md', + relativePath: 'src/late.md', + mode: 'edit' as const, + isDirty: true + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [lateTab], + setExternalMutation + } as never) + beginEditorPathMove({ + operationId: 'op-dir', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + sourcePaths: ['/repo/src'] + }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + // The rename echo arrives as a delete+create pair for the old subtree path. + handleFsChanged( + payload([ + { kind: 'delete', absolutePath: '/repo/src/late.md' }, + { kind: 'create', absolutePath: '/repo/dst/late.md' } + ]) + ) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).not.toHaveBeenCalledWith('file-late', 'renamed') + expect(setExternalMutation).not.toHaveBeenCalledWith('file-late', 'deleted') + dispose() + }) + + it('tombstones a genuine delete once the move has settled', () => { + beginMove() + settleEditorPathMove('op-1') + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }])) + vi.advanceTimersByTime(200) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'deleted') + dispose() + }) +}) + +describe('self-move echo verification (content identity)', () => { + const setExternalMutation = vi.fn() + const setPendingLiveDiskVerification = vi.fn() + const clearSelfMoveEcho = vi.fn() + + const BASELINE_CONTENT = 'the file on disk\n' + const baselineSignature = getDiskBaselineSignature(BASELINE_CONTENT) + const TARGET_PATH = '/repo/subdir/notes.md' + + function movedDirtyTab(overrides: Record = {}): Record { + return { + id: 'file-notes', + worktreeId: 'wt-1', + worktreePath: '/repo', + filePath: TARGET_PATH, + relativePath: 'subdir/notes.md', + mode: 'edit' as const, + isDirty: true, + lastKnownDiskSignature: baselineSignature, + // Installed on the tab by the rekey; routes the echo into verification. + pendingSelfMoveEcho: { operationId: 'op-1', targetPath: TARGET_PATH }, + ...overrides + } + } + + function mockState(file: Record): void { + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [file], + setExternalMutation, + setPendingLiveDiskVerification, + clearSelfMoveEcho + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + } + + function payloadFor(): FsChangedPayload { + return { worktreePath: '/repo', events: [{ kind: 'update', absolutePath: TARGET_PATH }] } + } + + function stubDiskRead(result: unknown): void { + vi.stubGlobal('window', { api: { fs: { readFile: vi.fn().mockResolvedValue(result) } } }) + } + + function stubDiskReadError(error: Error): void { + vi.stubGlobal('window', { api: { fs: { readFile: vi.fn().mockRejectedValue(error) } } }) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + __clearSelfWriteRegistryForTests() + __clearEditorPathMovesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('suspends autosave synchronously and suppresses when disk matches the baseline', async () => { + mockState(movedDirtyTab()) + stubDiskRead({ isBinary: false, content: BASELINE_CONTENT }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', true) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed') + expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', false) + expect(clearSelfMoveEcho).toHaveBeenCalledWith('file-notes') + dispose() + }) + + it('marks changed when a genuine external write differs from the baseline', async () => { + mockState(movedDirtyTab()) + stubDiskRead({ isBinary: false, content: 'an agent rewrote this file\n' }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('fails closed (marks changed) when the tab has no disk baseline', async () => { + mockState(movedDirtyTab({ lastKnownDiskSignature: undefined })) + stubDiskRead({ isBinary: false, content: BASELINE_CONTENT }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('fails closed when the destination read errors', async () => { + mockState(movedDirtyTab()) + stubDiskReadError(new Error('EACCES')) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', false) + dispose() + }) + + it('fails closed when the destination is binary', async () => { + mockState(movedDirtyTab()) + stubDiskRead({ isBinary: true, content: '' }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + await vi.advanceTimersByTimeAsync(100) + + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) + + it('does not verify an event with no move provenance (normal changed-on-disk path)', async () => { + mockState(movedDirtyTab({ pendingSelfMoveEcho: undefined })) + stubDiskRead({ isBinary: false, content: BASELINE_CONTENT }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) + + handleFsChanged(payloadFor()) + await vi.advanceTimersByTimeAsync(100) + + // No provenance -> not treated as a move echo -> normal path marks it changed. + expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed') + dispose() + }) +}) + +describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () => { + const setPendingLiveDiskVerification = vi.fn() + const clearSelfMoveEcho = vi.fn() + const setExternalMutation = vi.fn() + + const CONTENT = 'the echoed file on disk\n' + const baseline = getDiskBaselineSignature(CONTENT) + // A floating-workspace tab: relativePath is relative to its OWN root (`../…`) + // and must NOT be joined onto the initiating worktree path. + const FLOATING_FILE_PATH = '/elsewhere/notes/readme.md' + const floatingTab = { + id: 'file-x', + worktreeId: 'floating', + worktreePath: '/elsewhere', + filePath: FLOATING_FILE_PATH, + relativePath: '../notes/readme.md', + runtimeEnvironmentId: null, + mode: 'edit' as const, + isDirty: true, + lastKnownDiskSignature: baseline, + pendingSelfMoveEcho: { operationId: 'op-1', targetPath: FLOATING_FILE_PATH } + } + let readFile: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + readFile = vi.fn().mockResolvedValue({ isBinary: false, content: CONTENT }) + vi.stubGlobal('window', { api: { fs: { readFile } } }) + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [floatingTab], + setPendingLiveDiskVerification, + clearSelfMoveEcho, + setExternalMutation + } as never) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('reads the tab own filePath, not the initiating worktree joined with its relativePath', async () => { + verifyLatchedMoveDestinations('/initiating-worktree', undefined, ['file-x']) + await vi.advanceTimersByTimeAsync(50) + + expect(readFile).toHaveBeenCalledWith(expect.objectContaining({ filePath: FLOATING_FILE_PATH })) + // Disk == baseline -> echo -> suppress + release the gate, no false banner. + expect(setExternalMutation).not.toHaveBeenCalledWith('file-x', 'changed') + expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-x', false) + // Safety net does NOT consume provenance (a later watcher event still needs it). + expect(clearSelfMoveEcho).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index 90029020d0c..747babef0e1 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -33,6 +33,7 @@ import { __clearSelfWriteRegistryForTests, recordSelfWrite } from '@/components/editor/editor-self-write-registry' +import { __clearEditorPathMovesForTests } from '@/components/editor/editor-path-move-inflight' describe('getWatchedTargetKey', () => { it('changes when a worktree gains an SSH connection id', () => { @@ -225,6 +226,7 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { vi.useRealTimers() vi.unstubAllGlobals() __clearSelfWriteRegistryForTests() + __clearEditorPathMovesForTests() }) function payload(events: FsChangedPayload['events']): FsChangedPayload { diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index facb821e77b..c078a86b103 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -16,6 +16,10 @@ import { getRecentSelfWrite, type RecentSelfWrite } from '@/components/editor/editor-self-write-registry' +import { + hasActiveEditorPathMoves, + isActiveMoveSourcePath +} from '@/components/editor/editor-path-move-inflight' import type { FsChangedPayload } from '../../../shared/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { OpenFile } from '@/store/slices/editor' @@ -27,6 +31,7 @@ import { } from './worktree-file-change-event' import { isGitRepoKind } from '../../../shared/repo-kind' import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' +import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' // Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per (worktreeId+path). const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 @@ -416,12 +421,22 @@ export function createExternalWatchEventHandler( // Why: mark editor tabs deleted/renamed instead of closing them so the user keeps in-memory content; a paired create means rename, a lone delete is hard. // Why: snapshot openFiles once so the delete/rename helpers share a consistent view without N store reads per payload. const openFilesAtStart = useAppStore.getState().openFiles - const deletedOpenEditorIds = collectDeletedOpenEditorIds( + const deletedOpenEditorIdsRaw = collectDeletedOpenEditorIds( payload, target.worktreeId, target.runtimeEnvironmentId, openFilesAtStart ) + // Only pay the per-id lookup to suppress a move's own source-delete while a move is live; else the batch stays O(deletes). + const deletedOpenEditorIds = hasActiveEditorPathMoves() + ? deletedOpenEditorIdsRaw.filter((fileId) => { + const file = openFilesAtStart.find((f) => f.id === fileId) + return ( + !file || + !isActiveMoveSourcePath(target.worktreeId, target.runtimeEnvironmentId, file.filePath) + ) + }) + : deletedOpenEditorIdsRaw // Why: correlate creates to deletes by basename to avoid mislabelling unrelated create+delete pairs as "renamed"; default to 'deleted' when we can't correlate. const hasPairedCreate = deletedOpenEditorIds.length > 0 && @@ -533,6 +548,7 @@ export function createExternalWatchEventHandler( relativePath, runtimeEnvironmentId: target.runtimeEnvironmentId } + const absolutePath = joinPath(notification.worktreePath, notification.relativePath) const matching = getOpenFilesForExternalFileChange(openFilesSnapshot, notification) if (matching.length === 0) { // Why: combined-diff tab has no in-memory content to clobber and guards its own reload, so notify it directly without self-write suppression. @@ -543,13 +559,27 @@ export function createExternalWatchEventHandler( } const dirtyMatches = matching.filter((f) => f.isDirty) if (dirtyMatches.length > 0) { - // Why: an external write on a dirty tab must not vanish silently (issue #7265) — mark it so the editor shows a changed-on-disk banner with a reload path. - scheduleChangedOnDiskMark( - target, - notification, - // Why: canAutoSaveOpenFile is exactly the tabs that can hold unsaved edits (edit + unstaged diff) — the tabs the banner serves. - dirtyMatches.filter((dirtyFile) => canAutoSaveOpenFile(dirtyFile)).map((f) => f.id) - ) + // canAutoSaveOpenFile is the set of tabs that can hold unsaved edits — the tabs the banner serves. + const dirtyIds = dirtyMatches.filter((f) => canAutoSaveOpenFile(f)).map((f) => f.id) + // A tab carrying move-echo provenance for this path may just be seeing the move's own echo; settle by + // disk identity below. Only a provenance-carrying tab can match, so skip the normalize when none has it. + let isSelfMoveEcho = false + if (dirtyMatches.some((f) => f.pendingSelfMoveEcho)) { + const normalizedAbsolutePath = normalizeRuntimePathForComparison(absolutePath) + isSelfMoveEcho = dirtyMatches.some( + (f) => + f.pendingSelfMoveEcho && + normalizeRuntimePathForComparison(f.pendingSelfMoveEcho.targetPath) === + normalizedAbsolutePath + ) + } + if (isSelfMoveEcho) { + // Real destination fs event confirms the echo — consume the provenance so a later genuine write takes the normal path. + scheduleSelfMoveEchoVerification(target, dirtyIds, true) + } else { + // An external write on a dirty tab must not vanish silently (issue #7265); mark it for the reload banner. + scheduleChangedOnDiskMark(target, notification, dirtyIds) + } if (dirtyMatches.length === matching.length) { if (hasCombinedDiffConsumer) { scheduleDebouncedExternalReload(notification) @@ -558,7 +588,6 @@ export function createExternalWatchEventHandler( } // Clean sibling tabs (e.g. an unstaged diff of the same path) still reload below; consumers skip dirty files. } - const absolutePath = joinPath(notification.worktreePath, notification.relativePath) const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId) if (recentSelfWrite) { scheduleSelfWriteAwareExternalReload(target, notification, matching[0], recentSelfWrite) @@ -656,6 +685,129 @@ function scheduleChangedOnDiskMark( }) } +// Per-file generation so a newer echo-verify read supersedes an older one — overlapping reads can't clear each other's autosave gate or apply a stale verdict. +const liveMoveVerifyGeneration = new Map() +let liveMoveVerifyCounter = 0 + +type LiveMoveVerifyCandidate = { + fileId: string + baseline: string | undefined + generation: number + /** The move that installed the provenance; a newer move re-homing the tab supersedes this verification, even across a rekey. */ + operationId?: string +} + +// Resolve one candidate against the disk read (`diskSignature` null = binary/unreadable). +// Fails CLOSED: anything but a proven baseline match surfaces the conflict, so a real external write is never swallowed. +function resolveLiveMoveVerification( + candidate: LiveMoveVerifyCandidate, + diskSignature: string | null, + connectionId: string | undefined, + consumeProvenance: boolean +): void { + const { fileId, baseline, generation, operationId } = candidate + if (liveMoveVerifyGeneration.get(fileId) !== generation) { + return // a newer verification owns this tab and its autosave gate + } + liveMoveVerifyGeneration.delete(fileId) + const state = useAppStore.getState() + state.setPendingLiveDiskVerification(fileId, false) + const file = state.openFiles.find((f) => f.id === fileId) + // Skip if the interim moved on: tab resolved, baseline advanced, or a different move replaced the provenance we captured. + if ( + !file || + !file.isDirty || + file.externalMutation === 'changed' || + file.lastKnownDiskSignature !== baseline || + (operationId !== undefined && file.pendingSelfMoveEcho?.operationId !== operationId) + ) { + return + } + // Consume only when a real watcher event drove this check. The proactive post-commit verify must LEAVE the provenance, + // else the destination fs event (on FSEvents/SSH it lands after the faster local read) would raise a false conflict banner. + if (consumeProvenance) { + state.clearSelfMoveEcho(fileId) + } + const isMoveEcho = baseline !== undefined && diskSignature === baseline + if (!isMoveEcho) { + markFileChangedOnDisk(state, file, { connectionId, origin: 'live' }) + } +} + +/** + * Verify tabs whose destination echo was LATCHED before the rekey installed them: the coordinator calls this + * after a successful move so a pre-rekey event isn't lost and the autosave gate can't strand. + */ +export function verifyLatchedMoveDestinations( + worktreePath: string, + connectionId: string | undefined, + fileIds: readonly string[] +): void { + const state = useAppStore.getState() + const gated = fileIds.filter( + (id) => state.openFiles.find((f) => f.id === id)?.pendingSelfMoveEcho + ) + if (gated.length === 0) { + return + } + // consumeProvenance:false — safety net; leave the provenance so a destination watcher event after this read still reads as an echo. + // (Owner/worktree are resolved per-tab inside the read; worktreePath is unused, each tab reads at its own filePath.) + scheduleSelfMoveEchoVerification( + { worktreeId: '', worktreePath, connectionId, runtimeEnvironmentId: null }, + gated, + false + ) +} + +// Settle each dirty tab by content identity: the move's own echo leaves disk == the tab's baseline, a genuine external write does not. +// Autosave is suspended synchronously first so a write landing mid-read can't be overwritten before we decide. +function scheduleSelfMoveEchoVerification( + target: WatchedTarget, + fileIds: string[], + consumeProvenance: boolean +): void { + if (fileIds.length === 0) { + return + } + const state = useAppStore.getState() + for (const fileId of fileIds) { + const file = state.openFiles.find((f) => f.id === fileId) + if (!file || !file.isDirty || file.externalMutation === 'changed') { + continue + } + const generation = ++liveMoveVerifyCounter + liveMoveVerifyGeneration.set(fileId, generation) + state.setPendingLiveDiskVerification(fileId, true) + const candidate: LiveMoveVerifyCandidate = { + fileId, + baseline: file.lastKnownDiskSignature, + generation, + operationId: file.pendingSelfMoveEcho?.operationId + } + // Read the tab's OWN absolute path: a cross-worktree/floating tab's relativePath is relative to its own root (can be `../…`), + // never join it onto another worktree's path. readFileForEchoVerification dedups concurrent same-path reads, so siblings share one. + void readFileForEchoVerification({ + runtimeEnvironmentId: file.runtimeEnvironmentId?.trim() || target.runtimeEnvironmentId, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + connectionId: target.connectionId + }) + .then((result) => { + const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content) + resolveLiveMoveVerification( + candidate, + diskSignature, + target.connectionId, + consumeProvenance + ) + }) + .catch(() => + resolveLiveMoveVerification(candidate, null, target.connectionId, consumeProvenance) + ) + } +} + function scheduleSelfWriteAwareExternalReload( target: WatchedTarget, notification: ExternalWatchNotification, diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts new file mode 100644 index 00000000000..63e729dc65e --- /dev/null +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import type * as EditorAutosave from '@/components/editor/editor-autosave' + +const mocks = vi.hoisted(() => ({ renameRuntimePath: vi.fn() })) +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, renameRuntimePath: mocks.renameRuntimePath } +}) +vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) } +}) + +import { useAppStore } from '@/store' +import { executeOpenEditorPathMove } from './execute-open-editor-path-move' +import { __activeEditorPathMoveCountForTests } from '@/components/editor/editor-path-move-inflight' +import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { createExternalWatchEventHandler } from '@/hooks/useEditorExternalWatch' + +const CONTEXT = { + settings: null, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined +} + +function openDirtyTab(): string { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, 'unsaved work') + state.markFileDirty(id, true) + state.setLastKnownDiskSignature(id, 'sig-a') + return id +} + +describe('executeOpenEditorPathMove', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + mocks.renameRuntimePath.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renames on disk, retargets the session in place, and installs the verify gate', async () => { + openDirtyTab() + + await executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/sub/a.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/a.md', '/repo/sub/a.md') + const moved = useAppStore.getState().openFiles[0]! + expect(moved.filePath).toBe('/repo/sub/a.md') + expect(moved.isDirty).toBe(true) + expect(moved.lastKnownDiskSignature).toBe('sig-a') + expect(useAppStore.getState().editorDrafts['/repo/sub/a.md']).toBe('unsaved work') + // Dirty destination gets the content-verify gate + provenance. + expect(moved.pendingLiveDiskVerification).toBe(true) + expect(moved.pendingSelfMoveEcho?.targetPath).toBe('/repo/sub/a.md') + // The in-flight transaction is settled (no leak). + expect(__activeEditorPathMoveCountForTests()).toBe(0) + }) + + it('resolves the verify gate proactively when no destination watcher event arrives', async () => { + const DISK_CONTENT = 'the file as it exists on disk\n' + const id = openDirtyTab() + useAppStore.getState().setLastKnownDiskSignature(id, getDiskBaselineSignature(DISK_CONTENT)) + vi.stubGlobal('window', { + api: { + fs: { readFile: vi.fn().mockResolvedValue({ isBinary: false, content: DISK_CONTENT }) } + } + }) + + await executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/sub/a.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const moved = useAppStore.getState().openFiles[0]! + // Disk matched the carried baseline: gate released, no false conflict banner. + expect(moved.pendingLiveDiskVerification).toBeFalsy() + expect(moved.externalMutation).toBeUndefined() + expect(moved.isDirty).toBe(true) + // Safety-net verify LEAVES the provenance so a destination watcher event + // arriving after this read is still recognized as the move's own echo. + expect(moved.pendingSelfMoveEcho?.targetPath).toBe('/repo/sub/a.md') + }) + + it('does not raise a false banner when the destination watcher event lands after the move', async () => { + // Regression: the proactive verify must NOT consume the echo provenance, or + // the real destination fs event (which on FSEvents/SSH lands after the fast + // local read) would fall through to the immediate changed-on-disk mark. + const DISK_CONTENT = 'the file as it exists on disk\n' + const id = openDirtyTab() + useAppStore.getState().setLastKnownDiskSignature(id, getDiskBaselineSignature(DISK_CONTENT)) + vi.stubGlobal('window', { + api: { + fs: { readFile: vi.fn().mockResolvedValue({ isBinary: false, content: DISK_CONTENT }) } + } + }) + + await executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/sub/a.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + // The delayed destination create/update event finally arrives. + const { handleFsChanged, dispose } = createExternalWatchEventHandler((worktreePath) => + worktreePath === '/repo' + ? { + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: null + } + : undefined + ) + handleFsChanged({ + worktreePath: '/repo', + events: [{ kind: 'update', absolutePath: '/repo/sub/a.md' }] + }) + await new Promise((resolve) => setTimeout(resolve, 120)) + + const moved = useAppStore.getState().openFiles[0]! + expect(moved.externalMutation).toBeUndefined() + expect(moved.isDirty).toBe(true) + dispose() + }) + + it('undoes the on-disk rename when the editor rekey collides', async () => { + openDirtyTab() + // A distinct live session already occupies the destination id. + useAppStore.getState().openFile( + { + filePath: '/repo/sub/a.md', + relativePath: 'sub/a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const before = useAppStore.getState().openFiles.map((f) => f.id) + + await expect( + executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/sub/a.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + ).rejects.toThrow(/retarget/) + + // Forward rename then inverse rename to undo the disk move. + expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/a.md', '/repo/sub/a.md') + expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/sub/a.md', '/repo/a.md') + // The stranded source session is left intact (not rekeyed). + expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) + }) + + it('leaves the store untouched and settles the transaction when the rename fails', async () => { + openDirtyTab() + const before = useAppStore.getState().openFiles.map((f) => ({ ...f })) + mocks.renameRuntimePath.mockRejectedValue(new Error('EACCES')) + + await expect( + executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/sub/a.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + ).rejects.toThrow('EACCES') + + // Commit-only: no store mutation on failure. + expect(useAppStore.getState().openFiles).toEqual(before) + expect(__activeEditorPathMoveCountForTests()).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/execute-open-editor-path-move.ts b/src/renderer/src/lib/execute-open-editor-path-move.ts new file mode 100644 index 00000000000..09f89194296 --- /dev/null +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -0,0 +1,144 @@ +import { useAppStore } from '@/store' +import { renameRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' +import { + beginEditorPathMove, + settleEditorPathMove +} from '@/components/editor/editor-path-move-inflight' +import { + isPathInsideOrEqual, + remapOpenEditorTabsForPathChange +} from '@/lib/remap-open-editor-tabs-for-path-change' +import { verifyLatchedMoveDestinations } from '@/hooks/useEditorExternalWatch' +import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' + +let moveOperationCounter = 0 + +/** + * Coordinates an Orca-owned move as one editor transaction: quiesce affected + * saves, register in-flight source suppression, run the on-disk rename, then + * atomically rekey the open sessions in place (installing the content-verify + * gate) and re-verify any destination echo that was latched before the rekey. + * On failure the store is untouched — only the suppression scope is released. + */ +export async function executeOpenEditorPathMove(args: { + context: RuntimeFileOperationArgs + fromPath: string + toPath: string + worktreeId: string + worktreePath: string +}): Promise { + const { context, fromPath, toPath, worktreeId, worktreePath } = args + const operationId = `editor-move-${(moveOperationCounter += 1)}` + + // Host-scoped: the same abs path on another host (2nd SSH connection, local vs + // runtime) is a distinct file the rename never touched — keep it out of the move. + const moveState = useAppStore.getState() + const initiatingHostId = getExecutionHostIdForWorktree(moveState, worktreeId) + const affected = moveState.openFiles.filter( + (f) => + isPathInsideOrEqual(fromPath, f.filePath) && + getExecutionHostIdForWorktree(moveState, f.worktreeId) === initiatingHostId + ) + + // Suppress the move ROOT (prefix-matched) per scope, always including the + // initiating one, so a file opened UNDER a moving dir mid-rename is covered + // even though it wasn't in `affected`. + const ownerSubOps: string[] = [] + const scopes = new Map() + const addScope = (wtId: string, owner: string | null): void => { + scopes.set(`${wtId}::${owner ?? 'local'}`, { worktreeId: wtId, owner }) + } + addScope(worktreeId, getRuntimeEnvironmentIdForWorktree(moveState, worktreeId)) + for (const f of affected) { + addScope(f.worktreeId, f.runtimeEnvironmentId?.trim() || null) + } + for (const [key, scope] of scopes) { + const subOperationId = `${operationId}::${key}` + ownerSubOps.push(subOperationId) + beginEditorPathMove({ + operationId: subOperationId, + worktreeId: scope.worktreeId, + runtimeEnvironmentId: scope.owner, + sourcePaths: [fromPath] + }) + } + + // Let any in-flight autosave settle so a trailing write can't recreate the old + // path after the rename. + await Promise.all(affected.map((f) => requestEditorSaveQuiesce({ fileId: f.id }))) + + try { + await renameRuntimePath(context, fromPath, toPath) + } catch (err) { + // Rename never landed — release the source suppression immediately. + for (const subOperationId of ownerSubOps) { + settleEditorPathMove(subOperationId) + } + throw err + } + + // The on-disk rename has committed. Guarantee the source suppression is + // released on every exit path, but only AFTER any rollback rename runs so a + // late forward-rename delete event stays suppressed during rollback. + try { + // Capture host-close resolution from the PRE-rekey store (mirrored tab ids + // change on rekey), but don't send it yet — only a committed rekey should + // close the host's authoritative tab. + const mirrorState = useAppStore.getState() + const mirroredAffected = affected.filter((f) => f.mirroredFromRuntimeSession) + + // Commit: retarget the live sessions in place (the rekey installs the gate + + // provenance on dirty destinations). + const rekeyResult = remapOpenEditorTabsForPathChange({ + fromPath, + toPath, + worktreePath, + worktreeId, + moveOperationId: operationId + }) + if (!rekeyResult.ok) { + // The disk rename succeeded but the editor state couldn't be retargeted + // (a destination collision or stale plan). Undo the on-disk move so the + // still-open source session isn't stranded pointing at a vanished path. + let rollbackError: unknown + try { + await renameRuntimePath(context, toPath, fromPath) + } catch (err) { + rollbackError = err + } + const base = `Could not retarget open editors for the move (${rekeyResult.reason}).` + throw new Error( + rollbackError + ? `${base} The on-disk move could not be undone and the file may remain at the new path: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }` + : base + ) + } + + // Rekey detached mirrored tabs to local; close the host's old-path tab so its + // snapshot can't resurrect the old path (the close intent suppresses re-mirroring). + for (const file of mirroredAffected) { + notifyHostOfMirroredEditorClose(mirrorState, file.worktreeId, file.id) + } + } finally { + for (const subOperationId of ownerSubOps) { + settleEditorPathMove(subOperationId) + } + } + + // Proactively verify every gated tab so the autosave gate resolves even if the + // destination watcher event never arrives (down / dropped / coalesced). + const gatedTabIds = useAppStore + .getState() + .openFiles.filter((f) => f.pendingSelfMoveEcho?.operationId === operationId) + .map((f) => f.id) + if (gatedTabIds.length > 0) { + verifyLatchedMoveDestinations(worktreePath, context.connectionId, gatedTabIds) + } +} diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts index d641666359a..758896c538f 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts @@ -164,6 +164,265 @@ describe('remapOpenEditorTabsForPathChange', () => { }) }) + it('leaves a same-path tab on a different execution host untouched', () => { + // Two SSH connections both have /repo/a.md open with separate dirty drafts. + // Renaming on connA's host must not retarget connB's tab (a distinct file). + const state = useAppStore.getState() + useAppStore.setState({ + repos: [ + { id: 'repoA', connectionId: 'connA' }, + { id: 'repoB', connectionId: 'connB' } + ] as never, + worktreesByRepo: { + repoA: [{ id: 'wt-a', repoId: 'repoA' }], + repoB: [{ id: 'wt-b', repoId: 'repoB' }] + } as never + }) + + const openDirtyAt = (worktreeId: string, draft: string): string => { + state.openFile( + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles.find((f) => f.worktreeId === worktreeId)!.id + state.setEditorDraft(id, draft) + state.markFileDirty(id, true) + return id + } + openDirtyAt('wt-a', 'draft A') + const idB = openDirtyAt('wt-b', 'draft B') + + const result = remapOpenEditorTabsForPathChange({ + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreePath: '/repo', + worktreeId: 'wt-a' + }) + expect(result.ok).toBe(true) + + const files = useAppStore.getState().openFiles + // connA's tab moved; connB's tab kept its id, path, AND its dirty draft. + expect(files.find((f) => f.worktreeId === 'wt-a')!.filePath).toBe('/repo/b.md') + const tabB = files.find((f) => f.worktreeId === 'wt-b')! + expect(tabB.id).toBe(idB) + expect(tabB.filePath).toBe('/repo/a.md') + expect(tabB.isDirty).toBe(true) + expect(useAppStore.getState().editorDrafts[idB]).toBe('draft B') + }) + + it('selects a Windows tab whose path differs only in case from the move root', () => { + // Windows paths are case-insensitive: an open tab at C:\Repo\Src\a.ts must be + // retargeted when the move root is c:\repo\src — otherwise the rename lands on + // disk while the tab is stranded on the vanished source path. + const state = useAppStore.getState() + state.openFile( + { + filePath: 'C:\\Repo\\Src\\a.ts', + relativePath: 'Src\\a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, 'dirty windows work') + state.markFileDirty(id, true) + + const result = remapOpenEditorTabsForPathChange({ + fromPath: 'c:\\repo\\src', + toPath: 'c:\\repo\\dst', + worktreePath: 'c:\\repo', + worktreeId: 'wt-1' + }) + + expect(result.ok).toBe(true) + const moved = useAppStore.getState().openFiles[0]! + expect(moved.filePath).toBe('c:\\repo\\dst\\a.ts') + expect(moved.isDirty).toBe(true) + expect(useAppStore.getState().editorDrafts[moved.id]).toBe('dirty windows work') + }) + + it('rebuilds the moved path across WSL UNC aliases without fabricating segments', () => { + // The shared matcher equates \\wsl$\Ubuntu and \\wsl.localhost\ubuntu (same + // filesystem), but they differ in raw length — a raw slice(fromPath.length) + // would corrupt the destination. Segment-count reconstruction must not. + const state = useAppStore.getState() + state.openFile( + { + filePath: '\\\\wsl.localhost\\ubuntu\\repo\\src\\a.ts', + relativePath: 'src\\a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, 'wsl draft') + state.markFileDirty(id, true) + + const result = remapOpenEditorTabsForPathChange({ + fromPath: '\\\\wsl$\\Ubuntu\\repo\\src', + toPath: '\\\\wsl$\\Ubuntu\\repo\\dst', + worktreePath: '\\\\wsl$\\Ubuntu\\repo', + worktreeId: 'wt-1' + }) + + expect(result.ok).toBe(true) + const moved = useAppStore.getState().openFiles[0]! + expect(moved.filePath).toBe('\\\\wsl$\\Ubuntu\\repo\\dst\\a.ts') + expect(useAppStore.getState().editorDrafts[moved.id]).toBe('wsl draft') + }) + + it('preserves a legal backslash in a POSIX/SSH filename (no separator invention)', () => { + // Backslash is legal filename data on POSIX; the move must not treat it as a + // separator or emit one into the destination. + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/src/a\\b.txt', + relativePath: 'src/a\\b.txt', + worktreeId: 'wt-1', + language: 'plaintext', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: '/repo/src', + toPath: '/repo/dst', + worktreePath: '/repo', + worktreeId: 'wt-1' + }) + + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/repo/dst/a\\b.txt') + }) + + it('preserves a legal POSIX backslash in a cross-worktree tab relative path', () => { + const state = useAppStore.getState() + // Anchor the move to wt-1 / local host. + state.openFile( + { + filePath: '/repo/src/keep.ts', + relativePath: 'src/keep.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + // Floating tab for a POSIX file whose name contains a literal backslash. + state.openFile( + { + filePath: '/repo/src/a\\b.txt', + relativePath: 'a\\b.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + runtimeEnvironmentId: null, + language: 'plaintext', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: '/repo/src', + toPath: '/repo/dst', + worktreePath: '/repo', + worktreeId: 'wt-1' + }) + + const floating = useAppStore + .getState() + .openFiles.find((f) => f.worktreeId === FLOATING_TERMINAL_WORKTREE_ID)! + expect(floating.filePath).toBe('/repo/dst/a\\b.txt') + // The backslash stays filename data in the recomputed relativePath — it must + // NOT have been folded into a `a/b.txt` separator. + expect(floating.relativePath.includes('a\\b.txt')).toBe(true) + expect(floating.relativePath.includes('a/b.txt')).toBe(false) + }) + + it('does not strip a trailing backslash from a POSIX destination directory name', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/src/a.ts', + relativePath: 'src/a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + // Destination directory is literally named `dst\` (legal on POSIX). + remapOpenEditorTabsForPathChange({ + fromPath: '/repo/src', + toPath: '/repo/dst\\', + worktreePath: '/repo', + worktreeId: 'wt-1' + }) + + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/repo/dst\\/a.ts') + }) + + it('keeps `/` for a POSIX destination whose ancestor contains a legal backslash', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/srv/team\\repo/src/a.ts', + relativePath: 'src/a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: '/srv/team\\repo/src', + toPath: '/srv/team\\repo/dst', + worktreePath: '/srv/team\\repo', + worktreeId: 'wt-1' + }) + + // POSIX flavor: the separator stays `/`; the ancestor backslash is untouched. + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/srv/team\\repo/dst/a.ts') + }) + + it('rebuilds the moved path when the source root has duplicate separators', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: 'C:\\Repo\\Src\\deep\\a.ts', + relativePath: 'Src\\deep\\a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: 'C:\\Repo\\\\Src', + toPath: 'C:\\Repo\\Dst', + worktreePath: 'C:\\Repo', + worktreeId: 'wt-1' + }) + + // Nested suffix preserved, no dropped or doubled separator. + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('C:\\Repo\\Dst\\deep\\a.ts') + }) + it('clears the untitled marker when remapping a renamed new markdown file', () => { const state = useAppStore.getState() const oldPath = '/repo/untitled.md' @@ -192,4 +451,65 @@ describe('remapOpenEditorTabsForPathChange', () => { }) expect(useAppStore.getState().openFiles[0].isUntitled).toBeUndefined() }) + + it('carries the disk baseline and live conflict state onto the re-homed tab', () => { + const state = useAppStore.getState() + const oldPath = '/repo/notes.md' + const newPath = '/repo/archive/notes.md' + state.openFile( + { + filePath: oldPath, + relativePath: 'notes.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const oldId = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(oldId, 'unsaved work') + state.markFileDirty(oldId, true) + state.setLastKnownDiskSignature(oldId, 'sig-abc') + state.setExternalMutation(oldId, 'changed') + + remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath: '/repo' }) + + const moved = useAppStore.getState().openFiles.find((f) => f.filePath === newPath) + expect(moved).toBeTruthy() + // The re-homed tab must keep the identity that lets the watcher distinguish + // the move echo from a real external write, and any pre-existing conflict. + expect(moved?.lastKnownDiskSignature).toBe('sig-abc') + expect(moved?.externalMutation).toBe('changed') + expect(moved?.isDirty).toBe(true) + }) + + it('retargets an unstaged diff tab when its directory is moved', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/docs/readme.md', + relativePath: 'docs/readme.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged' + } as never, + { suppressActiveRuntimeFallback: true } + ) + + remapOpenEditorTabsForPathChange({ + fromPath: '/repo/docs', + toPath: '/repo/notes', + worktreePath: '/repo', + worktreeId: 'wt-1' + }) + + const moved = useAppStore.getState().openFiles.find((f) => f.mode === 'diff') + expect(moved?.filePath).toBe('/repo/notes/readme.md') + expect( + useAppStore.getState().openFiles.some((f) => f.filePath === '/repo/docs/readme.md') + ).toBe(false) + }) }) diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index 5f354f06b1a..645ad60ce99 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts @@ -1,18 +1,26 @@ import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import { basename } from '@/lib/path' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { + buildDiffEditorFileId, + buildOwnedEditorFileId, + resolveEditorFileIdForOwner, + type OpenFilePathRekey, + type RekeyOpenFilesResult +} from '@/store/slices/editor' +import { + isPathInsideOrEqual, + isWindowsAbsolutePathLike, normalizeRuntimePathSeparators, relativePathInsideRoot } from '../../../shared/cross-platform-path' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean { - if (candidatePath === rootPath) { - return true - } - return candidatePath.startsWith(`${rootPath}/`) || candidatePath.startsWith(`${rootPath}\\`) -} +// Re-export the shared, flavor-aware containment check: move selection must fold +// case + separators (Windows/UNC/WSL) so a same-file tab differing only in case +// isn't missed and left stranded on the vanished source path after the rename. +export { isPathInsideOrEqual } function isAbsolutePathLike(path: string): boolean { return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') @@ -22,12 +30,24 @@ function stripTrailingSeparators(path: string): string { if (path === '/' || /^[A-Za-z]:[\\/]?$/.test(path)) { return normalizeRuntimePathSeparators(path) } - return normalizeRuntimePathSeparators(path).replace(/\/+$/, '') + // Only fold backslashes to `/` for Windows/UNC paths; on POSIX a backslash is + // legal filename data and must not be treated as a separator. + return isWindowsAbsolutePathLike(path) + ? normalizeRuntimePathSeparators(path).replace(/\/+$/, '') + : path.replace(/\/+$/, '') +} + +// Fold `\`→`/` only when the associated absolute path (flavorSource) is Windows +// drive/UNC syntax; on POSIX a backslash is legal filename data, not a separator. +function foldSeparatorsForFlavor(fragment: string, flavorSource: string): string { + return isWindowsAbsolutePathLike(flavorSource) + ? normalizeRuntimePathSeparators(fragment) + : fragment } function deriveRelativeRootFromOpenFile(filePath: string, relativePath: string): string { const normalizedFilePath = stripTrailingSeparators(filePath) - const normalizedRelativePath = normalizeRuntimePathSeparators(relativePath).replace(/^\/+/, '') + const normalizedRelativePath = foldSeparatorsForFlavor(relativePath, filePath).replace(/^\/+/, '') if (!normalizedRelativePath || isAbsolutePathLike(relativePath)) { const separatorIndex = normalizedFilePath.lastIndexOf('/') return separatorIndex <= 0 ? '/' : normalizedFilePath.slice(0, separatorIndex) @@ -56,10 +76,14 @@ function splitAbsolutePath(path: string): { prefix: string; segments: string[] } } if (normalized.startsWith('//')) { const segments = normalized.slice(2).split('/').filter(Boolean) - return { - prefix: `//${segments.slice(0, 2).join('/').toLowerCase()}`, - segments: segments.slice(2) - } + const server = (segments[0] ?? '').toLowerCase() + // Canonicalize the two WSL UNC aliases to one identity (matches the shared + // normalizer) so `\\wsl$\Distro` and `\\wsl.localhost\Distro` compare equal. + const prefix = + server === 'wsl.localhost' || server === 'wsl$' + ? `//wsl/${(segments[1] ?? '').toLowerCase()}` + : `//${segments.slice(0, 2).join('/').toLowerCase()}` + return { prefix, segments: segments.slice(2) } } if (normalized.startsWith('/')) { return { prefix: '/', segments: normalized.slice(1).split('/').filter(Boolean) } @@ -67,6 +91,29 @@ function splitAbsolutePath(path: string): { prefix: string; segments: string[] } return { prefix: '', segments: normalized.split('/').filter(Boolean) } } +// Rebuild via suffix, not `slice(fromPath.length)`: containment matches paths of +// differing raw length (wsl$ vs wsl.localhost, duplicate separators), so a raw +// splice would fabricate a path. relativePathInsideRoot gives the original-case +// `/`-separated suffix (POSIX backslashes preserved) to re-emit in dest flavor. +function computeMovedPath(fromPath: string, toPath: string, filePath: string): string { + const suffix = relativePathInsideRoot(fromPath, filePath) + if (suffix === null) { + // Selection already matched, so this is unreachable; degrade to the raw splice. + return toPath + filePath.slice(fromPath.length) + } + if (suffix === '') { + return toPath + } + // Flavor by SYNTAX (drive/UNC), never by "contains a backslash": a POSIX/SSH + // path may hold a legal backslash, so it must keep `/`, never gain one, and + // never have a trailing backslash stripped as if it were a separator. + if (isWindowsAbsolutePathLike(toPath)) { + const separator = toPath.includes('\\') ? '\\' : '/' + return `${toPath.replace(/[\\/]+$/, '')}${separator}${suffix.split('/').join(separator)}` + } + return `${toPath.replace(/\/+$/, '')}/${suffix}` +} + function getRelativePathFromRoot(rootPath: string, candidatePath: string): string { const insideRoot = relativePathInsideRoot(rootPath, candidatePath) if (insideRoot !== null) { @@ -76,14 +123,23 @@ function getRelativePathFromRoot(rootPath: string, candidatePath: string): strin const root = splitAbsolutePath(rootPath) const candidate = splitAbsolutePath(candidatePath) if (root.prefix !== candidate.prefix) { - return normalizeRuntimePathSeparators(candidatePath) + return foldSeparatorsForFlavor(candidatePath, candidatePath) } + // Windows drive/UNC segments are case-insensitive, so a case-only difference + // is the SAME directory and must not emit a spurious `..`. The WSL Linux suffix + // (canonical prefix //wsl/) stays case-sensitive, as do POSIX roots. + const foldCase = + /^[a-z]:$/i.test(root.prefix) || + (root.prefix.startsWith('//') && !root.prefix.startsWith('//wsl/')) + const sameSegment = (a: string, b: string): boolean => + foldCase ? a.toLowerCase() === b.toLowerCase() : a === b + let commonSegmentCount = 0 while ( commonSegmentCount < root.segments.length && commonSegmentCount < candidate.segments.length && - root.segments[commonSegmentCount] === candidate.segments[commonSegmentCount] + sameSegment(root.segments[commonSegmentCount], candidate.segments[commonSegmentCount]) ) { commonSegmentCount += 1 } @@ -110,13 +166,16 @@ function getUpdatedRelativePath({ initiatingWorktreePath: string }): string { const worktreeRelative = relativePathInsideRoot(initiatingWorktreePath, filePath) - const normalizedRelativePath = normalizeRuntimePathSeparators(relativePath).replace(/^\/+/, '') + // Both sides fold on the same flavor (the file's own absolute path) so the + // comparison stays consistent and a legal POSIX backslash isn't mistaken for a + // separator. + const normalizedRelativePath = foldSeparatorsForFlavor(relativePath, filePath).replace(/^\/+/, '') const usesInitiatingWorktreeRoot = initiatingWorktreeId !== undefined ? worktreeId === initiatingWorktreeId : worktreeId !== FLOATING_TERMINAL_WORKTREE_ID && worktreeRelative !== null && - normalizeRuntimePathSeparators(worktreeRelative) === normalizedRelativePath + foldSeparatorsForFlavor(worktreeRelative, filePath) === normalizedRelativePath const relativeRoot = usesInitiatingWorktreeRoot ? initiatingWorktreePath : deriveRelativeRootFromOpenFile(filePath, relativePath) @@ -128,98 +187,147 @@ export function remapOpenEditorTabsForPathChange({ fromPath, toPath, worktreePath, - worktreeId + worktreeId, + moveOperationId }: { fromPath: string toPath: string worktreePath: string worktreeId?: string -}): void { + /** Passed by the move coordinator so dirty destinations get a content-verify + * gate + provenance installed atomically with the re-home. */ + moveOperationId?: string +}): RekeyOpenFilesResult { const state = useAppStore.getState() - const filesToMove = state.openFiles.filter((file) => isPathInsideOrEqual(fromPath, file.filePath)) - - // Why: preview tabs refer to edit tab ids as their source, so edits must be - // remapped first before reopening markdown previews with updated source ids. - const remappedFileIds = new Map() - const orderedFilesToMove = [...filesToMove].sort( - (a, b) => Number(a.mode === 'markdown-preview') - Number(b.mode === 'markdown-preview') + // The rename only touched the initiating execution host. The same absolute + // path can be open on a DIFFERENT host (a second SSH connection, or local vs + // runtime) as a distinct physical file — never retarget those tabs. + const initiatingHostId = getExecutionHostIdForWorktree(state, worktreeId) + const filesToMove = state.openFiles.filter( + (file) => + isPathInsideOrEqual(fromPath, file.filePath) && + getExecutionHostIdForWorktree(state, file.worktreeId) === initiatingHostId ) + if (filesToMove.length === 0) { + return { ok: true } + } - for (const file of orderedFilesToMove) { - const oldFilePath = file.filePath - const suffix = oldFilePath.slice(fromPath.length) - const updatedPath = toPath + suffix - const updatedRelative = getUpdatedRelativePath({ - filePath: oldFilePath, + // Retarget the live edit session in place (atomic store rekey) instead of + // close+reopen: preserves the full OpenFile + all id-keyed state and closes + // the watcher-race window that close/reopen opened. + const updatedPathOf = (file: { filePath: string }): string => + computeMovedPath(fromPath, toPath, file.filePath) + const relativeOf = (file: { + filePath: string + relativePath: string + worktreeId: string + }): string => + getUpdatedRelativePath({ + filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - updatedPath, + updatedPath: updatedPathOf(file), initiatingWorktreeId: worktreeId, initiatingWorktreePath: worktreePath }) - const draft = state.editorDrafts[file.id] - const wasDirty = file.isDirty - // Why: renameRuntimePath already moved the file. Clear the untitled marker - // before closeFile so its cleanup path does not try to delete the old path. - if (file.isUntitled) { - useAppStore.getState().clearUntitled(file.id) + // First owner to claim a destination gets the plain-path id; other owners of + // the same path get an owner-qualified id (as sequential openFile did). An + // unaffected tab already at the destination is honoured via + // resolveEditorFileIdForOwner; a same-owner conflict is a real collision the + // rekey action rejects. + const ownerKeyOf = (file: { worktreeId: string; runtimeEnvironmentId?: string | null }): string => + `${file.worktreeId}::${file.runtimeEnvironmentId?.trim() || ''}` + const plainPathOwner = new Map() + const reservedSourceId = (file: { + filePath: string + worktreeId: string + runtimeEnvironmentId?: string | null + }): string => { + const updatedPath = updatedPathOf(file) + const ownerKey = ownerKeyOf(file) + const claimed = plainPathOwner.get(updatedPath) + if (claimed === ownerKey) { + return updatedPath } + if (claimed !== undefined) { + return buildOwnedEditorFileId(updatedPath, file.worktreeId, file.runtimeEnvironmentId) + } + const id = resolveEditorFileIdForOwner( + state, + updatedPath, + file.worktreeId, + file.runtimeEnvironmentId, + ['edit'] + ) + if (id === updatedPath) { + plainPathOwner.set(updatedPath, ownerKey) + } + return id + } - // Why: preview tabs use synthetic ids (`markdown-preview::...`) instead of - // filePath, so close the real tab id before reopening at the new path. - state.closeFile(file.id) - if (file.mode === 'edit') { - state.openFile( - { - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - runtimeEnvironmentId: file.runtimeEnvironmentId, - language: detectLanguage(basename(updatedPath)), - mode: 'edit' - }, - { suppressActiveRuntimeFallback: file.runtimeEnvironmentId === null } - ) - } else if (file.mode === 'markdown-preview') { - const remappedSourceFileId = file.markdownPreviewSourceFileId - ? remappedFileIds.get(file.markdownPreviewSourceFileId) - : undefined - state.openMarkdownPreview( - { - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - runtimeEnvironmentId: file.runtimeEnvironmentId, - language: 'markdown' - }, - { - anchor: file.markdownPreviewAnchor ?? null, - // Why: preview-only tabs may point at an owner-qualified source id - // whose edit tab is not open. Let the store resolve that id for the - // renamed path instead of preserving the old path in the preview id. - sourceFileId: remappedSourceFileId - } - ) - } else { + const rekeys: OpenFilePathRekey[] = [] + // Edits first so a preview can point its source id at the moved edit's new id. + const newEditIdByOldId = new Map() + for (const file of filesToMove) { + if (file.mode !== 'edit') { continue } - - const freshState = useAppStore.getState() - const reopenedFile = freshState.openFiles.find( - (entry) => - entry.filePath === updatedPath && - entry.worktreeId === file.worktreeId && - entry.mode === file.mode && - (entry.runtimeEnvironmentId ?? null) === (file.runtimeEnvironmentId ?? null) - ) - const reopenedFileId = reopenedFile?.id ?? updatedPath - remappedFileIds.set(file.id, reopenedFileId) - if (draft !== undefined) { - freshState.setEditorDraft(reopenedFileId, draft) - } - if (wasDirty) { - freshState.markFileDirty(reopenedFileId, true) - } + const newId = reservedSourceId(file) + newEditIdByOldId.set(file.id, newId) + rekeys.push({ + oldFileId: file.id, + newFileId: newId, + oldFilePath: file.filePath, + newFilePath: updatedPathOf(file), + newRelativePath: relativeOf(file), + newLanguage: detectLanguage(basename(updatedPathOf(file))), + // Only an explicit rename of the file itself consumes untitled status; a + // containing-directory move keeps it. + consumeUntitled: file.isUntitled === true && file.filePath === fromPath + }) } + for (const file of filesToMove) { + if (file.mode !== 'markdown-preview') { + continue + } + const newSourceFileId = + (file.markdownPreviewSourceFileId + ? newEditIdByOldId.get(file.markdownPreviewSourceFileId) + : undefined) ?? reservedSourceId(file) + rekeys.push({ + oldFileId: file.id, + newFileId: `markdown-preview::${newSourceFileId}`, + oldFilePath: file.filePath, + newFilePath: updatedPathOf(file), + newRelativePath: relativeOf(file), + newMarkdownPreviewSourceFileId: newSourceFileId + }) + } + // Only single-file staged/unstaged diff ids are purely path-derived, so only + // they can be retargeted from path. Branch/commit diffs carry extra compare + // metadata and "Changes" is worktree-rooted — rebuilding those from path alone + // would produce the wrong id. + for (const file of filesToMove) { + if (file.mode !== 'diff' || (file.diffSource !== 'staged' && file.diffSource !== 'unstaged')) { + continue + } + const newRelativePath = relativeOf(file) + rekeys.push({ + oldFileId: file.id, + newFileId: buildDiffEditorFileId( + file.worktreeId, + file.diffSource, + newRelativePath, + file.runtimeEnvironmentId + ), + oldFilePath: file.filePath, + newFilePath: updatedPathOf(file), + newRelativePath + }) + } + if (rekeys.length === 0) { + return { ok: true } + } + return useAppStore.getState().rekeyOpenFilesForPathChange({ rekeys, moveOperationId }) } diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index 7e3d00d8c36..e6ce2a5981a 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -2,10 +2,8 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { basename, dirname, joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' -import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from '@/components/right-sidebar/fileExplorerUndoRedo' -import { renameRuntimePath } from '@/runtime/runtime-file-client' -import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change' +import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -33,15 +31,12 @@ type RenameFileArgs = { /** * Rename a file or directory on disk. Handles: * - no-op when the name is unchanged - * - quiescing any in-flight autosave on open tabs under `oldPath` - * (so a trailing write can't recreate the old path post-rename) - * - remapping every affected open editor tab to the new path + * - the open-editor move transaction (quiesce + rename + rekey) via the coordinator * - committing an undo/redo pair via the file-explorer undo stack * - unwrapped toast on IPC failure * * Used by the file-explorer inline rename and by double-click-rename - * from an editor tab. Both entry points should go through here so - * the tab-remap + quiesce behavior stays consistent. + * from an editor tab. Both go through here so the move behavior stays consistent. */ export async function renameFileOnDisk(args: RenameFileArgs): Promise { const { oldPath, newName, worktreeId, worktreePath, refreshDir } = args @@ -56,41 +51,45 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { const parentDir = dirname(oldPath) const newPath = joinPath(parentDir, trimmed) const connectionId = getConnectionId(worktreeId) ?? undefined - - // Let any in-flight autosave under `oldPath` finish first — a trailing - // write to the old path after rename would silently recreate it. - const state = useAppStore.getState() - const filesToQuiesce = state.openFiles.filter( - (file) => - file.filePath === oldPath || - file.filePath.startsWith(`${oldPath}/`) || - file.filePath.startsWith(`${oldPath}\\`) - ) - await Promise.all(filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) const fileContext = { - settings: state.settings, + settings: useAppStore.getState().settings, worktreeId, worktreePath, connectionId } try { - await renameRuntimePath(fileContext, oldPath, newPath) - remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath }) + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: oldPath, + toPath: newPath, + worktreeId, + worktreePath + }) commitFileExplorerOp({ undo: async () => { - await renameRuntimePath(fileContext, newPath, oldPath) + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: newPath, + toPath: oldPath, + worktreeId, + worktreePath + }) if (refreshDir) { await refreshDir(parentDir) } - remapOpenEditorTabsForPathChange({ fromPath: newPath, toPath: oldPath, worktreePath }) }, redo: async () => { - await renameRuntimePath(fileContext, oldPath, newPath) + await executeOpenEditorPathMove({ + context: fileContext, + fromPath: oldPath, + toPath: newPath, + worktreeId, + worktreePath + }) if (refreshDir) { await refreshDir(parentDir) } - remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath }) } }) } catch (err) { diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts index 060bac2a12d..bb63e6d5397 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts @@ -16,6 +16,11 @@ import { notifyHostOfMirroredEditorClose, type MirroredEditorCloseState } from './close-mirrored-editor-tab' +import { + isWebSessionCloseIntentPending, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' +import { toHostSessionTabId } from '../../../shared/terminal-surface-id' function buildState(overrides: Partial = {}): MirroredEditorCloseState { return { @@ -32,6 +37,16 @@ describe('notifyHostOfMirroredEditorClose', () => { closeWebRuntimeSessionTabMock.mockReset() getRuntimeEnvironmentIdForWorktreeMock.mockReset() getRuntimeEnvironmentIdForWorktreeMock.mockReturnValue('env-1') + resetWebSessionCloseIntentForTests() + }) + + it('records the host close intent SYNCHRONOUSLY (before the async close resolves)', () => { + const now = Date.now() + // No await: the intent must be pending the instant the call returns, so a + // host snapshot in the dynamic-import gap can't flash the old-path tab back. + notifyHostOfMirroredEditorClose(buildState(), 'wt-1', 'file-1') + + expect(isWebSessionCloseIntentPending('wt-1', toHostSessionTabId('host-tab-1'), now)).toBe(true) }) it('closes the mirrored editor tab on the host using the host tab id', async () => { diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.ts index ae1372bb52a..8b96f3d1c5f 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.ts @@ -2,6 +2,8 @@ import { getRuntimeEnvironmentIdForWorktree, type WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' +import { recordWebSessionCloseIntent } from './web-session-close-intent' +import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import type { OpenFile } from '@/store/slices/editor' import type { Tab } from '../../../shared/types' @@ -10,12 +12,9 @@ export type MirroredEditorCloseState = WorktreeRuntimeOwnerState & { unifiedTabsByWorktree: Record } -// Why: an editor file mirrored from a runtime session is owned by the host. The -// host republishes its open files to companions, so removing the tab only locally -// is undone by the next snapshot. Tell the host to close its own tab; the close -// intent recorded by the RPC suppresses re-mirroring until the snapshot catches up. -// Side-effecting only — callers still run their normal local close. No-op (returns -// false) for non-mirrored files, so the host's own closes are untouched. +// A mirrored editor file is owned by the host, which republishes its open files, so a local-only close is undone by the next +// snapshot. Tell the host to close its own tab; the RPC's close intent suppresses re-mirroring until the snapshot catches up. +// Side-effecting only (callers still run their local close); no-op for non-mirrored files, so the host's own closes are untouched. export function notifyHostOfMirroredEditorClose( state: MirroredEditorCloseState, worktreeId: string | null | undefined, @@ -32,17 +31,18 @@ export function notifyHostOfMirroredEditorClose( if (!runtimeEnvironmentId?.trim()) { return false } - // Why: a mirrored editor unified tab carries the host's tab id as `id` and the - // local file id as `entityId`, and the host close RPC resolves editor tabs by id. + // A mirrored unified tab carries the host's tab id as `id` and the local file id as `entityId`; the host close RPC resolves by id. const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( (tab) => tab.contentType === 'editor' && tab.entityId === fileId ) if (!unifiedTab) { return false } - // Why: this helper is imported by the editor slice during store creation. - // Importing web-runtime-session eagerly would import the store back and can - // trip cyclic initialization in full-suite test/import order. + // Record the close intent SYNCHRONOUSLY so a host snapshot landing before the dynamic import below resolves can't + // flash the old-path tab back. closeWebRuntimeSessionTab re-records it idempotently. + recordWebSessionCloseIntent(worktreeId, toHostSessionTabId(unifiedTab.id), Date.now()) + // Dynamic import: this helper is imported by the editor slice during store creation, so importing + // web-runtime-session eagerly imports the store back and trips cyclic init in full-suite import order. void import('./web-runtime-session').then(({ closeWebRuntimeSessionTab }) => closeWebRuntimeSessionTab({ worktreeId, diff --git a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts new file mode 100644 index 00000000000..e7ea518092e --- /dev/null +++ b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { OpenFilePathRekey } from './editor' +import type { Tab, TabGroup } from '../../../../shared/types' + +// Stage-1 foundation: rekeyOpenFilesForPathChange atomically retargets an open +// edit session across an Orca-owned move (no close/reopen), preserving all +// id-keyed state and failing closed on collision/stale without mutating. + +function seedEditTab(overrides: Partial> = {}): void { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, '20 min of work') + state.markFileDirty(id, true) + state.setLastKnownDiskSignature(id, 'sig-a') + useAppStore.setState((s) => ({ + editorCursorLine: { ...s.editorCursorLine, [id]: 42 }, + markdownViewMode: { ...s.markdownViewMode, [id]: 'rendered' } as never, + activeFileId: id, + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, 'wt-1': id }, + tabBarOrderByWorktree: { ...s.tabBarOrderByWorktree, 'wt-1': [id] }, + ...overrides + })) +} + +function rekeyFor(oldId: string, newPath: string, newRel: string): OpenFilePathRekey { + return { + oldFileId: oldId, + newFileId: newPath, // path-derived id for a local tab + oldFilePath: '/repo/a.md', + newFilePath: newPath, + newRelativePath: newRel + } +} + +describe('rekeyOpenFilesForPathChange', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('retargets the session preserving draft, dirty, baseline, cursor, view, active, tab bar', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + const newPath = '/repo/sub/a.md' + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')] + }) + + expect(result).toEqual({ ok: true }) + const s = useAppStore.getState() + const moved = s.openFiles[0]! + expect(moved.id).toBe(newPath) + expect(moved.filePath).toBe(newPath) + expect(moved.relativePath).toBe('sub/a.md') + expect(moved.isDirty).toBe(true) + expect(moved.lastKnownDiskSignature).toBe('sig-a') + // id-keyed state migrated to the new id, old key gone. + expect(s.editorDrafts[newPath]).toBe('20 min of work') + expect(s.editorDrafts[oldId]).toBeUndefined() + expect(s.editorCursorLine[newPath]).toBe(42) + expect(s.editorCursorLine[oldId]).toBeUndefined() + expect(s.markdownViewMode[newPath]).toBe('rendered') + expect(s.activeFileId).toBe(newPath) + expect(s.activeFileIdByWorktree['wt-1']).toBe(newPath) + expect(s.tabBarOrderByWorktree['wt-1']).toEqual([newPath]) + }) + + it('migrates unified tab entityId/id and group order', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + const tab: Tab = { + id: oldId, + entityId: oldId, + contentType: 'editor', + groupId: 'g1', + title: 'a.md' + } as never + const group: TabGroup = { + id: 'g1', + tabOrder: [oldId], + activeTabId: oldId, + recentTabIds: [oldId] + } as never + useAppStore.setState({ + unifiedTabsByWorktree: { 'wt-1': [tab] }, + groupsByWorktree: { 'wt-1': [group] } + } as never) + const newPath = '/repo/sub/a.md' + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')] + }) + + const s = useAppStore.getState() + const movedTab = s.unifiedTabsByWorktree['wt-1']![0]! + expect(movedTab.entityId).toBe(newPath) + expect(movedTab.id).toBe(newPath) + const movedGroup = s.groupsByWorktree['wt-1']![0]! + expect(movedGroup.tabOrder).toEqual([newPath]) + expect(movedGroup.activeTabId).toBe(newPath) + expect(movedGroup.recentTabIds).toEqual([newPath]) + }) + + it('rejects a collision with a distinct live session and mutates nothing', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + // A second, unrelated open tab already lives at the destination id. + useAppStore.getState().openFile( + { + filePath: '/repo/sub/a.md', + relativePath: 'sub/a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const before = useAppStore.getState().openFiles.map((f) => f.id) + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(result).toEqual({ ok: false, reason: 'collision' }) + expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) + }) + + it('reports stale when a source id is no longer open, mutating nothing', () => { + seedEditTab() + const before = useAppStore.getState().openFiles.map((f) => f.id) + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor('editor:gone', '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(result).toEqual({ ok: false, reason: 'stale' }) + expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) + }) + + it('consumes untitled status on an explicit rename', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit', + isUntitled: true + }, + { suppressActiveRuntimeFallback: true } + ) + const oldId = useAppStore.getState().openFiles[0]!.id + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [ + { + oldFileId: oldId, + newFileId: '/repo/named.md', + oldFilePath: '/repo/untitled.md', + newFilePath: '/repo/named.md', + newRelativePath: 'named.md', + consumeUntitled: true + } + ] + }) + + const moved = useAppStore.getState().openFiles[0]! + expect(moved.isUntitled).toBeUndefined() + expect(moved.filePath).toBe('/repo/named.md') + }) + + it('installs the move-echo gate + provenance on a dirty destination when moveOperationId is set', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + const newPath = '/repo/sub/a.md' + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')], + moveOperationId: 'op-42' + }) + + const moved = useAppStore.getState().openFiles[0]! + // Autosave is gated synchronously by the same commit that re-homes the tab. + expect(moved.pendingLiveDiskVerification).toBe(true) + expect(moved.pendingSelfMoveEcho).toEqual({ operationId: 'op-42', targetPath: newPath }) + }) + + it('does not gate a tab already showing the changed-on-disk banner', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + // A dirty tab that already conflicts with disk: autosave is suspended by the + // banner; gating it would strand the gate (verification skips a 'changed' tab). + useAppStore.setState((s) => ({ + openFiles: s.openFiles.map((f) => + f.id === oldId ? { ...f, externalMutation: 'changed' as const } : f + ) + })) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')], + moveOperationId: 'op-42' + }) + + const moved = useAppStore.getState().openFiles[0]! + expect(moved.externalMutation).toBe('changed') + expect(moved.pendingLiveDiskVerification).toBeUndefined() + expect(moved.pendingSelfMoveEcho).toBeUndefined() + }) + + it('migrates a pending editor reveal keyed by fileId to the new tab id', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + useAppStore.setState({ + pendingEditorReveal: { fileId: oldId, filePath: '/repo/a.md', line: 40, requestId: 1 } + } as never) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + const reveal = useAppStore.getState().pendingEditorReveal! + expect(reveal.fileId).toBe(useAppStore.getState().openFiles[0]!.id) + expect(reveal.filePath).toBe('/repo/sub/a.md') + }) + + it('does not gate a clean destination', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + useAppStore.getState().markFileDirty(oldId, false) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')], + moveOperationId: 'op-42' + }) + + const moved = useAppStore.getState().openFiles[0]! + expect(moved.pendingLiveDiskVerification).toBeUndefined() + expect(moved.pendingSelfMoveEcho).toBeUndefined() + }) + + it('detaches a moved tab from the host mirror so the snapshot cannot cull it', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + useAppStore.setState((s) => ({ + openFiles: s.openFiles.map((f) => + f.id === oldId ? { ...f, mirroredFromRuntimeSession: true } : f + ) + })) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(useAppStore.getState().openFiles[0]!.mirroredFromRuntimeSession).toBeUndefined() + }) + + it('migrates a pending editor reveal to the new path', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + useAppStore.setState({ + pendingEditorReveal: { filePath: '/repo/a.md', requestId: 1 } + } as never) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(useAppStore.getState().pendingEditorReveal?.filePath).toBe('/repo/sub/a.md') + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fcfffeb641e..df58668e752 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -235,6 +235,10 @@ export type OpenFile = { lastKnownDiskSignature?: string /** Why: gates autosave for restored dirty tabs until the conflict scan compares disk vs baseline, else a slow SSH read loses the race. Not persisted. */ pendingDiskBaselineVerification?: boolean + /** Why: gates autosave during a live self-move echo's disk verification; separate flag from the restored scan's so the two can't clear each other's gate. Not persisted. */ + pendingLiveDiskVerification?: boolean + /** Why: routes an Orca-owned move's destination-watcher echo into content verification. On the tab so it survives the atomic rekey; operationId supersedes a stale verification on re-move. Not persisted. */ + pendingSelfMoveEcho?: { operationId: string; targetPath: string } /** Why: diff bodies are cached in EditorPanel; bump this on re-select so the panel refetches instead of reusing a stale snapshot. */ diffContentReloadNonce?: number /** Why: bumping refetches clean tabs — the user's manual recovery when a remote watcher misses an external write. */ @@ -462,6 +466,15 @@ export type EditorSlice = { setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void setLastKnownDiskSignature: (fileId: string, signature: string) => void clearPendingDiskBaselineVerification: (fileId: string) => void + setPendingDiskBaselineVerification: (fileId: string, value: boolean) => void + setPendingLiveDiskVerification: (fileId: string, value: boolean) => void + clearSelfMoveEcho: (fileId: string) => void + /** Atomically retargets open editor sessions across an Orca-owned move — one commit-only update migrating every path-derived id + all id-keyed state, no close/reopen. Returns collision/stale without mutating. */ + rekeyOpenFilesForPathChange: (args: { + rekeys: readonly OpenFilePathRekey[] + /** When set, dirty autosave-capable destinations get move-echo provenance + a synchronous autosave gate so the watcher can content-verify the echo. */ + moveOperationId?: string + }) => RekeyOpenFilesResult clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -939,7 +952,7 @@ function isSameEditorOwner( ) } -function buildOwnedEditorFileId( +export function buildOwnedEditorFileId( filePath: string, worktreeId: string, runtimeEnvironmentId: string | null | undefined @@ -948,7 +961,7 @@ function buildOwnedEditorFileId( return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` } -function buildDiffEditorFileId( +export function buildDiffEditorFileId( worktreeId: string, diffSource: DiffSource, relativePath: string, @@ -1008,7 +1021,7 @@ function getReusableOpenFileModes(mode: OpenFile['mode']): readonly OpenFile['mo return [mode] } -function resolveEditorFileIdForOwner( +export function resolveEditorFileIdForOwner( state: Pick, filePath: string, worktreeId: string, @@ -1161,7 +1174,8 @@ function migrateHydratedEditorTabsAndGroups( } const tabIdMigrations = new Map() const nextTabs = tabs.map((tab) => { - if (tab.contentType !== 'editor') { + // Why: widened for the shared live-move rekey — a move retargets every editor-family tab (diff/conflict-review/check-details), not only plain 'editor'. + if (!isEditorTabContentType(tab.contentType)) { return tab } const nextId = idMigrations.get(tab.id) ?? tab.id @@ -1226,6 +1240,39 @@ function migrateHydratedEditorTabsAndGroups( } } +/** One tab's migration in an Orca-owned move; precomputed by the move coordinator. */ +export type OpenFilePathRekey = { + oldFileId: string + newFileId: string + oldFilePath: string + newFilePath: string + newRelativePath: string + newLanguage?: string + newMarkdownPreviewSourceFileId?: string + /** Explicit rename of an untitled file consumes its untitled status. */ + consumeUntitled?: boolean +} + +export type RekeyOpenFilesResult = { ok: true } | { ok: false; reason: 'collision' | 'stale' } + +function rekeyFileIdRecord( + record: Record, + migrations: ReadonlyMap +): Record { + let changed = false + const next: Record = {} + for (const [key, value] of Object.entries(record)) { + const mapped = migrations.get(key) + if (mapped !== undefined && mapped !== key) { + next[mapped] = value + changed = true + } else { + next[key] = value + } + } + return changed ? next : record +} + function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) @@ -2395,6 +2442,176 @@ export const createEditorSlice: StateCreator = (s } }), + setPendingDiskBaselineVerification: (fileId, value) => + set((s) => { + const file = s.openFiles.find((f) => f.id === fileId) + const next = value || undefined + if (!file || file.pendingDiskBaselineVerification === next) { + return s + } + return { + openFiles: s.openFiles.map((f) => + f.id === fileId ? { ...f, pendingDiskBaselineVerification: next } : f + ) + } + }), + + setPendingLiveDiskVerification: (fileId, value) => + set((s) => { + const file = s.openFiles.find((f) => f.id === fileId) + const next = value || undefined + if (!file || file.pendingLiveDiskVerification === next) { + return s + } + return { + openFiles: s.openFiles.map((f) => + f.id === fileId ? { ...f, pendingLiveDiskVerification: next } : f + ) + } + }), + + clearSelfMoveEcho: (fileId) => + set((s) => { + const file = s.openFiles.find((f) => f.id === fileId) + if (!file?.pendingSelfMoveEcho) { + return s + } + return { + openFiles: s.openFiles.map((f) => + f.id === fileId ? { ...f, pendingSelfMoveEcho: undefined } : f + ) + } + }), + + rekeyOpenFilesForPathChange: ({ rekeys, moveOperationId }) => { + if (rekeys.length === 0) { + return { ok: true } + } + let result: RekeyOpenFilesResult = { ok: true } + set((s) => { + const migrations = new Map() + const rekeyByOldId = new Map() + for (const rekey of rekeys) { + migrations.set(rekey.oldFileId, rekey.newFileId) + rekeyByOldId.set(rekey.oldFileId, rekey) + } + const openById = new Map(s.openFiles.map((f) => [f.id, f])) + + // Preflight (atomic with apply): every source still open, target ids unique, + // and no target id belongs to an UNAFFECTED live session (never merge two). + const seenNewIds = new Set() + for (const rekey of rekeys) { + if (!openById.has(rekey.oldFileId)) { + result = { ok: false, reason: 'stale' } + return s + } + if (seenNewIds.has(rekey.newFileId)) { + result = { ok: false, reason: 'collision' } + return s + } + seenNewIds.add(rekey.newFileId) + const occupier = openById.get(rekey.newFileId) + if (occupier && !migrations.has(occupier.id)) { + result = { ok: false, reason: 'collision' } + return s + } + } + + const nextOpenFiles = s.openFiles.map((f) => { + const rekey = rekeyByOldId.get(f.id) + if (!rekey) { + return f + } + // Spread the whole OpenFile so fields this action doesn't know about survive; change only the path-derived ones. + // Gate atomically here so autosave is suspended before any echo can be verified (only a dirty autosave-capable tab can be clobbered). + const gatesEcho = + moveOperationId !== undefined && + f.isDirty && + // A 'changed' tab is already autosave-suspended via externalMutation; gating it would strand the gate (verification skips a 'changed' tab), so leave the banner as terminal. + f.externalMutation !== 'changed' && + (f.mode === 'edit' || (f.mode === 'diff' && f.diffSource === 'unstaged')) + return { + ...f, + id: rekey.newFileId, + filePath: rekey.newFilePath, + relativePath: rekey.newRelativePath, + // A moved tab's id no longer matches the host snapshot, so leaving it host-owned would cull it (losing the draft); the coordinator close-notifies the host's old-path tab. (Re-homing the host tab in place is a follow-up.) + mirroredFromRuntimeSession: undefined, + ...(rekey.newLanguage !== undefined ? { language: rekey.newLanguage } : {}), + ...(rekey.newMarkdownPreviewSourceFileId !== undefined + ? { markdownPreviewSourceFileId: rekey.newMarkdownPreviewSourceFileId } + : {}), + ...(rekey.consumeUntitled + ? { isUntitled: undefined, deleteUntouchedOnClose: undefined } + : {}), + ...(gatesEcho + ? { + pendingLiveDiskVerification: true, + pendingSelfMoveEcho: { + operationId: moveOperationId, + targetPath: rekey.newFilePath + } + } + : {}) + } + }) + + const activeFileIdByWorktree: Record = {} + for (const [wtId, activeId] of Object.entries(s.activeFileIdByWorktree)) { + activeFileIdByWorktree[wtId] = activeId ? (migrations.get(activeId) ?? activeId) : activeId + } + + // Partition by each moved file's OWN worktree: the same path can be open in more than one worktree (e.g. a floating workspace), and tab-bar / group state is per-worktree. + const migrationsByWorktree: Record> = {} + for (const rekey of rekeys) { + const wtId = openById.get(rekey.oldFileId)!.worktreeId + ;(migrationsByWorktree[wtId] ??= new Map()).set(rekey.oldFileId, rekey.newFileId) + } + + const tabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + for (const [wtId, wtMigrations] of Object.entries(migrationsByWorktree)) { + const prevBarOrder = tabBarOrderByWorktree[wtId] + if (prevBarOrder) { + tabBarOrderByWorktree[wtId] = prevBarOrder.map((id) => wtMigrations.get(id) ?? id) + } + } + + const reveal = s.pendingEditorReveal + const rekeyForReveal = reveal + ? rekeys.find((r) => r.oldFilePath === reveal.filePath) + : undefined + + return { + openFiles: nextOpenFiles, + editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), + editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), + markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), + editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations), + markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations), + markdownTableOfContentsVisible: rekeyFileIdRecord( + s.markdownTableOfContentsVisible, + migrations + ), + activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null, + activeFileIdByWorktree, + tabBarOrderByWorktree, + ...migrateHydratedEditorTabsAndGroups(s, migrationsByWorktree), + ...(reveal && rekeyForReveal + ? { + pendingEditorReveal: { + ...reveal, + filePath: rekeyForReveal.newFilePath, + // matchesPendingEditorReveal prefers fileId, so migrate it too or + // the reveal would never match the rekeyed tab. + ...(reveal.fileId ? { fileId: migrations.get(reveal.fileId) ?? reveal.fileId } : {}) + } + } + : {}) + } + }) + return result + }, + clearUntitled: (fileId) => set((s) => ({ openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f)) From f9f3cd2fbecc64f6cb7648a73a7f9d58ad62c355 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 22:20:16 -0400 Subject: [PATCH 03/55] fix(terminal): prevent reconnect from killing live daemon sessions (#9804) --- config/reliability-gates.jsonc | 119 ++++ ...-daemon-generation-safety-investigation.md | 619 ++++++++++++++++++ .../app/h/[hostId]/session/[worktreeId].tsx | 5 +- src/main/daemon/daemon-init.test.ts | 75 +-- src/main/daemon/daemon-init.ts | 8 +- src/main/runtime/orca-runtime.test.ts | 528 +++++++++++++++ src/main/runtime/orca-runtime.ts | 199 +++++- .../rpc/methods/session-tab-close-methods.ts | 80 +++ .../rpc/methods/session-tabs-schemas.test.ts | 61 +- .../rpc/methods/session-tabs-schemas.ts | 12 + .../runtime/rpc/methods/session-tabs.test.ts | 119 ++++ src/main/runtime/rpc/methods/session-tabs.ts | 8 +- src/main/runtime/runtime-rpc.ts | 1 + src/renderer/src/components/Terminal.tsx | 11 +- .../useTabGroupWorkspaceModel.focus.test.ts | 3 +- .../tab-group/useTabGroupWorkspaceModel.ts | 6 +- .../TerminalPaneOverlayLayer.tsx | 1 + .../terminal-parked-tab-watchers.test.ts | 6 + .../terminal-parked-tab-watchers.ts | 5 + .../terminal-close-incarnation.test.ts | 24 + .../terminal/terminal-close-incarnation.ts | 8 + .../terminal/terminal-tab-actions.test.ts | 163 ++++- .../terminal/terminal-tab-actions.ts | 38 +- src/renderer/src/hooks/useIpcEvents.ts | 3 +- .../runtime/close-mirrored-editor-tab.test.ts | 3 +- .../src/runtime/close-mirrored-editor-tab.ts | 3 +- .../src/runtime/web-runtime-session.test.ts | 237 ++++++- .../src/runtime/web-runtime-session.ts | 102 ++- .../runtime/web-session-close-intent.test.ts | 61 +- .../src/runtime/web-session-close-intent.ts | 60 +- .../src/runtime/web-session-tabs-sync.test.ts | 57 +- .../src/runtime/web-session-tabs-sync.ts | 50 +- src/shared/runtime-types.ts | 19 + ...daemon-generation-reconnect-safety.spec.ts | 450 +++++++++++++ .../e2e/fixtures/daemon-generation-canary.cjs | 36 + .../daemon-generation-desktop-discovery.ts | 45 ++ tests/e2e/fixtures/daemon-generation-entry.ts | 90 +++ .../daemon-generation-fixture-contract.ts | 1 + .../daemon-generation-reconnect-client.ts | 297 +++++++++ .../daemon-generation-runtime-close.ts | 98 +++ .../helpers/daemon-generation-processes.ts | 178 +++++ .../daemon-generation-runtime-fixture.ts | 151 +++++ .../daemon-generation-safety-fixtures.ts | 243 +++++++ 43 files changed, 4139 insertions(+), 144 deletions(-) create mode 100644 docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md create mode 100644 src/main/runtime/rpc/methods/session-tab-close-methods.ts create mode 100644 src/renderer/src/components/terminal/terminal-close-incarnation.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-close-incarnation.ts create mode 100644 tests/e2e/daemon-generation-reconnect-safety.spec.ts create mode 100644 tests/e2e/fixtures/daemon-generation-canary.cjs create mode 100644 tests/e2e/fixtures/daemon-generation-desktop-discovery.ts create mode 100644 tests/e2e/fixtures/daemon-generation-entry.ts create mode 100644 tests/e2e/fixtures/daemon-generation-fixture-contract.ts create mode 100644 tests/e2e/fixtures/daemon-generation-reconnect-client.ts create mode 100644 tests/e2e/fixtures/daemon-generation-runtime-close.ts create mode 100644 tests/e2e/helpers/daemon-generation-processes.ts create mode 100644 tests/e2e/helpers/daemon-generation-runtime-fixture.ts create mode 100644 tests/e2e/helpers/daemon-generation-safety-fixtures.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3f473c12bf1..20535188c2c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -903,6 +903,125 @@ ], "demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority." }, + { + "id": "terminal-session.daemon-generation-reconnect-safety", + "title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-runtime-rpc-windows-daemon-contract", + "surfaces": [ + "runtime session reconnect", + "legacy daemon adoption", + "terminal lifecycle close", + "app relaunch and profile reconnect" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["daemon", "runtime", "ssh", "wsl"], + "coveredPlatforms": ["windows"], + "coveredProviders": ["daemon", "runtime"], + "coverageNotes": "A native Windows v21/v22/v23/v24/v25 named-pipe harness calls the production desktop legacy-adapter scanner and covers warm reattach, current-to-next upgrade, parallel clients, repeated reconnects, client relaunch, exact process identity, bounded failed disposal, and PTY-root/descendant survival. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, missing intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/9749", + "https://github.com/stablyai/orca/issues/8871", + "https://github.com/stablyai/orca/issues/9138", + "https://github.com/stablyai/orca/issues/9229" + ], + "invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.", + "oracle": "Start five isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v24 from a v25 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, explicit user close to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/methods/session-tabs.test.ts", + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts", + "src/renderer/src/runtime/web-runtime-session.test.ts", + "src/renderer/src/runtime/web-session-close-intent.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts", + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts", + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "tests/e2e/daemon-generation-reconnect-safety.spec.ts" + ], + "assertionRefs": [ + { + "file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts", + "assertions": [ + "the production scanner discovers v21/v22/v23/v24 from v25 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive", + "desktop and two remote profiles repeat lifecycle closes before and after client relaunch with zero session-killed events", + "shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "live, unknown, stale, missing-intent, and non-owner lifecycle closes invoke neither PTY kill nor renderer close", + "dead whole-headless retirement removes stale state without signalling a retained PTY id", + "explicit user close remains destructive" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-session.test.ts", + "assertions": [ + "lifecycle close uses the additive method with publication and terminal evidence", + "old-server method_not_found never falls back to destructive legacy close" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-close-intent.test.ts", + "assertions": [ + "identical worktree and tab ids in another runtime cannot suppress, reconcile, or clear this profile's intent" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "assertions": [ + "parked lifecycle closes carry the exact exiting PTY and cannot borrow a replacement or sibling incarnation" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-21", + "runner": "local", + "platform": "windows", + "command": "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 137.1, + "summary": "The full command, including a fresh Electron E2E build, passed. Production desktop discovery found v21/v22/v23/v24 from v25; all five daemons and all ten exact PTY-root/descendant canaries survived six repeated lifecycle attempts per stale mirror with zero session-killed events. Bounded shutdown-dispose-failed dropped pipe authority while its refusing daemon/root/descendant remained alive until exact fixture cleanup; no fixture directory remained." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local deterministic Windows evidence includes the final two-scenario pass and a separate 25-burst stress pass; CI and 14-day soak history are absent." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events." + }, + "performanceBudget": { + "required": true, + "evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded." + }, + "promotionCriteria": [ + "Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.", + "Add packaged Electron update/relaunch evidence with the same exact PTY survival oracle.", + "Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown." + ], + "knownGaps": [ + "The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.", + "Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.", + "Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.", + "A dead split leaf stays with its authoritative owner rather than being remotely pruned." + ], + "demotionRule": "Keep experimental or quarantine if reconnect emits session-killed for a live canary, an exact root/descendant dies, an old server receives fallback destructive close, cleanup leaks a fixture process/pipe, or the gate flakes without a proven harness defect." + }, { "id": "terminal-session.startup-cwd-missing-dir-recovery", "title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd", diff --git a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md new file mode 100644 index 00000000000..819269d1efa --- /dev/null +++ b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md @@ -0,0 +1,619 @@ +# Windows daemon-generation safety investigation (#9749) + +Investigation and completion snapshot: 2026-07-21 16:59 PDT +(2026-07-21 23:59 UTC) + +Branch baseline: `OrcaWin/issue-9749-windows-daemon-generation-safety` at +`937a2015e`, 40 commits after `v1.4.148-rc.1`. This document is the hard gate +before a reproduction harness or production change. GitHub access during the +investigation was read-only, and no installed Orca daemon, pipe, token, process, +or terminal session was contacted or changed. + +## Findings + +The reported incident is a composition of three lifecycle paths, not one: + +1. **Survival and adoption:** normal app quit deliberately disconnects from + daemon clients without shutting down their PTYs. Protocol-specific named + pipes let multiple generations coexist, and legacy adapters intentionally + reconnect to them so live PTYs remain warm and reattachable across upgrades. +2. **Broad current-generation replacement:** + `cleanupDaemonForProtocol` sends the only production + `shutdown { killSessions: true }` request found in the repository. Its + production callers replace or explicitly restart the current generation; + legacy discovery does not call it. A failed `listSessions` is currently + converted to an empty list before this broad shutdown, which makes its + reported kill count untrustworthy but does not suppress the shutdown. +3. **Reconnect-triggered per-session destruction:** #8871 provides the strongest + causal evidence. A reconnecting renderer restores stale remote handles, + synthesizes `pty-exit`, drops that reason at `session.tabs.close`, and the + authoritative host interprets the close as user intent. Its PTY router then + forwards individual `kill` requests to whichever current or legacy adapter + claimed each reusable session ID during discovery. + +`client-hello` is necessary to discover and route legacy sessions, but neither +the hello handler nor legacy discovery sends a kill or shutdown request. The +~3.3-second renderer-bootstrap-to-kill evidence in #8871, repeated renderer +spawn/burst correlations, and the untyped close path are substantially stronger +than #9749's inference that greeting an old pipe causes that daemon to kill its +table autonomously. + +The immediate safety boundary is therefore destructive request authority and +provenance. Cross-profile, sleeping-session, and generation-retirement policy +remains the larger #9138/#9229 problem. + +## Deterministic native-Windows reproduction + +Reproduction snapshot: 2026-07-21 14:36 PDT (2026-07-21 21:36 UTC). + +`tests/e2e/daemon-generation-reconnect-safety.spec.ts` now constructs an +isolated `orca-9749-dg-*` runtime under the Windows temporary directory. It +starts three real daemon-server processes on versioned v21/v22/v23 named +pipes, with a live canary and a stale-mirror canary in each generation. Each +canary records the PTY-root and descendant PID/start identity independently. +The fixture refuses cleanup outside its exact temporary root and terminates +only recorded fixture process incarnations; it never enumerates or connects to +installed Orca endpoints. + +The Electron-as-Node reconnect client performs three production +`DaemonPtyAdapter`/`DaemonPtyRouter.discoverLegacySessions` bursts, reattaches +every original PID, pings every canary, and opens a simultaneous second client +to each generation. It then sends duplicate `session.tabs.close` RPCs carrying +`reason: 'pty-exit'` through the real schema, `OrcaRuntimeService`, and routed +daemon adapters while the reconnect-client process remains alive. + +The current-main run failed at the intended external invariant: + +- before the close, all three daemons, six PTY roots, and six descendants were + alive; +- each daemon accepted eight new control/stream hellos across the reconnect and + parallel-client cycles; +- v21, v22, and v23 each logged two `session-killed` requests for the same + stale-mirror session ID within 1–4 ms; +- all three stale-mirror PTY roots and descendants exited, while all three + unrelated live canaries, all daemon processes, and reconnect client PID 26876 + remained alive; +- the final assertion requires those stale-mirror roots to remain alive, so it + is RED before the fix and will become GREEN only when lifecycle-originated + closes are adjudicated non-destructively at the host. + +Focused command: + +```text +pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 +``` + +The captured per-generation timestamp/PID/hello/kill/liveness report is written +to the Playwright test output as `daemon-generation-reconnect-events.json`. + +## Release and protocol chronology + +| Release or branch | Date | Daemon protocol | Relevant lifecycle behavior | +| --------------------- | ---------- | --------------: | -------------------------------------------------------------------------------------------------------------------------- | +| `v1.4.141` | 2026-07-14 | 21 | Legacy adoption already intentional; #8871 observed here. | +| `v1.4.142` | 2026-07-15 | 22 | New versioned endpoint; v21 can remain reattachable. | +| `v1.4.143` | 2026-07-16 | 22 | #9138 macOS accumulation reported. | +| `v1.4.144` | 2026-07-17 | 22 | #9195 Windows survival reported. | +| `v1.4.145` | 2026-07-18 | 22 | No generation-retirement change. | +| `v1.4.146` | 2026-07-19 | 23 | #9749's old current generation; already contains #8661's continue-shutdown-on-dispose-failure behavior. | +| `v1.4.147` | 2026-07-20 | 24 | First stable release containing #9277: authenticated identity and atomic empty-daemon retirement. | +| `v1.4.148` | 2026-07-21 | 24 | Reporter current release; legacy v23 and older remain intentionally adoptable. | +| `main` at `937a2015e` | 2026-07-21 | 25 | Protocol bumped by #9651 (`cc44acaaa`) for PTY startup ingress; v24 is now legacy. No stable tag contains this commit yet. | + +`PREVIOUS_DAEMON_PROTOCOL_VERSIONS` is cumulative rather than one-version-only. +At the investigated `main`, it contains versions 1 through 24. + +## Investigation matrix + +Confidence is **proven** when the issue evidence and source/diff establish the +mechanism, **supported** when multiple observations fit a reachable source path, +and **suspected** where attribution is missing. + +| Item | State; dates; releases/protocols | Exact symptom | Mechanism and relevant code/commits | Shipped status; unresolved #9749 relevance; scope boundary | +| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#9749](https://github.com/stablyai/orca/issues/9749) | Open; 2026-07-21; Windows 11; daemon-host 1.4.146→1.4.148; old v21/v22/v23 and current v24 observed | Three surviving daemons were greeted at `16:41:32.277Z`–`.286Z`; later bursts logged 55/30/65/60/60 v22 kills and 10/32 v23 kills, including repeated IDs, while 191,266 main-process spans showed no coincident app death. One v23 broad `shutdown reason:rpc killSessions:true` and repeated `shutdown-dispose-failed` were present. | **Supported composition, reporter causality not proven.** Versioned legacy adoption explains hellos; `cleanupDaemonForProtocol` explains broad shutdown; #8871's stale-mirror close echo best explains reconnect-time individual kills. `daemon-server.ts` logs `session-killed` before awaiting `host.kill`, so repeated records prove repeated requests, not repeated physical death. | No fix shipped for the destructive initiator. #9277 mitigates empty v24+ accumulation only. Skipping all legacy hello or killing legacy generations on sight would destroy the promised warm-reattach behavior and is unsafe scope creep. | +| [#9138](https://github.com/stablyai/orca/issues/9138) | Open; 2026-07-17; macOS 1.4.143/v22; observed v18/v20/v21/v22 | UI showed 8 sessions while about 46 Claude processes remained; removing stale trees removed about 370 processes and reduced swap from 25 GB to under 5 GB. | **Proven accumulation, ownership policy deliberately conservative.** Normal disconnect, versioned endpoints, incomplete cross-profile ownership, sleeping/cold restore, and legacy wildcard claims prevent safe blind retirement. Prototype commit `7c915909b` implements an audit/journal/incarnation design but is unmerged. | No complete fix shipped. #9277 safely handles only empty current-generation v24+ daemons. The broader ownership journal, grace period, all-profile evidence, and enforcement rollout belong here, not the immediate #9749 close-authority patch. | +| [#9211](https://github.com/stablyai/orca/issues/9211) | Closed duplicate 2026-07-17; v21/v22 | A v21 daemon remained four days beside v22, and each app launch greeted both. | **Proven intentional legacy discovery with missing retirement.** `createLegacyDaemonAdapters` plus `discoverLegacySessions`. | Consolidated into #9138; no separate shipped fix. Same accumulation input as #9749, but not its destructive initiator. | +| [#9229](https://github.com/stablyai/orca/issues/9229) | Open P1; 2026-07-17; Linux headless | Failed shutdown left a 15.55 GiB/81-process old runtime beside a 1.21 GiB/4-process replacement. | **Supported reconciliation design.** Requires provenance, one destructive authority, exact daemon/session PID+start incarnations, complete evidence, and fail-closed missing reads. | Unshipped. Supplies invariants for later reconciliation. Implementing its full profile migration and journal in #9749 would be unsafe scope expansion. | +| [#9195](https://github.com/stablyai/orca/issues/9195) | Open; 2026-07-17; Windows 1.4.144/v22; comments through #9277 release read | `orca-terminal-daemon.exe` remains after app exit; later comments observed v21/v22/v23 and invisible sessions. | **Proven intentional for non-empty daemons.** Normal quit calls `disconnectDaemon`, not `shutdownDaemon`; the child is detached/unref'd. Empty accumulation was an unhandled gap before #9277. | #9277 shipped in 1.4.147 for empty v24+ only. Treating every survivor as a bug would regress warm reattachment. | +| [PR #9277](https://github.com/stablyai/orca/pull/9277) | Merged 2026-07-20 as `7adda25b0`; first stable `v1.4.147`; v23→v24 | Empty current-generation daemons accumulated after clean app disconnect or failed adoption. | **Proven by diff/tests.** Hello returns PID/start-time/launch-nonce identity; control/stream identity must match. `shutdownIfIdle` atomically checks one complete client, no unknown transport, no sessions, and no admission in flight. Shutdown fences admission before disposal. A two-minute watchdog covers only never-adopted startup. | Shipped in `v1.4.147-rc.4`, `v1.4.147`, `v1.4.148-rc.1`, and `v1.4.148`. It explicitly does not perform startup reaping, retire legacy daemons, infer ownership, or kill reattachable PTYs. Strong identity/capability foundation; partial mitigation only. | +| [#8871](https://github.com/stablyai/orca/issues/8871) | Open P0; 2026-07-15; first observed 1.4.141/v21; all corrections/comments read | First reconnect kill followed renderer bootstrap by 3.348 s. Three later fresh renderer processes correlated within about 1 s with bursts killing 4, 7, and 7 worktrees while app and daemon PIDs remained stable. | **Supported-to-proven request path.** Persisted remote handle → subscribe returns `no_connected_pty` → synthetic `pty-exit` → `closeTerminalTab(reason:'pty-exit')` → reason dropped from `session.tabs.close` → `closeMobileSessionTab` kills host PTY. Early pre-connect kills were correctly downgraded to unattributed. Close propagation intersects landed #8628 and later #8958. | Not fixed on main. This is the strongest immediate #9749 foundation. Trigger repair alone is insufficient; the authoritative host must adjudicate intent. | +| [PR #8872](https://github.com/stablyai/orca/pull/8872) | Open; 2026-07-15; head `bb69fac775`; rebased 2026-07-21 | Prevents mirror `pty-exit`/cleanup evidence from killing a host PTY while retaining real user close and dead-tab retirement. | **Actual diff/tests inspected.** Threads typed `user`/`pty-exit`/`cleanup`; host refuses non-user closes if any parent leaf has a connected PTY, republishes an unchanged snapshot with a guarded replay marker, and handles dead-leaf/live-sibling loops. Adds an optional daemon `kill intent:'auto'` guard, but intentionally sends no auto intent until protocol/capability negotiation exists. | Unshipped. The earlier Windows/WSL live matrix passed on an older head and was explicitly invalidated by the rebase, so native validation must be rerun. This is the best narrow safety foundation, but reasonless old clients remain destructive on a new host. | +| [PR #8888](https://github.com/stablyai/orca/pull/8888) | Open; 2026-07-15; head `b13235211`; reviews/follow-ups read | Ambiguous paired-runtime close RPCs could destroy host tabs with no requester attribution. | **Actual diff/tests inspected.** Default-denies intent-less paired-runtime `session.tabs.close`, `terminal.close`, and `terminal.closeTab`; validates user source/target, dedupes request IDs per device across reconnects, rate-limits, restricts create rollback to its connection, and traces device/connection/source/decision without bearer tokens. Adds local daemon-control client ID to `session-killed`. | Unshipped. Useful defense-in-depth and logging foundation. Its “old paired client gets successful no-op” behavior is safely conservative but needs explicit compatibility/product acceptance; taking the entire policy is broader than the smallest #9749 fix. | +| [#9414](https://github.com/stablyai/orca/issues/9414) | Open tracking issue; 2026-07-19 | Recognition-dependent terminal lifecycle actions can destroy or strand arbitrary processes. | **Proven design boundary.** Host owns adjudication; stale client transport state and agent-name recognition are not kill authority. Tracks #8872/#8888 and lifecycle-state work. | Unshipped tracker. Aligns with #9749 invariants; generic agent recognition work is separate scope. | +| [PR #8628](https://github.com/stablyai/orca/pull/8628) | Merged 2026-07-14 as `36cd8a334`; releases after that date | Tab close did not durably retire all associated terminal/session state. | **Actual merge diff inspected.** Centralized terminal retirement/ownership and ensured remote-owned closes reach the authoritative host. The remote call still carried no close reason, so lifecycle and user closes were indistinguishable. | Shipped and important context, not a regression to revert wholesale. #9749 must preserve its durable explicit-user close while adding authority. | +| [#8878](https://github.com/stablyai/orca/issues/8878) / [PR #9098](https://github.com/stablyai/orca/pull/9098) | Issue and PR open; 2026-07-15/17; PR head `9f3abd778` | A reconnecting paired client resumes a provider session still live on the host, producing duplicate TUIs. | **Proven companion feedback loop.** Client-local resume runs before authoritative mirror arrival; a #8871 kill also leaves a resume record. PR gates resume for runtime-owned worktrees while preserving the record. | Unshipped. Relevant reconnect stress case, but provider-session resume dedupe is not needed to stop #9749 kills. | +| [#9352](https://github.com/stablyai/orca/issues/9352) / [#9585](https://github.com/stablyai/orca/issues/9585) | Open; 2026-07-18/20; remote macOS and Windows | Closed or killed remote tabs return as `ptyId:null` phantoms; host snapshots keep dead terminal surfaces, and repeated host restarts accumulate them. | **Proven stale mirror/snapshot state.** Host `touchMobileSessionSnapshotsForPty` republishes the same tab; viewer mirrors every terminal surface. Remote transport also leaks the explicit-kill error. | Unshipped. Explains repeated stale close inputs and must be in stress coverage, but pruning dead surfaces is a separate lifecycle fix requiring sleeping-session care. | +| [#9217](https://github.com/stablyai/orca/issues/9217) / [#8970](https://github.com/stablyai/orca/issues/8970) | #9217 closed duplicate 2026-07-19; #8970 open; v1.4.143 | Agent/sidebar rows remain after local close or SSH relay connection loss. | **Proven UI/status lifecycle gaps.** SSH teardown intentionally preserves PTY ownership for reattach but omitted status clearing; renderer lacks a sweep. | Separate non-destructive roster cleanup. Combining sidebar cleanup with #9749 would be scope creep. | +| [#8851](https://github.com/stablyai/orca/issues/8851) / [PR #8825](https://github.com/stablyai/orca/pull/8825) | Issue closed; PR merged 2026-07-15 as `c8986ca52`; 1.4.143 notes | Finished named Claude children remained as idle sidebar rows. | **Proven Claude roster mechanism, not PTY lifecycle.** Working-only roster and hydration cleanup. | Shipped, but #8970 proves top-level/session residuals. Unrelated to destructive daemon authority. | +| [#8275](https://github.com/stablyai/orca/issues/8275) / [#8276](https://github.com/stablyai/orca/issues/8276) | #8275 open `cannot_repro`; #8276 closed duplicate; 2026-07-11; v20→v22 attempts | Rapid worktree removal was followed by daemon death and unrelated split panes exiting `-1`; no shutdown record. | **Different mechanism.** Shared daemon process dies during PTY teardown; current v22 attempt did not reproduce. | Not the #9749 initiator: #9749 daemons stay alive and log explicit shutdown/kill requests. Keep daemon-death regression coverage, but do not merge root causes. | +| [PR #8140](https://github.com/stablyai/orca/pull/8140) | Merged 2026-07-10 as `03a673708`/PR head `1710325e0`; fixes #8048 | Graceful then immediate Windows PTY teardown double-closed the same ConPTY handle and killed the shared daemon. | **Proven by actual native harness/diff.** `nodePtyKillIssued` makes later Windows force a no-op. Harness uses unique pipe/temp state, 25 victims, and a witness PTY/daemon survival assertion. | Shipped. Prevents one daemon-death path; does not authorize reconnect closes and does not retire descendants. Reuse its isolation/witness patterns only. | +| [PR #8284](https://github.com/stablyai/orca/pull/8284) | Closed unmerged 2026-07-16; head `ebe0b6523` | Proposed serial worktree PTY admission and verified fail-closed teardown across Windows/POSIX/SSH. | **Actual 50-file diff/tests inspected.** Retained shutdown ownership, admission fences, physical-exit proof, and Windows relay ConPTY ownership. | Contrary to the issue prompt, it did **not** merge. Landed work was split/superseded by #8661 and #8706. Its teardown patterns are useful, but adopting its broad branch is unsafe. | +| [PR #8661](https://github.com/stablyai/orca/pull/8661) | Merged before 1.4.146; key commit `a635ff9a7` | Disposal failure could prevent orderly runtime/daemon termination. | **Proven current source behavior.** Shutdown RPC catches `host.dispose`, logs `shutdown-dispose-failed`, then continues fencing, client destruction, and server close; resource disposal is retried. | Shipped before #9749. Corrects the report's inference that the failure necessarily leaves the endpoint authoritative forever. A native handle may still keep a process alive, but the server intends to close. | +| [#9045](https://github.com/stablyai/orca/issues/9045) | Open; 2026-07-16; Windows | Worktree deletion fails because agent descendants retain filesystem handles after PTY teardown. | **Supported descendant ownership gap.** Root PTY exit is not full tree exit on Windows. | Unresolved. It concerns cleanup after a legitimate kill, not who may initiate it. | +| [#9704](https://github.com/stablyai/orca/issues/9704) | Open; 2026-07-21; Windows 1.4.147/v24 | Killed PTY descendants survive (six trees/18 processes/~1.1 GB, later ~2 GB); runtime lists dead sessions as connected. | **Proven descendant leakage plus stale registry.** Explicitly asks for initiator attribution rather than assuming the kill was valid. | Unresolved. Different from #9749 because the root session was killed; #9704 concerns what survives afterward. | +| [PR #9752](https://github.com/stablyai/orca/pull/9752) | Open; 2026-07-21; head `6be947317` | Windows agent descendants survive explicit or natural PTY-root exit. | **Actual native patch and tests inspected.** Suspends recognized native-Windows agent ConPTY roots, creates/configures/assigns a kill-on-close Job Object before resume, owns the handle atomically, and falls back to direct-root termination. Plain terminals, WSL, POSIX, SSH relay, and unrelated sessions are excluded; Windows process-table commands are removed. | Unshipped. Correct post-authorization cleanup, explicitly not an initiator fix. Combining it into the #9749 authority patch would obscure causality; compose/test separately if it lands first. | +| [PR #9266](https://github.com/stablyai/orca/pull/9266) / [PR #9612](https://github.com/stablyai/orca/pull/9612) | Both open; 2026-07-18/20 | Alternative Windows descendant tree termination using process enumeration/taskkill-style sweeps. | **Competing cleanup approaches.** Carry PID-reuse, access-denied, cost, and partial-tree risks that native Job ownership avoids. | Unshipped. Do not duplicate inside #9749. | +| [PR #8706](https://github.com/stablyai/orca/pull/8706) | Merged 2026-07-15 as `40d015992`/merge `6dbeeda3e`; stable thereafter | POSIX agent descendants survived root teardown. | **Proven POSIX snapshot-before-root-kill.** Windows intentionally returns no snapshot. | Shipped for macOS/Linux only. No #9749 authority effect. | +| [#9193](https://github.com/stablyai/orca/issues/9193) / [PR #9288](https://github.com/stablyai/orca/pull/9288) | Both open; 2026-07-17/18 | `terminal close --tab` cannot address live floating/tabless PTYs; pane close can kill, and Windows may retain a stale entry. | **Proven addressing/registry gap.** PR routes tabless PTY through existing pane close. | Unshipped. Diagnostic and stale-registry relevance only; making more sessions closeable is not authority fencing. | +| [#9563](https://github.com/stablyai/orca/issues/9563) / [PR #9634](https://github.com/stablyai/orca/pull/9634) | Both open; 2026-07-20/21; headless macOS | LaunchAgent host invokes update, disconnects clients, old binary respawns, and ShipIt reports “App Still Running.” | **Supported updater/headless ownership race.** PR defers installation while serving headlessly. | Unshipped. Must be a relaunch scenario in the harness, but updater policy is separate from close authority. | +| [#8261](https://github.com/stablyai/orca/issues/8261) | Open; 2026-07-11; macOS/Linux headless comments | Silent update installation kills active PTYs; one headless log has `shutdown reason:rpc killSessions:true`. | **Supported broad shutdown during update.** | Unresolved adjacent caller/lifecycle context. Updater UX and install policy are scope creep; broad shutdown attribution is relevant. | +| [#8459](https://github.com/stablyai/orca/issues/8459) | Open; 2026-07-13 | Resource Manager labels live daemon sessions orphan from renderer-only evidence and bulk-kills them. | **Proven ownership-safety precedent.** Absence from one renderer is not authority. | Unshipped. Same invariant as #9749, different UI initiator. Do not couple UI resource-manager redesign. | +| [#8585](https://github.com/stablyai/orca/issues/8585) | Open; 2026-07-13; SSH relay | Failed relay `--connect` unlinks a socket while the old relay and PTYs remain alive. | **Different namespace/transport ownership leak.** | Unresolved. SSH regression consideration, not a native daemon-generation fix. | +| [#7783](https://github.com/stablyai/orca/issues/7783) | Open; 2026-07-08; macOS | Helper survives app quit with roughly 189 descendants. | **Supported historical survival/descendant leak.** | Unresolved adjacent accumulation evidence; no reconnect-kill attribution. | +| [#8457](https://github.com/stablyai/orca/issues/8457) | Open; 2026-07-13 | Headless serve and GUI relaunch ownership collide, interrupting or duplicating live agents. | **Supported multi-owner lifecycle conflict.** | Reinforces exactly-one reconciliation authority. Full headless lifecycle redesign is separate. | +| [#8362](https://github.com/stablyai/orca/issues/8362) | Open; 2026-07-12; remote relay | PTY master FDs leak across relay children. | **Different mechanism: missing close-on-exec/inherited descriptors.** | Unresolved, but unrelated to named-pipe discovery or destructive requests. | +| [#9569](https://github.com/stablyai/orca/issues/9569) / [PR #9587](https://github.com/stablyai/orca/pull/9587) | Issue closed 2026-07-20; PR open | Worktree removal dials a dead legacy v22 socket after v23 upgrade and fails ENOENT. | **Proven stale adapter routing.** PR tolerates dead legacy adapter teardown. | Unshipped. Demonstrates adapter lifecycle staleness; no authority fix. | +| [#8689](https://github.com/stablyai/orca/issues/8689) / [PR #8697](https://github.com/stablyai/orca/pull/8697) | Closed/merged 2026-07-14; merge `840d3277d` | Daemon accepts a connection but never answers hello, wedging startup. | **Proven bounded handshake/replacement path.** | Shipped. Harness must bound hello and avoid reconnect storms; not a session-kill mechanism. | +| [PR #7538](https://github.com/stablyai/orca/pull/7538) | Merged 2026-07-07 as `03cfc5bd1` | Windows update moved daemon code while live daemon/PTYs should survive. | **Proven historical compatibility intent.** Relocated host preserves same-protocol daemon across update. | Shipped. Later legacy adapters extended preservation across protocol bumps; blanket reaping would regress this contract. | +| [PR #2974](https://github.com/stablyai/orca/pull/2974) | Merged 2026-05-28 as `5a852415a` | Resolver refresh risked killing live PTYs. | **Proven preserve-live policy.** Protocol bump/legacy routing is preferred to broad cleanup. | Shipped. Strong evidence against “kill old generation on sight.” | +| [PR #7836](https://github.com/stablyai/orca/pull/7836) | Merged 2026-07-18 as `5f6728c1b` | Shutdown/provider selection race could clear a binding while the daemon PTY survived. | **Proven ownership race fix.** Retains provider/shutdown ownership until outcome. | Shipped. Preserve in regression tests; not close-intent adjudication. | +| [PR #1343](https://github.com/stablyai/orca/pull/1343) | Merged 2026-05-03 as `df1fefcc2` | Users lacked session visibility; stale PID files risked PID-reuse mistakes. | **Proven management/PID-start-time guard.** | Shipped. Useful diagnostic/incarnation precedent, but observability alone cannot prevent #9749. | +| [PR #9516](https://github.com/stablyai/orca/pull/9516) | Merged 2026-07-20 as `2e67af82d`/`de86f482c` | Windows worktree teardown RPCs could hang indefinitely. | **Proven bounded-deadline change.** | Shipped. Bounds cleanup but does not decide whether cleanup is authorized. | +| [PR #8768](https://github.com/stablyai/orca/pull/8768) / [PR #8817](https://github.com/stablyai/orca/pull/8817) | Merged 2026-07-14/15 as `02de3c565` and `f7926c11f` | Restored/legacy PTYs could render blank or be unmounted while still live. | **Proven adoption compatibility.** Keep legacy daemon PTYs mounted and defer snapshots correctly. | Shipped. Direct reason that refusing all legacy discovery is unsafe. | +| [#9441](https://github.com/stablyai/orca/issues/9441) / [PR #9446](https://github.com/stablyai/orca/pull/9446) | Open; 2026-07-19; macOS 1.4.146 | Large persisted profile drives high CPU/RSS and exits during startup restoration; clean user-data does not reproduce. | **Supported restore-load ordering issue.** PR defers full worktree scan. | Unshipped. Profile-switch/load stress case only; not destructive daemon authority. | + +## Timestamped incident and call-flow reconstruction + +### A. How generations survive + +1. **2026-07-15 13:50 local:** #9749's v21 daemon starts. +2. An app quit/update runs `disconnectDaemon`, whose adapter `disconnectOnly` + closes client sockets and leaves live PTYs/history reattachable. The daemon is + detached/unref'd, so parent death is not daemon death. +3. **2026-07-18 14:26 local:** a v22 daemon starts on a different versioned + endpoint while v21 retains its PTYs. +4. **2026-07-20 14:50 local:** v23 starts while both prior endpoints remain. +5. Windows endpoints are generated by + `getDaemonSocketPath(runtimeDir, protocolVersion)` as + `\\?\pipe\orca-terminal-host-v-`. + Tokens and PID records are likewise protocol-specific files. No endpoint + collision forces an old generation out. +6. Before protocol 24, a non-empty daemon had no generation-retirement + protocol. Since #9277, an empty v24+ daemon can atomically self-retire, but a + live session intentionally blocks it and v23-or-older behavior is unchanged. +7. #9749's claim that `shutdown-dispose-failed` necessarily leaves the pipe open + is not source-proven for 1.4.146. Commit `a635ff9a7` catches disposal failure + and continues ordinary shutdown; #9277 additionally closes admission first. + A stuck native handle may keep a process alive, but the endpoint is meant to + stop being authoritative. + +### B. Why startup greets every surviving generation + +1. Current startup establishes the v25 (v24 in the reporter build) adapter and + its complete control/stream lifecycle lease. +2. `createLegacyDaemonAdapters(runtimeDir)` loops every value in + `PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, derives that version's pipe/token/PID + paths, and probes each endpoint. +3. A responsive endpoint receives a `DaemonPtyAdapter` configured with that + exact old protocol. It has no respawn callback, because new code must not + recreate old environment semantics. +4. `DaemonPtyRouter.discoverLegacySessions()` calls `adapter.listProcesses()` + for each legacy adapter. +5. `listProcesses()` calls `ensureConnected()`. `DaemonClient.doConnect()` opens + a control socket, sends hello, then opens a stream socket and sends hello + using one client UUID. This is the precise source of each control/stream pair + in #9749 at `16:41:32.277Z` through `.286Z`. +6. v24+ hellos return PID/start-time/launch-nonce and require both sockets to + match. Old protocols return no identity, so successful token+protocol hello + authenticates the endpoint but cannot prove a process incarnation. +7. `listSessions` results populate `sessionAdapters: Map`. + That map is keyed only by reusable session ID, not daemon/session + incarnation; a later generation can overwrite an earlier claim. +8. No code in hello acceptance, adapter construction, or discovery sends + `kill`, `shutdown`, or `shutdownIfIdle` to a legacy daemon. + +### C. Exact broad shutdown caller + +1. Current-daemon replacement/manual restart/full cleanup calls + `cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)` (or the explicitly + supplied current handle protocol). +2. It probes that version's endpoint and creates a `DaemonClient` for the same + protocol. +3. It connects with the same control/stream hello sequence. +4. It requests `listSessions`; any error is converted to `{ sessions: [] }`. +5. It then unconditionally sends `shutdown { killSessions: true }` and treats a + reply race with daemon exit as success. +6. `daemon-server.ts` logs `shutdown reason:'rpc' killSessions:true`, begins the + ordinary shutdown admission fence, awaits `host.dispose`, logs but catches + `shutdown-dispose-failed`, writes the reply if possible, disposes resources, + destroys clients/transports, unlinks owned identity artifacts, and closes + the server. +7. Repository search found no other direct production sender of + `shutdown { killSessions: true }`. Legacy adapter discovery is not a caller. +8. Unsafe residual: list failure cannot be treated as proof of emptiness for + any future generation reaper, and broad shutdown still lacks an origin, + daemon-incarnation, ownership, and intent audit record. + +### D. Supported reconnect-to-mass-kill path + +1. **Renderer bootstrap T+0:** a paired desktop/runtime renderer hydrates + persisted mirrors containing process-lifetime remote terminal handles. +2. **T+milliseconds:** before the fresh authoritative session snapshot fully + reconciles, remote transport subscribes using a stale handle. +3. The host cannot resolve a connected PTY for that handle and returns a gone + condition; remote transport synthesizes `pty-exit`. +4. `Terminal.tsx` calls `closeTerminalTab(tabId, { reason: 'pty-exit' })`. +5. Current main uses that reason for local retirement behavior but + `closeWebRuntimeSessionTab` sends only `{ worktree, tabId }`. +6. Runtime RPC `session.tabs.close` has no origin/intent in its schema or + handler. `OrcaRuntimeService.closeMobileSessionTab` treats it like an + explicit user request and invokes either direct `ptyController.kill`, a + whole-parent renderer close, or headless teardown. +7. For daemon-backed terminals, the PTY controller is the `DaemonPtyRouter`. + `adapterFor(sessionId)` uses the discovery map to select a current or legacy + generation and its adapter sends `kill { sessionId, immediate }`. +8. The owning daemon logs `session-killed` before awaiting `host.kill`. This is + why app and daemon PIDs remain alive while real terminal sessions disappear. +9. **#8871 observed T+3.348 s** for the first bootstrap-to-kill incident. Three + other new renderer processes aligned within about one second with 4-, 7-, + and 7-worktree kill bursts. +10. Stale host tab surfaces (#9352/#9585), persisted client mirrors, reconnect + retries, and republished snapshots can invoke the same close again. If the + daemon still retains the session entry or physical exit is unresolved, a + reconnect can rediscover and target the same ID again. Because logging + precedes the awaited kill, repeated `session-killed` records can also be + repeated failed/not-yet-settled attempts; they do not prove a dead process + was resurrected and killed twice. + +### E. Why adjacent bugs are different + +- **#8275/#8048/#8140:** PTY teardown double-closes a Windows ConPTY native + handle and the shared daemon dies. There is no broad shutdown event. All + sessions disappear because their owner process died. +- **#9704/#9045/#9752:** a valid or invalid kill has already targeted the PTY + root, but Windows descendants remain and retain memory/files. The owner stays + alive or the registry stays stale. This is cleanup completeness after a kill, + not destructive authority. +- **#9749/#8871:** app and daemons remain alive. Explicit RPCs reach live daemon + sessions because stale client lifecycle evidence is interpreted as intent. + +## Compatibility behavior that must remain + +- App quit is a disconnect, not terminal shutdown. +- Same-protocol daemon survival across packaged Windows updates (#7538) avoids + terminating live work. +- Old-protocol adapters remain addressable after an upgrade so mounted and + sleeping PTYs can reattach (#2974, #8768, #8817). +- Legacy daemons cannot be judged empty from one current profile or renderer. + Missing ownership data, inactive profiles, sleeping sessions, remote/SSH + routes, and legacy claims all mean keep/audit. +- Current protocol 24+ can retire only after its own atomic server-side idle + predicate proves there is nothing to preserve. +- Older clients/servers must not infer support from an ignored additive field. + In particular, no caller may send daemon `kill intent:'auto'` until a + protocol bump or negotiated capability proves the daemon will enforce it. + +## Direction comparison + +| Direction | Immediate safety | Compatibility and failure mode | Decision | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Skip `client-hello` for every old protocol | Avoids routing kills to legacy sessions, but also makes their live PTYs invisible/unattachable. | Directly violates warm legacy reattachment and converts preservation into inaccessible leaks. | Reject. Hello is not the authority bug. | +| Force-exit on `shutdown-dispose-failed` | Bounds a half-shutdown process if endpoint fencing and handle cleanup are correct. | Can silently destroy promised live PTYs; does not stop reconnect close RPCs; overlaps #9752 descendant semantics. Current source already continues server shutdown after disposal error. | Not the immediate fix. Test bounded endpoint loss separately. | +| Startup reaping | Can reduce accumulated attack surface. | Unsafe without #9138/#9229 all-profile evidence, exact incarnations, grace observations, barriers, and one authority. | Audit-only follow-up, not #9749 patch. | +| Explicit generation handoff/retirement | Correct long-term lifecycle model. | Requires old/new capability negotiation and ownership persistence across profiles, SSH, WSL, and sleeping sessions. | Broader #9138/#9229 work. | +| Daemon-side fencing | Prevents a retiring daemon from accepting new work or acting after retirement begins. | v24 #9277 already fences ordinary/idle shutdown admission; older protocols cannot be retrofitted. | Preserve and extend only with negotiated capability. | +| Host close-intent adjudication (#8872) | Stops the demonstrated stale `pty-exit` echo at the component that owns the live PTY, while real user close and genuinely dead tab retirement still work. | Old clients remain ambiguous unless paired with a default-deny compatibility policy. | Best narrow foundation. | +| Connection/device provenance policy (#8888) | Default-denies ambiguous paired-runtime destruction, adds dedupe/rate bounds and strong attribution. | Older paired clients receive a safe successful no-op; policy is broader than one close reason and must preserve mobile/CLI/local/SSH semantics. | Reuse focused policy/logging pieces; assess full policy after the failing harness. | +| Job Objects (#9752) | Reaps descendants after an authorized native-Windows agent PTY kill. | Cannot determine intent; plain terminal and WSL exclusions are semantically important. | Compose separately, never substitute for authority. | + +The smallest robust immediate change is expected to combine typed close intent +with host-side liveness adjudication and non-secret requester logging. The +three-generation harness must decide whether #8888's default-deny/dedupe layer +is also required for old-client and repeated-reconnect safety. Full generation +retirement stays in #9138/#9229. + +## Harness gate for the next phase + +The first executable artifact will be an isolated native-Windows harness, not a +production edit. It must: + +- allocate a temporary runtime/user-data root and unique pipe namespace; +- launch three disposable protocol fixtures (for example v21/v22/v23) with + distinct live canary children plus stale/terminating table entries; +- use the real `DaemonClient`, legacy adapter discovery, router, and runtime + session-close path where feasible instead of calling a proposed helper; +- prove hello/discovery alone is non-destructive; +- separately trigger the exact broad current-protocol shutdown caller and the + reconnect stale-mirror close path; +- distinguish daemon/app liveness, PTY-root liveness, and descendant liveness + with independent counters and process handles; +- repeat reconnect bursts and record duplicate requests for a retained ID; +- model a PTY owner whose disposal never proves physical exit and assert a + bounded, non-authoritative endpoint state; +- cover current→next protocol upgrade, same-version quit/relaunch, profile + switch, remote-server paired client, multiple simultaneous clients, mixed + client/server versions, and WSL/SSH routing boundaries; +- validate PID/start identity, dead parent, access-denied identity probes, no + listener/pipe/handle leaks, and no CIM/PowerShell per-session hot path; +- guarantee cleanup in `finally` using only fixture-owned exact PIDs, handles, + files, and pipe names. + +Externally visible RED invariants are: reconnect kills one or more canary PTYs +while the app/fixture processes remain alive; repeated stale input can issue +another destructive request for the same retained incarnation; and a refused +disposal does not reach a bounded terminal endpoint state. The post-fix GREEN +invariants are survival of every unrelated canary, one exact kill for an +explicit user target, legacy warm reattachment, bounded retirement fencing, and +zero leaked fixture processes/handles/pipes. + +## Implemented immediate safety boundary + +Implementation snapshot: 2026-07-21 15:26 PDT (2026-07-21 22:26 UTC). + +The initial implementation used a 30-second capability cache keyed only by +runtime-environment ID. That was rejected before finalization: a positive +`status.get` result could outlive a server replacement under the same +environment, and the later destructive request had no proof that it reached the +generation that supplied the capability. + +The implemented boundary makes compatibility and identity validation atomic +with the lifecycle request: + +1. Explicit user closes remain on `session.tabs.close`, preserving the durable + host-owned close behavior shipped by #8628 and compatibility with old + servers that ignore the additive `reason:'user'` field. +2. Renderer-originated `pty-exit` and `cleanup` echoes use the additive + `session.tabs.closeLifecycle` method. An old server returns + `method_not_found` before entering its legacy destructive close handler. The + client clears optimistic close suppression and requests an authoritative + snapshot; it never falls back to `session.tabs.close`. +3. The lifecycle method requires both the `publicationEpoch` observed in the + host snapshot and the exact terminal handle observed for that tab. Missing + evidence means keep, refresh, and audit rather than kill. +4. The host refreshes its PTY records, rejects a different publication epoch, + rejects a terminal handle that no longer belongs to the addressed parent, + rejects when provider liveness is unavailable, and rejects while any parent + leaf still has a connected PTY. Refusals carry a bounded reason + (`stale-publication`, `stale-terminal`, `unknown-liveness`, + `live-host-pty`, or `retirement-owner`) and republish only when doing so + cannot create the known dead-leaf/live-sibling echo loop. +5. A lifecycle close never signals a PTY or relays a renderer close. A dead + whole headless parent can be retired from persisted/runtime state with + `killPtys:false`; a renderer-owned parent or partial split remains with its + authoritative owner. Thus a reusable tab ID or incomplete provider read + cannot become destructive authority. +6. Reasonless legacy-client closes on a new host are successful conservative + refusals with an authoritative replay. Older clients and servers therefore + degrade to a visible retained tab, not terminal loss. +7. Renderer close intents are scoped by runtime environment and worktree, and + terminal-incarnation evidence must match that exact runtime environment. + Identical tab/worktree IDs in another profile cannot suppress or authorize + this profile's retirement. + +The RPC span records origin/client kind, close reason, connection ID, request +ID, publication epoch, and allow/refusal decision. It does not record terminal +contents, authentication tokens, or environment secrets. The legacy adapter +hello/discovery path, warm PTY adoption, daemon shutdown protocol, and Job +Object descendant cleanup remain unchanged. + +This is the smallest immediate #9749 fix. Cross-profile generation inventory, +all-profile ownership evidence, sleeping-session policy, and explicit daemon +handoff/retirement remain #9138/#9229. Windows descendant reaping after an +authorized kill remains #9704/#9752. + +## GREEN evidence + +The post-fix native-Windows run completed at 2026-07-21 15:25 PDT: + +- v21/v22/v23 used distinct versioned named pipes under a disposable runtime + root, with six independent PTY-root/descendant canaries; +- three reconnect discovery bursts plus simultaneous clients opened the real + control/stream hello pairs without destructive side effects; +- desktop and two remote/profile connection identities attempted each stale + mirror retirement three times, then a full reconnect-client process + exit/relaunch repeated the same persisted IDs for six attempts total; +- the lifecycle requests traversed the production schema, dispatcher, + `OrcaRuntimeService`, and `DaemonPtyRouter` using publication/terminal + incarnation claims; +- every daemon, PTY root, and descendant remained alive while the app/client + process remained alive; +- the contained refusal-to-exit PTY produced `shutdown-dispose-failed`, lost + named-pipe authority within the bounded deadline, and was cleaned by exact + fixture-owned process identity; +- the reconnect/relaunch test passed in 42.9 seconds and the bounded-disposal + test passed in 15.2 seconds. + +Focused verification at this checkpoint: node and web typechecks passed; +changed-file `oxlint` and `git diff --check` passed; 181 focused renderer/RPC +tests passed; and 12 host close-adjudication tests passed (784 unrelated tests +filtered out). No PowerShell/CIM process-per-session path, polling loop, broad +installed-daemon discovery, or Job Object implementation was added. + +Additional validation completed at 2026-07-21 15:48 PDT: + +- the native generation harness passed 25 reconnect bursts in 52.1 seconds; + every v21/v22/v23 daemon and all six PTY-root/descendant canaries survived; +- the mixed-version daemon-lifecycle E2E kept the non-empty v22 daemon + reattachable while the empty current v24 daemon retired through #9277; +- a fresh `pnpm build:electron-vite` and a fresh paired-web-client build both + completed successfully; +- 182 remote-runtime, multi-client, remote-server parity, SSH-provider, WSL + host-context, and remote PTY transport tests passed; +- the complete runtime service/RPC group passed 884 of 885 tests. The lone + failure, `preserves existing badgeColor on runtime createRepo dedupe`, is an + existing Windows-only POSIX path expectation (`/tmp/...` versus + `\\tmp\\...`) in code untouched by this change; every close, remote, SSH, + and WSL case in that run passed; +- CLI typecheck, switch-independent changed-file lint, reliability gates, + max-lines ratchet, and `git diff --check` passed. The repository-wide + switch-exhaustiveness command is blocked by the pre-existing unmatched + `undefined | 'current' | 'duplicate'` cases in + `skill-freshness-group.tsx`, outside this diff. + +Two practical Electron runs exposed setup/teardown limitations without +contradicting the close-safety result: + +- `restart-restore-terminal-input.spec.ts` completed the clean-restart, live + daemon, restored-output, keyboard-input, and direct-input assertions, then + failed only in `RestartSession.dispose()` with `EPERM` deleting its isolated + profile. Restart Manager and Sysinternals Handle found no surviving file + lock after teardown; the exact fixture root deleted successfully later + without terminating a process. The same failure reproduced with the temp + root inside this worktree and with the production diff to + `orca-restart.ts` empty, so it is recorded as a fixture cleanup limitation, + not a session-liveness failure; +- the paired-browser navigation E2E built the web client and launched the + isolated desktop, but timed out before pairing because its host fixture + displayed `No workspaces found`. The deterministic multi-client runtime + integration passed; no terminal-close assertion failed in this E2E. + +All diagnostic downloads and worktree-local E2E temp roots were removed by +their exact verified paths. No installed Orca daemon pipe or real user terminal +was discovered, greeted, stopped, or mutated during these runs. + +## Internal review-until-clean and final native evidence + +The requested `$internal-review-until-clean` loop ran against merge base +`937a2015eaf85144d02848c5b6d4c09ecd423830`. Round 1 found and fixed four +in-scope safety defects: + +- lifecycle retirement could still relay a destructive renderer close or kill + retained/disconnected headless IDs; lifecycle requests now never signal a + process and only state-retire a dead whole headless parent; +- an unavailable/access-denied PTY inventory was treated like authoritative + absence; it now returns `unknown-liveness` and keeps/audits; +- pending close intent was keyed only by worktree and could cross-contaminate + two runtime profiles; it is now scoped and cleaned by environment/worktree; +- the harness accepted a two-second PID start-time tolerance; capture and + revalidation now compare the same CIM `CreationDate` exactly. + +The elegance pass also removed an unnecessary cached capability probe. The +additive method dispatch on the exact connection is the atomic compatibility +boundary; a cached positive result could outlive server replacement. The +performance pass found no production polling, process enumeration, listener, +or subprocess addition. The existing bounded controller inventory refresh is +unchanged in frequency, snapshot refreshes coalesce per environment/worktree, +and all added tracking maps have completion or ownership cleanup. Round 2 +re-interrogated the full diff and found no remaining proven in-scope issue. +After strengthening the native harness to invoke production desktop discovery, +round 3 found one Windows-only test defect: three scanner tests modeled a live +v9 endpoint through POSIX `existsSync` but let every Windows named-pipe probe +connect. Their socket mock now accepts only v9 and errors every other version; +all 133 daemon lifecycle tests pass. The subsequent full-diff review is clean. + +The latest native Windows run captured its event reconstruction at +2026-07-21 16:58 PDT (2026-07-21 23:58 UTC). It launched v21/v22/v23/v24/v25 +on isolated versioned named pipes, then called the same +`createLegacyDaemonAdapters` scanner as desktop startup: the v25 client found +exactly v21-v24. Every generation accepted 16 reconnect control/stream hellos; +desktop and two remote-profile paths sent six lifecycle attempts for each +persisted stale-mirror ID across process relaunch. All five daemons, all ten +PTY roots, and all ten descendants were alive afterward, with zero +`session-killed` events. This directly covers the v24→v25 current-to-next +upgrade boundary while keeping older generations reattachable. + +The bounded `shutdown-dispose-failed` scenario also passed and now separates +authority from liveness explicitly: the late connection failed after endpoint +fencing while the refusing daemon, PTY root, and descendant remained alive +until exact fixture cleanup. The full Playwright command, including a fresh +Electron E2E build, exited successfully in 137.1 seconds, and no +`orca-9749-dg-*` directory remained. A final no-rebuild rerun passed both +scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed +again in 24.0 seconds, and mixed-version retirement/live-session preservation +passed 2/2 in 23.1 seconds. + +Latest focused verification includes 14/14 host adjudication tests, 948/949 +focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/ +retirement/access-failure tests, all three typechecks, and 215 +remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote +PTY, and shared-control tests. The sole focused failure remains the untouched +Windows `/tmp` normalization baseline documented above. The experimental +`terminal-session.daemon-generation-reconnect-safety` reliability gate records +the invariant, RED/GREEN oracle, performance budget, promotion criteria, and +known platform/provider gaps. + +## Fresh PR review after current-main integration + +The explicitly requested post-PR `$internal-review-until-clean` pass completed +at 2026-07-21 18:02 PDT after merging `v1.4.150-rc.0` main, including the +remote-runtime network-recovery work from #9774. It found and fixed three +additional contract gaps: + +- a stale PTY-exit callback could borrow a replacement or sibling handle from + tab-wide state; lifecycle evidence now comes only from the exact callback PTY; +- the legacy close endpoint accepted lifecycle reasons without incarnation + evidence and refused old mobile close taps; it now accepts only explicit user + intent, with reasonless compatibility limited to authenticated mobile clients; +- keep-on-unknown preserved the PTY but could leave its client mirror hidden; + the host now republishes unchanged authority when inventory is unavailable. + +The PR feedback loop also corrected the bulk-close payload assertion and made +the fixture protocol list collision-safe. The final native no-build run passed +both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds, +mixed-version retirement passed, and practical Electron restart/input passed +two scenarios with one intentionally skipped wedge scenario. Current-main +remote recovery (112 tests), focused close/reconnect suites, all 14 host +adjudication cases, all typechecks, reliability gates, max-lines, changed lint, +formatting, and diff checks pass. The full lint command remains blocked only by +pre-existing current-main switch-exhaustiveness and localization findings; no +remaining in-scope review finding is open. + +## Additional clean review after `v1.4.150-rc.0` integration + +This additional requested `$internal-review-until-clean` pass completed at +2026-07-21 18:56 PDT (2026-07-22 01:56 UTC) against merge base +`4d0e3f51ce0325a8f4670b4074618494984ab63d`. Round 1 found and fixed four +in-scope evidence/performance gaps: + +- the native harness omitted production `DaemonPtyRouter.listProcesses`, so + its synthetic lifecycle closes could pass through `unknown-liveness`; it now + routes the production inventory, uses worktree-prefixed daemon session IDs, + and requires every close to return `live-host-pty` with a republished + snapshot; +- concurrent reconnect closes each started a full cross-generation PTY + inventory; the host now shares one in-flight inventory and a deterministic + count test proves two concurrent closes call `listProcesses` once; +- the renderer introduced a second snapshot-refresh map; it now reuses the + existing environment/worktree remote-session deduper used by PTY reconnect; +- the reliability gate omitted the parked-tab callback path; its exact exiting + PTY incarnation assertion is now part of the gate. + +The review also added a wire-compatibility assertion proving that the additive +`{ reason: 'user' }` field is stripped by the previous `ActivateTab` server +schema. The repeated elegance and performance interrogation found no remaining +avoidable infrastructure, polling, subprocess churn, listener/handle leak, or +unbounded reconnect work. Round 2 re-read the full production and fixture diff, +constructed stale-publication, stale-handle, concurrent-close, mixed-client, +and old-server failure paths, and found no remaining proven in-scope issue. + +Fresh verification includes all three typechecks; reliability-manifest, +max-lines, changed-file lint, formatting, and diff checks; 175 remote snapshot +and PTY transport tests; and the isolated lifecycle/reconnect adjudication +group. The strengthened native five-generation Windows harness passed after a +fresh Electron E2E build in 137.2 seconds, required `live-host-pty` for every +synthetic close, left every daemon/root/descendant alive, and left no +`orca-9749-dg-*` directory. The full focused gate reached 992/993 passing tests; +the only failure remains the untouched Windows `/tmp` versus `\\tmp` +`createRepo` baseline. The newly merged headless-update group reached 143/144 +passing tests plus 25 skips; its only failure is an untouched LF-only +source-text assertion that does not match CRLF on Windows. + +## Completion evidence matrix + +| Original acceptance requirement | Authoritative evidence | Status | +| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Complete recursive issue/PR/commit/tag investigation before production edits | The 41-row investigation matrix above, release chronology, and timestamped call-flow reconstruction cover every required starting item and every directly discovered related item. GitHub reads were completed before the first production edit. | Proven | +| Explain survival, generation discovery/hello, broad shutdown caller, repeated reconnect kills, adjacent-bug distinctions, and compatibility | Sections A–E identify `disconnectDaemon`, `createLegacyDaemonAdapters` → `discoverLegacySessions` → control/stream hello, `cleanupDaemonForProtocol`, the stale-handle `pty-exit` path, #8275 versus #9704, and the warm legacy reattach contract. | Proven | +| Isolated native-Windows multi-generation reproduction with external liveness oracle | `daemon-generation-reconnect-safety.spec.ts` uses an exact temporary user-data/runtime root, versioned pipe names, disposable ConPTY children, independent daemon/root/descendant PID-start identities, bounded output, and exact cleanup. The historical RED run above recorded real repeated `session-killed` events while daemon/client witnesses stayed alive. | Proven | +| Exercise the desktop discovery path and current→next mixed-version upgrade | The latest native run calls production `createLegacyDaemonAdapters`; v25 discovers exactly v21/v22/v23/v24, reattaches every original process incarnation, and leaves all five generations alive. | Proven | +| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) | +| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven | +| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven | +| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and reasonless clients. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven | +| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) | +| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven | +| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable | +| Practical Electron restart behavior | A real isolated Electron application created a daemon-backed terminal, wrote and restored output across clean app quit/relaunch, preserved the exact daemon PID, and accepted both keyboard and direct terminal input after reattachment. | Proven on native Windows (`electron-headless`, 24.0 s) | +| Internal review-until-clean and final gates | The original three review rounds and both additional PR review loops are clean. The latest loop fixed four oracle/performance/gate gaps, then completed a clean full-diff re-review. Typecheck, lint, format, reliability manifest, max-lines ratchet, Electron build, native harness, and focused suites pass apart from the documented untouched baselines. | Proven | +| Public GitHub and real daemon safety | No public GitHub mutation occurred. Fixture guards reject non-temporary roots and known Orca user-data paths; no installed pipe/token/session was discovered or contacted. | Proven | + +Live Linux SSH validation remains an explicit gap: the required throwaway +Docker target is unavailable on this runner (`docker` is not installed and no +Docker Desktop process or standard executable path exists), and `wsl.exe` +reports that WSL is not installed. A real remote or localhost was deliberately +not substituted. The Orca worktree comment was updated through the scoped +`worktree set --comment` CLI with the investigation, RED reproduction, +root-cause, implementation, native-validation, and clean-review milestones. +No terminal or daemon command was issued through the CLI, and no public GitHub +comment was made. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index dee14cdd387..8b032c3ad99 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4072,7 +4072,10 @@ export default function SessionScreen() { try { const response = await client.sendRequest('session.tabs.close', { worktree: `id:${worktreeId}`, - tabId: tab.id + tabId: tab.id, + // Why: a tapped tab close is explicit user intent; older hosts strip + // the unknown field and keep their legacy behavior. + reason: 'user' }) if (response.ok) { if (tab.type === 'terminal' && typeof tab.terminal === 'string') { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index d6b0da6327f..c1f72dbc63f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -441,6 +441,27 @@ function mockConnectedAdoptionClientOnce(): void { }) } +function mockOnlyDaemonSocketAlive(socketSuffix: string): void { + netConnectMock.mockImplementation((options?: { path?: string }) => { + const live = options?.path?.endsWith(socketSuffix) ?? false + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, callback: () => void) { + handlers[event]?.push(callback) + if ((live && event === 'connect') || (!live && event === 'error')) { + queueMicrotask(() => callback()) + } + return this + }, + removeListener(event: string, callback: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + destroy() {} + } + }) +} + describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { probeSocketExistsMock.mockReturnValue(false) @@ -575,23 +596,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => { const mod = await importFresh() probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) - netConnectMock.mockImplementation(() => { - const handlers: Record void)[]> = { connect: [], error: [] } - return { - on(event: string, cb: () => void) { - handlers[event]?.push(cb) - if (event === 'connect') { - queueMicrotask(() => cb()) - } - return this - }, - removeListener(event: string, cb: () => void) { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return this - }, - destroy() {} - } - }) + mockOnlyDaemonSocketAlive('daemon-v9.sock') let resolveDiscovery!: (sessions: { sessionId: string }[]) => void const discovery = new Promise<{ sessionId: string }[]>((resolve) => { resolveDiscovery = resolve @@ -622,23 +627,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockImplementation( (path?: string) => path?.endsWith('daemon-v9.sock') ?? false ) - netConnectMock.mockImplementation(() => { - const handlers: Record void)[]> = { connect: [], error: [] } - return { - on(event: string, callback: () => void) { - handlers[event]?.push(callback) - if (event === 'connect') { - queueMicrotask(() => callback()) - } - return this - }, - removeListener(event: string, callback: () => void) { - handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] - return this - }, - destroy() {} - } - }) + mockOnlyDaemonSocketAlive('daemon-v9.sock') const discoveryError = new Error('router subscription failed') const currentCleanupError = new Error('current cleanup failed') const legacyCleanupError = new Error('legacy cleanup failed') @@ -881,23 +870,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => { const mod = await importFresh() probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) - netConnectMock.mockImplementation(() => { - const handlers: Record void)[]> = { connect: [], error: [] } - return { - on(event: string, cb: () => void) { - handlers[event]?.push(cb) - if (event === 'connect') { - queueMicrotask(() => cb()) - } - return this - }, - removeListener(event: string, cb: () => void) { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return this - }, - destroy() {} - } - }) + mockOnlyDaemonSocketAlive('daemon-v9.sock') await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 0f0c9efad64..32e3baf9365 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -1020,7 +1020,11 @@ function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: numb } } -async function createLegacyDaemonAdapters(runtimeDir: string): Promise { +// Why: callers that own an isolated runtime namespace must keep discovery history out of app userData. +export async function createLegacyDaemonAdapters( + runtimeDir: string, + historyPath = getHistoryDir() +): Promise { const adapters: DaemonPtyAdapter[] = [] for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion) @@ -1055,7 +1059,7 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() }) + describe('close intent adjudication', () => { + // Shared setup: a renderer-adopted tab whose PTY the host sees alive. + function makeAdoptedLiveTabRuntime(): { + runtime: OrcaRuntimeService + getSession: () => WorkspaceSessionState + kill: ReturnType + closeTerminal: ReturnType + closeTerminalTab: ReturnType + listProcesses: ReturnType + processes: { id: string; cwd: string; title: string }[] + } { + const servePtyId = 'serve-live-1' + const processes = [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Live' }] + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: servePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Live Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(async () => {}) + const listProcesses = vi.fn(async () => processes) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Live Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: servePtyId + } + ] + }) + return { + runtime, + getSession, + kill, + closeTerminal, + closeTerminalTab, + listProcesses, + processes + } + } + + it.each(['pty-exit', 'cleanup'] as const)( + 'refuses a %s echoed close while the PTY is live and republishes the snapshot', + async (reason) => { + const { runtime, getSession, kill, closeTerminal, closeTerminalTab } = + makeAdoptedLiveTabRuntime() + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const events: { worktree: string; snapshotVersion: number; tabs: unknown[] }[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason + }) + + unsubscribe() + // No destructive branch may run: no PTY kill, no renderer close relay. + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'live-host-pty', + snapshotRepublished: true + }) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + // The snapshot is republished (version bumped) so the echoing client + // re-adds and re-attaches the still-live tab. + const republished = events.filter((event) => event.worktree === TEST_WORKTREE_ID) + expect(republished.length).toBeGreaterThan(0) + const last = republished.at(-1)! + expect(last.snapshotVersion).toBeGreaterThan(before.snapshotVersion) + expect( + last.tabs.some((tab) => (tab as { parentTabId?: string }).parentTabId === 'host-tab') + ).toBe(true) + } + ) + + it('coalesces a reconnect close burst onto one authoritative PTY inventory', async () => { + const { runtime, listProcesses, processes } = makeAdoptedLiveTabRuntime() + const inventory = deferred() + listProcesses.mockImplementation(() => inventory.promise) + + const closes = Promise.all([ + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit' + }), + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'cleanup' + }) + ]) + await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledTimes(1)) + inventory.resolve(processes) + + await expect(closes).resolves.toEqual([ + expect.objectContaining({ refused: true, refusalReason: 'live-host-pty' }), + expect.objectContaining({ refused: true, refusalReason: 'live-host-pty' }) + ]) + expect(listProcesses).toHaveBeenCalledTimes(1) + }) + + it('keeps an explicit user close destructive while the PTY is live', async () => { + const { runtime, closeTerminalTab } = makeAdoptedLiveTabRuntime() + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'user' + }) + + // Legacy whole-parent relay: the renderer close transaction still runs. + expect(closeTerminalTab).toHaveBeenCalledWith('host-tab') + }) + + it('keeps a reasonless legacy close and republishes its live mirror', async () => { + const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime() + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + const result = await runtime.refuseUnattributedMobileSessionTabClose( + `id:${TEST_WORKTREE_ID}`, + 'host-tab' + ) + const after = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + }) + expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + }) + + it('refuses a lifecycle close from a stale host publication', async () => { + const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime() + const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const terminal = current.tabs.find((tab) => tab.type === 'terminal') + if (!terminal || terminal.status !== 'ready') { + throw new Error('expected a ready terminal fixture') + } + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit', + expectedPublicationEpoch: 'stale-epoch', + expectedTerminalHandle: terminal.terminal + }) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'stale-publication', + snapshotRepublished: true + }) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + }) + + it('refuses a reused tab id that names a different terminal incarnation', async () => { + const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime() + const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit', + expectedPublicationEpoch: current.publicationEpoch, + expectedTerminalHandle: 'term-from-retired-incarnation' + }) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'stale-terminal', + snapshotRepublished: true + }) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + }) + + it('leaves dead renderer-owned retirement to the renderer without relaying a close', async () => { + const { runtime, processes, kill, closeTerminal, closeTerminalTab } = + makeAdoptedLiveTabRuntime() + const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const terminal = current.tabs.find((tab) => tab.type === 'terminal') + if (!terminal || terminal.status !== 'ready') { + throw new Error('expected a ready terminal fixture') + } + runtime.onPtyExit('serve-live-1', 0) + processes.length = 0 + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit', + expectedPublicationEpoch: current.publicationEpoch, + expectedTerminalHandle: terminal.terminal + }) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'retirement-owner' + }) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + }) + + function makeSplitLeafRuntime(): { + runtime: OrcaRuntimeService + getSession: () => WorkspaceSessionState + kill: ReturnType + closeTerminal: ReturnType + } { + const layout = makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'serve-left', + [HEADLESS_SECOND_LEAF_ID]: 'serve-right' + }) + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-left', + worktreeId: TEST_WORKTREE_ID, + title: 'Split Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { 'host-tab': layout } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ closeTerminal } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Split Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'serve-left', + paneTitle: 'L' + }, + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 2, + ptyId: 'serve-right', + paneTitle: 'R' + } + ] + }) + return { runtime, getSession, kill, closeTerminal } + } + + it('refuses a pty-exit echoed close of a live split leaf (direct-kill branch)', async () => { + const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime() + + await runtime.closeMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_SECOND_LEAF_ID}`, + { reason: 'pty-exit' } + ) + + expect(kill).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + + it('still kills a live split leaf for an explicit user close', async () => { + const { runtime, kill } = makeSplitLeafRuntime() + + await runtime.closeMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_SECOND_LEAF_ID}`, + { reason: 'user' } + ) + + expect(kill).toHaveBeenCalledWith('serve-right') + expect(kill).not.toHaveBeenCalledWith('serve-left') + }) + + it('refuses without republishing when the echoed leaf is dead but a sibling is live', async () => { + // Why: the only reachable close path for a single leaf destroys the whole + // parent (live sibling included), so the close must be refused — but a + // republish would re-add the dead leaf on the echoing client and feed a + // refuse→republish→re-echo loop. + const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime() + runtime.onPtyExit('serve-right', 0) + const events: { worktree: string }[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + const result = await runtime.closeMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_SECOND_LEAF_ID}`, + { reason: 'pty-exit' } + ) + + unsubscribe() + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'live-host-pty' + }) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(events.filter((event) => event.worktree === TEST_WORKTREE_ID)).toEqual([]) + }) + + it('refuses a pty-exit echoed close of a runtime-owned headless tab with a live PTY', async () => { + // Why: the headless close path kills every leaf PTY and de-persists the + // parent; an echo must not reach it while the host sees the PTY alive. + const servePtyId = 'serve-headless-live' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: servePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Serve Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ closeTerminal } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Serve Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: servePtyId, + paneTitle: 'A' + } + ] + }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit' + }) + + expect(kill).not.toHaveBeenCalled() + expect(closeTerminal).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + + it('retires a dead headless tab on a pty-exit echoed close', async () => { + // Why: headless hosts have no renderer pty-exit handling of their own; + // they rely on the client echo to retire genuinely dead tab records. + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit' + }) + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) + + it('retires a dead headless tab whose exited PTY still has a retained record', async () => { + // Why: onPtyExit keeps the disconnected record in ptysById for status and + // exit reads — the production state after a real exit. The gate must not + // read record presence as liveness or the dead tab never retires and the + // client echo loops. + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const processes: { id: string; cwd: string; title: string }[] = [ + { id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Shell' } + ] + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => processes + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + // Seed the connected PTY record from the controller listing, then let the + // process die: the record flips to disconnected but stays retained. + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + runtime.onPtyExit('persisted-pty', 0) + processes.length = 0 + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit' + }) + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + expect(kill).not.toHaveBeenCalled() + }) + + it('keeps a headless tab when the provider inventory is unavailable', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => { + throw new Error('access denied') + } + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'pty-exit' + }) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'unknown-liveness', + snapshotRepublished: true + }) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + expect(kill).not.toHaveBeenCalled() + }) + }) + it('builds mobile session agent launch commands on the runtime host', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6480b965570..8d911cc8f39 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -356,6 +356,7 @@ import type { RuntimeMarkdownSaveTabResult, RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionClientTab, + RuntimeMobileSessionTabCloseResult, RuntimeMobileSessionMarkdownTab, RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, @@ -366,6 +367,7 @@ import type { RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, + RuntimeSessionTabCloseReason, RuntimeBrowserDriverState, RuntimeTerminalDriverState, RuntimeSyncWindowGraph, @@ -2327,6 +2329,7 @@ export class OrcaRuntimeService { createMobileSessionTabsNotifyCoalescer((worktreeId) => this.notifyMobileSessionTabsChangedNow(worktreeId) ) + private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -4919,14 +4922,35 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null - ): Promise { + ): Promise | null> { + if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + const pending = this.pendingMobileSessionPtyInventoryRefresh + if (pending) { + return pending + } + // Why: reconnect exit bursts share one authoritative daemon inventory + // instead of multiplying a full cross-generation list RPC per stale tab. + const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { + if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { + this.pendingMobileSessionPtyInventoryRefresh = null + } + }) + this.pendingMobileSessionPtyInventoryRefresh = refresh + return refresh + } + return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + } + + private async performMobileSessionPtyRecordsRefresh( + targetWorktreeId: string | null + ): Promise | null> { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { - return + return null } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() - await this.refreshPtyWorktreeRecordsFromController( + return await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, isFloatingWorkspace ? targetWorktreeId : null ) @@ -5244,13 +5268,68 @@ export class OrcaRuntimeService { }) } - async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { + async refuseUnattributedMobileSessionTabClose( + worktreeSelector: string, + tabId: string + ): Promise { + const snapshot = await this.listMobileSessionTabs(worktreeSelector) + const tabExists = snapshot.tabs.some( + (candidate) => + candidate.id === tabId || + (candidate.type === 'terminal' && candidate.parentTabId === tabId) || + (candidate.type === 'browser' && candidate.browserWorkspaceId === tabId) + ) + if (!tabExists) { + throw new Error('tab_not_found') + } + // Why: a legacy client may already have hidden its mirror; a new snapshot + // restores it without granting an unattributed request destructive authority. + this.republishMobileSessionTabsSnapshot(snapshot.worktree) + return { + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + } + } + + async closeMobileSessionTab( + worktreeSelector: string, + tabId: string, + options: { + reason?: RuntimeSessionTabCloseReason + expectedPublicationEpoch?: string + expectedTerminalHandle?: string + } = {} + ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - await this.refreshMobileSessionPtyRecords() + const observedPtyIds = await this.refreshMobileSessionPtyRecords() const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) { + // Why: keep-on-unknown must also restore the mirror the caller already pruned. + this.republishMobileSessionTabsSnapshot(worktreeId) + return { + closed: true, + refused: true, + refusalReason: 'unknown-liveness', + ...(snapshot ? { snapshotRepublished: true as const } : {}) + } + } + if ( + options.expectedPublicationEpoch !== undefined && + snapshot?.publicationEpoch !== options.expectedPublicationEpoch + ) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return { + closed: true, + refused: true, + refusalReason: 'stale-publication', + ...(snapshot ? { snapshotRepublished: true as const } : {}) + } + } const tab = snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? snapshot?.tabs.find( @@ -5262,18 +5341,86 @@ export class OrcaRuntimeService { if (!tab) { throw new Error('tab_not_found') } + if (options.expectedTerminalHandle !== undefined) { + const terminalIncarnationMatches = + tab.type === 'terminal' && + snapshot!.tabs.some( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === tab.parentTabId && + this.getMobileSessionTerminalHandle(worktreeId, candidate) === + options.expectedTerminalHandle + ) + if (!terminalIncarnationMatches) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return { + closed: true, + refused: true, + refusalReason: 'stale-terminal', + snapshotRepublished: true + } + } + } if (tab.type === 'terminal') { const parentLeafCount = snapshot!.tabs.filter( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + // Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"), + // not authorization to kill. Every destructive branch below can take the + // whole parent down, so any live PTY under the parent means the echo is a + // transport artifact: refuse the close and republish the snapshot so the + // echoing client re-syncs and re-attaches. A reasonless close keeps + // legacy behavior — old clients send user closes without the field. + if (options.reason !== undefined && options.reason !== 'user') { + const parentLeaves = snapshot!.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ) + // Why: exited PTYs keep a disconnected record in ptysById for status + // reads (and a still-synced leaf retains its record), so record + // presence is not liveness — only `connected` counts, or a genuinely + // dead tab never retires and the echo loops forever. + const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => + this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true + if (parentLeaves.some(leafHasConnectedPty)) { + // Why: when the echo addresses a dead leaf under a live sibling we + // still refuse (every reachable close path below destroys the whole + // parent, live sibling included) but skip the republish — re-adding + // the dead leaf on the echoing client would feed an endless + // refuse→republish→re-echo cycle. + const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab) + if (!addressedDeadLeaf) { + this.republishMobileSessionTabsSnapshot(worktreeId) + } + // Why: both markers are skew-safe; clients must restore a mirror only + // when the host actually republished it, not for a dead leaf. + return { + closed: true, + refused: true, + refusalReason: 'live-host-pty', + ...(!addressedDeadLeaf ? { snapshotRepublished: true as const } : {}) + } + } + if (!closingWholeParent || this.tabs.has(tab.parentTabId)) { + // Why: only the renderer may retire its own tab or split leaf; a + // remote lifecycle echo must never cross that boundary into a kill. + return { + closed: true, + refused: true, + refusalReason: 'retirement-owner' + } + } + } // Why: a runtime-owned headless tab is absent from renderer state, so the // closeTerminalTab relay below would ack success without killing its PTY, // and syncMobileSessionTabs would republish the "closed" tab. Only bypass // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab, { + killPtys: options.reason === undefined || options.reason === 'user' + }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } @@ -5321,6 +5468,31 @@ export class OrcaRuntimeService { return { closed: true } } + // Why: a refused echoed close means the echoing client already pruned its + // local mirror. Bump the version and emit the unchanged snapshot so clients + // that dedupe by snapshotVersion re-add and re-attach the still-live tab. + private republishMobileSessionTabsSnapshot(worktreeId: string): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (snapshot) { + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + } + this.notifyMobileSessionTabsChanged(worktreeId) + } + + private getMobileSessionTerminalHandle( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): string | null { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + if (!pty) { + return null + } + return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + } + private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { // Why: this relay is advisory after main owns teardown; renderer failure must // not prevent the authoritative session flush or turn the close into failure. @@ -5424,7 +5596,8 @@ export class OrcaRuntimeService { private closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionTerminalTab + tab: RuntimeMobileSessionTerminalTab, + options: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) @@ -5451,8 +5624,10 @@ export class OrcaRuntimeService { ptyIdsToKill.add(ptyId) } } - for (const ptyId of ptyIdsToKill) { - this.ptyController?.kill(ptyId) + if (options.killPtys !== false) { + for (const ptyId of ptyIdsToKill) { + this.ptyController?.kill(ptyId) + } } const nextTabs = snapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { @@ -20846,13 +21021,15 @@ export class OrcaRuntimeService { if (!tabId) { throw new Error('terminal_tab_not_found') } - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + // Why: a handle-addressed CLI/automation close is an explicit intent, so + // it must stay destructive under the non-user close adjudication gate. + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { reason: 'user' }) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId, closeMode: 'tab', ptyKilled: false } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) - await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId) + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } } diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts new file mode 100644 index 00000000000..c7e4ca4ad0c --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -0,0 +1,80 @@ +import { withSpan } from '../../../observability/tracer' +import { defineMethod, type RpcAnyMethod } from '../core' +import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' + +export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'session.tabs.close', + params: CloseTab, + handler: async (params, context) => + withSpan( + 'runtime.session-tabs.close', + async (span) => { + if (!params.reason && context.clientKind !== 'mobile') { + const result = await context.runtime.refuseUnattributedMobileSessionTabClose( + params.worktree, + params.tabId + ) + span.setAttribute('decision', `refused-${result.refusalReason ?? 'missing-intent'}`) + return result + } + const result = await context.runtime.closeMobileSessionTab( + params.worktree, + params.tabId, + { reason: 'user' } + ) + span.setAttribute( + 'decision', + result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed' + ) + return result + }, + { + kind: 'client', + attributes: { + attribution: 'session-tab-close', + origin: context.clientKind ?? 'in-process', + closeReason: + params.reason ?? (context.clientKind === 'mobile' ? 'legacy-mobile-user' : 'missing'), + connectionGeneration: context.connectionId ?? 'in-process', + requestId: context.requestId ?? 'in-process' + } + } + ) + }), + defineMethod({ + name: 'session.tabs.closeLifecycle', + params: CloseLifecycleTab, + handler: async (params, context) => + withSpan( + 'runtime.session-tabs.close-lifecycle', + async (span) => { + const result = await context.runtime.closeMobileSessionTab( + params.worktree, + params.tabId, + { + reason: params.reason, + expectedPublicationEpoch: params.publicationEpoch, + expectedTerminalHandle: params.terminal + } + ) + span.setAttribute( + 'decision', + result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed' + ) + return result + }, + { + kind: 'client', + attributes: { + attribution: 'session-tab-lifecycle-close', + origin: context.clientKind ?? 'in-process', + closeReason: params.reason, + connectionGeneration: context.connectionId ?? 'in-process', + requestId: context.requestId ?? 'in-process', + publicationEpoch: params.publicationEpoch + } + } + ) + }) +] diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts index 0df074cbce3..c4a97c0400e 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ActivateTab, UpdatePaneLayout } from './session-tabs-schemas' +import { ActivateTab, CloseLifecycleTab, CloseTab, UpdatePaneLayout } from './session-tabs-schemas' const WT = 'id:wt' @@ -14,6 +14,65 @@ describe('ActivateTab.navigation', () => { }) }) +describe('CloseTab (session.tabs.close params)', () => { + it('accepts only explicit user intent on the legacy close method', () => { + const parsed = CloseTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'user' }) + expect(parsed).toMatchObject({ tabId: 'tab-1', reason: 'user' }) + expect(CloseTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'pty-exit' }).success).toBe( + false + ) + expect(CloseTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'cleanup' }).success).toBe( + false + ) + }) + + it('accepts a reasonless payload from legacy clients', () => { + // Why: parsing remains compatible; the RPC policy, not the schema, refuses missing intent. + const parsed = CloseTab.parse({ worktree: WT, tabId: 'tab-1' }) + expect(parsed.tabId).toBe('tab-1') + expect(parsed.reason).toBeUndefined() + }) + + it('keeps a new explicit-user payload parseable by the previous server schema', () => { + // Why: old hosts use ActivateTab here and must strip the additive field, + // not reject a manual close from an updated client. + const parsed = ActivateTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'user' }) + expect(parsed).toEqual({ worktree: WT, tabId: 'tab-1' }) + }) + + it('rejects an unknown close reason', () => { + expect(() => + CloseTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'transport-glitch' }) + ).toThrow() + }) +}) + +describe('CloseLifecycleTab (session.tabs.closeLifecycle params)', () => { + it('requires lifecycle intent and incarnation evidence', () => { + expect( + CloseLifecycleTab.parse({ + worktree: WT, + tabId: 'tab-1', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }) + ).toMatchObject({ reason: 'pty-exit', publicationEpoch: 'epoch-1', terminal: 'term-1' }) + expect( + CloseLifecycleTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'pty-exit' }).success + ).toBe(false) + expect( + CloseLifecycleTab.safeParse({ + worktree: WT, + tabId: 'tab-1', + reason: 'user', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }).success + ).toBe(false) + }) +}) + describe('UpdatePaneLayout.root (untrusted remote pane-layout tree)', () => { it('accepts a valid split tree', () => { const parsed = UpdatePaneLayout.parse({ diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 3d9f678695b..1e25e8a4b8d 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -27,6 +27,18 @@ export const ActivateTab = WorktreeTabSelector.extend({ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() }) +export const CloseTab = ActivateTab.extend({ + // Why: optional so legacy clients still validate; policy conservatively + // attributes only authenticated mobile callers as user intent. + reason: z.literal('user').optional() +}) + +export const CloseLifecycleTab = ActivateTab.extend({ + reason: z.enum(['pty-exit', 'cleanup']), + publicationEpoch: z.string().min(1).max(128), + terminal: z.string().min(1).max(256) +}) + export type TerminalPaneLayoutNodeInput = | { type: 'leaf'; leafId: string } | { diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 2fe42fcdc36..f37922c16cb 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -67,6 +67,125 @@ describe('session tab RPC methods', () => { }) }) + it('refuses a reasonless close without invoking destructive runtime logic', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ + closed: true, + refused: true, + snapshotRepublished: true + }), + closeMobileSessionTab: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { closed: true, refused: true, snapshotRepublished: true } + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') + expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled() + }) + + it('passes explicit user intent to host adjudication', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn(), + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.close', { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason: 'user' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason: 'user' + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() + }) + + it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn(), + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }), + (response) => replies.push(response), + { clientKind: 'mobile' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason: 'user' + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() + }) + + it.each(['pty-exit', 'cleanup'] as const)( + 'rejects %s on the legacy close endpoint before host adjudication', + async (reason) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + closeMobileSessionTab: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.close', { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled() + } + ) + + it.each(['pty-exit', 'cleanup'] as const)( + 'binds a %s lifecycle close to the observed publication and terminal', + async (reason) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.closeLifecycle', { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason, + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason, + expectedPublicationEpoch: 'epoch-1', + expectedTerminalHandle: 'term-1' + }) + } + ) + it('dispatches tab moves through the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 364f1b39301..7c4230c27b1 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -11,6 +11,7 @@ import { UpdatePaneLayout, WorktreeTabSelector } from './session-tabs-schemas' +import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ @@ -40,12 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ }) }) }), - defineMethod({ - name: 'session.tabs.close', - params: ActivateTab, - handler: async (params, { runtime }) => - runtime.closeMobileSessionTab(params.worktree, params.tabId) - }), + ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 584b0abc9d2..e11ae523fcf 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -320,6 +320,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'runtime.clientEvents.unsubscribe', 'session.tabs.activate', 'session.tabs.close', + 'session.tabs.closeLifecycle', 'session.tabs.createTerminal', 'session.tabs.list', 'session.tabs.listAll', diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f093cdcf95b..410b3bc8243 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1354,7 +1354,8 @@ function Terminal(): React.JSX.Element | null { void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) return } @@ -1408,7 +1409,7 @@ function Terminal(): React.JSX.Element | null { if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { return } - closeTerminalTab(tabId, { reason: 'pty-exit' }) + closeTerminalTab(tabId, { reason: 'pty-exit', lifecyclePtyId: ptyId }) }, [consumeSuppressedPtyExit] ) @@ -1445,7 +1446,8 @@ function Terminal(): React.JSX.Element | null { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } continue @@ -1509,7 +1511,8 @@ function Terminal(): React.JSX.Element | null { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } continue diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index da137c5d6af..a27baad0804 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -296,7 +296,8 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'terminal-2', - environmentId: 'env-1' + environmentId: 'env-1', + reason: 'user' }) }) diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index d775d0347c4..6dc3b0b36d1 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -253,7 +253,8 @@ export function useTabGroupWorkspaceModel({ void closeWebRuntimeSessionTab({ worktreeId, tabId: item.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) @@ -311,7 +312,8 @@ export function useTabGroupWorkspaceModel({ void closeWebRuntimeSessionTab({ worktreeId, tabId: item.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index b0cf7c20b4e..0bf780b8044 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -246,6 +246,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ } closeTerminalTab(terminalTabId, { reason: 'pty-exit', + lifecyclePtyId: ptyId, onClosed: leaveWorktreeIfEmpty }) }} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index d2a56f66bf7..ba963681a49 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -53,6 +53,8 @@ vi.mock('./pty-pre-handler-buffer', () => ({ type CloseTerminalTabOptions = { captureRecentlyClosed?: boolean + hostCloseReason?: string + lifecyclePtyId?: string onClosed?: () => void onCancel?: () => void } @@ -317,6 +319,10 @@ describe('terminal-parked-tab-watchers', () => { expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions expect(options.captureRecentlyClosed).toBe(false) + // Why: the wire must carry the pty-exit intent so a paired host can refuse + // the echo while its PTY is live, without skipping the pinned guard here. + expect(options.hostCloseReason).toBe('pty-exit') + expect(options.lifecyclePtyId).toBe(PTY_ID) options.onClosed?.() expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 581378cf40c..f7644935509 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -167,6 +167,11 @@ function startParkedTabWatchers( closeTerminalTab(tab.id, { // Why: autonomous PTY exit still needs pinned-tab confirmation but must not enter reopen history. captureRecentlyClosed: false, + // Why: same lifecycle echo as the mounted pty-exit handlers — tag the + // wire so the host can refuse it while its PTY is live, without + // `reason: 'pty-exit'` skipping the pinned confirmation above. + hostCloseReason: 'pty-exit', + lifecyclePtyId: ptyId, onClosed: () => { discardPreHandlerPtyState(ptyId) const entry = parkedWatchersByTabId.get(tab.id) diff --git a/src/renderer/src/components/terminal/terminal-close-incarnation.test.ts b/src/renderer/src/components/terminal/terminal-close-incarnation.test.ts new file mode 100644 index 00000000000..3cc0acf621a --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-incarnation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalIncarnationHandle } from './terminal-close-incarnation' + +describe('terminal close incarnation', () => { + it('selects only a terminal handle owned by the target runtime environment', () => { + expect(getTerminalIncarnationHandle('remote:target-env@@target-terminal', 'target-env')).toBe( + 'target-terminal' + ) + expect( + getTerminalIncarnationHandle('remote:other-env@@other-terminal', 'target-env') + ).toBeNull() + }) + + it('does not reinterpret native, WSL, or SSH identities as runtime authority', () => { + for (const ptyId of [ + 'remote:legacy-unscoped-handle', + 'native-pty', + 'wsl:Ubuntu:pty-1', + 'ssh:host:pty-2' + ]) { + expect(getTerminalIncarnationHandle(ptyId, 'target-env')).toBeNull() + } + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-incarnation.ts b/src/renderer/src/components/terminal/terminal-close-incarnation.ts new file mode 100644 index 00000000000..013712884d5 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-incarnation.ts @@ -0,0 +1,8 @@ +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +export function getTerminalIncarnationHandle(ptyId: string, environmentId: string): string | null { + const terminal = parseRemoteRuntimePtyId(ptyId) + if (terminal?.handle && terminal.environmentId === environmentId) { + return terminal.handle + } + return null +} diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 7c7bb511efc..76e71a0a9da 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -4,6 +4,7 @@ const { activateWebRuntimeSessionTabMock, closeWebRuntimeSessionTabMock, createWebRuntimeSessionTerminalMock, + getLatestWebSessionTabsPublicationEpochMock, getStateMock, isWebRuntimeSessionActiveMock, isWebTerminalSurfaceTabIdMock, @@ -13,6 +14,7 @@ const { activateWebRuntimeSessionTabMock: vi.fn(), closeWebRuntimeSessionTabMock: vi.fn(), createWebRuntimeSessionTerminalMock: vi.fn(), + getLatestWebSessionTabsPublicationEpochMock: vi.fn(() => 'epoch-1'), getStateMock: vi.fn(), isWebRuntimeSessionActiveMock: vi.fn(), isWebTerminalSurfaceTabIdMock: vi.fn(() => false), @@ -36,6 +38,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ })) vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: getLatestWebSessionTabsPublicationEpochMock, resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock })) @@ -204,7 +207,150 @@ describe('closeTerminalTab', () => { expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'host-tab-1', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' + }) + }) + + it('binds a pty-exit close to the observed host and terminal incarnation', () => { + const closeTab = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1') + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'local-tab-1', + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('local-tab-1', { + reason: 'pty-exit', + lifecyclePtyId: 'remote:web-runtime@@term-1' + }) + + // Local prune behavior is unchanged. + expect(closeTab).toHaveBeenCalledWith('local-tab-1', { + reason: 'pty-exit', + remoteCloseOwnedByHost: true + }) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'host-tab-1', + environmentId: 'web-runtime', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + }) + + it('does not borrow a replacement PTY handle for a stale exit callback', () => { + const closeTab = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1') + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }] + }, + ptyIdsByTabId: { 'local-tab-1': ['remote:web-runtime@@replacement-term'] }, + terminalLayoutsByTabId: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'local-tab-1', + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('local-tab-1', { + reason: 'pty-exit', + lifecyclePtyId: 'remote:web-runtime@@retired-term' + }) + + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith( + expect.objectContaining({ + terminalHandle: 'retired-term' + }) + ) + }) + + it('sends hostCloseReason on the wire without tagging the local close reason', () => { + // Why: parked-tab lifecycle closes must reach the host as 'pty-exit' so it + // can adjudicate them, while local guards keyed off `reason` still apply. + const closeTab = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1') + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'local-tab-1', + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('local-tab-1', { + hostCloseReason: 'pty-exit', + lifecyclePtyId: 'remote:web-runtime@@term-1' + }) + + expect(closeTab).toHaveBeenCalledWith('local-tab-1', { + reason: undefined, + remoteCloseOwnedByHost: true + }) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'host-tab-1', + environmentId: 'web-runtime', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + }) + + it('keeps the pinned confirmation guard for a hostCloseReason pty-exit close', () => { + const requestPinnedTabCloseConfirm = vi.fn() + const closeUnifiedTab = vi.fn() + getStateMock.mockReturnValue( + makePinnedTabState({ + confirmClosePinnedTab: true, + requestPinnedTabCloseConfirm, + closeUnifiedTab + }) + ) + + closeTerminalTab('pinned-entity-1', { hostCloseReason: 'pty-exit' }) + + expect(closeUnifiedTab).not.toHaveBeenCalled() + expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1) + }) + + it('marks a user action as explicit when no lifecycle reason is present', () => { + const closeTab = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1') + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'local-tab-1', + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('local-tab-1') + + const args = closeWebRuntimeSessionTabMock.mock.calls[0]?.[0] as Record + expect(args).toMatchObject({ + worktreeId: 'wt-1', + tabId: 'host-tab-1', + reason: 'user' }) }) @@ -333,7 +479,8 @@ describe('closeTerminalTab', () => { expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'plain-uuid-tab', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' }) }) @@ -592,12 +739,14 @@ describe('closeOtherTerminalTabs', () => { expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'close-a', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' }) expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'close-b', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' }) expect(closeTab).not.toHaveBeenCalled() }) @@ -633,12 +782,14 @@ describe('closeTerminalTabsToRight', () => { expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'term-b', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' }) expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'term-c', - environmentId: 'web-runtime' + environmentId: 'web-runtime', + reason: 'user' }) expect(closeFile).toHaveBeenCalledWith('file-b') expect(closeTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index ebd861e6952..1af5179be91 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -8,7 +8,10 @@ import { isWebRuntimeSessionActive, toHostSessionTabId } from '@/runtime/web-runtime-session' -import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' +import { + getLatestWebSessionTabsPublicationEpoch, + resolveHostSessionTabIdForWebSessionTab +} from '@/runtime/web-session-tabs-sync' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' import type { @@ -16,6 +19,7 @@ import type { TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' +import { getTerminalIncarnationHandle } from './terminal-close-incarnation' import { getWorktreeTerminalTabIds, resolveTerminalCloseTarget, @@ -51,6 +55,12 @@ export function closeTerminalTab( force?: boolean rejectPinned?: boolean reason?: TerminalTabCloseReason + /** Close reason sent to the host only. Unlike `reason`, it does not skip + * local guards (pinned confirmation keys off `reason === 'pty-exit'`), + * so lifecycle echoes that still need those guards can tag the wire. */ + hostCloseReason?: TerminalTabCloseReason + /** PTY whose lifecycle event initiated the host close. */ + lifecyclePtyId?: string captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -111,6 +121,15 @@ export function closeTerminalTab( worktreeId: owningWorktreeId, tabId: terminalTabId }) ?? toHostSessionTabId(terminalTabId) + const wireReason = options?.reason ?? options?.hostCloseReason ?? 'user' + const lifecycleTerminalHandle = + wireReason === 'user' + ? null + : getTerminalIncarnationHandle(options?.lifecyclePtyId ?? '', runtimeEnvironmentId) + const publicationEpoch = + wireReason === 'user' + ? null + : getLatestWebSessionTabsPublicationEpoch(runtimeEnvironmentId, owningWorktreeId) // Why: prune local mirrors immediately so close feels responsive while the // host session snapshot catches up. closeLocalTerminalTabState(terminalTabId, { @@ -129,7 +148,16 @@ export function closeTerminalTab( void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId: hostBackedTabId, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + // Why: lifecycle evidence binds this stale-prone echo to the exact host + // publication and terminal incarnation that the renderer observed. + reason: wireReason, + ...(wireReason !== 'user' + ? { + publicationEpoch, + terminalHandle: lifecycleTerminalHandle + } + : {}) }) options?.onClosed?.() return @@ -218,7 +246,8 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: tab.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } else { state.closeTab(tab.id) @@ -261,7 +290,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) } else { state.closeTab(id) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index a89132ad53e..c1a4a903615 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2487,7 +2487,8 @@ export function useIpcEvents(): void { void closeWebRuntimeSessionTab({ worktreeId, tabId, - environmentId + environmentId, + reason: 'user' }) return } diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts index bb63e6d5397..f95591eb49c 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts @@ -59,7 +59,8 @@ describe('notifyHostOfMirroredEditorClose', () => { expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'host-tab-1', - environmentId: 'env-1' + environmentId: 'env-1', + reason: 'user' }) }) diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.ts index 8b96f3d1c5f..1dbd1fbf3a4 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.ts @@ -47,7 +47,8 @@ export function notifyHostOfMirroredEditorClose( closeWebRuntimeSessionTab({ worktreeId, tabId: unifiedTab.id, - environmentId: runtimeEnvironmentId + environmentId: runtimeEnvironmentId, + reason: 'user' }) ) return true diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 4479a39ac8b..403e31a1060 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -15,6 +15,11 @@ import { setWebRuntimeTabProps, splitWebRuntimeTerminal } from './web-runtime-session' +import { + isWebSessionCloseIntentPending, + recordWebSessionCloseIntent, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -24,6 +29,7 @@ const mocks = vi.hoisted(() => ({ setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), applyFreshWebSessionTabsSnapshot: vi.fn(), + acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), @@ -38,6 +44,7 @@ vi.mock('../store', () => ({ })) vi.mock('./web-session-tabs-sync', () => ({ + acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => mocks.setState(buildPatch), @@ -59,6 +66,8 @@ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ const ENVIRONMENT_ID = 'web-env-1' const WORKTREE_ID = 'repo::/worktree' +afterEach(() => resetWebSessionCloseIntentForTests()) + function makeSnapshot(): RuntimeMobileSessionTabsResult { return { worktree: WORKTREE_ID, @@ -905,7 +914,8 @@ describe('web runtime session tab actions', () => { await expect( closeWebRuntimeSessionTab({ worktreeId: WORKTREE_ID, - tabId: 'local-browser-unified' + tabId: 'local-browser-unified', + reason: 'user' }) ).resolves.toBe(true) @@ -925,7 +935,8 @@ describe('web runtime session tab actions', () => { method: 'session.tabs.close', params: { worktree: `id:${WORKTREE_ID}`, - tabId: 'host-browser-unified' + tabId: 'host-browser-unified', + reason: 'user' }, timeoutMs: 15_000 }) @@ -939,6 +950,228 @@ describe('web runtime session tab actions', () => { }) expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled() }) + + it('sends lifecycle and explicit user close reasons on the wire', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ id: 'close-1', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list-1', ok: true, result: makeSnapshot() }) + .mockResolvedValueOnce({ id: 'close-2', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list-2', ok: true, result: makeSnapshot() }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.closeLifecycle', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.close', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + reason: 'user' + }, + timeoutMs: 15_000 + }) + }) + + it('suppresses lifecycle closes when terminal-incarnation evidence is missing', async () => { + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit' + }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.list' }) + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + }) + + it('fails closed when reconnect routes a lifecycle close to an older host', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method: session.tabs.closeLifecycle' + } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'session.tabs.closeLifecycle' }) + ) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.close' }) + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect( + isWebSessionCloseIntentPending( + ENVIRONMENT_ID, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(false) + }) + + it('restores reconciliation authority when the host refuses a lifecycle close', async () => { + const authoritative = makeSnapshot() + authoritative.snapshotVersion = 6 + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: true, + result: { closed: true, refused: true, snapshotRepublished: true } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: authoritative }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + + expect( + isWebSessionCloseIntentPending( + ENVIRONMENT_ID, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(false) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( + mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]! + ) + }) + + it('keeps the close intent when a refused lifecycle close was not republished', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: true, + result: { closed: true, refused: true } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + recordWebSessionCloseIntent(ENVIRONMENT_ID, WORKTREE_ID, 'other-host-tab', Date.now()) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + + expect( + isWebSessionCloseIntentPending( + ENVIRONMENT_ID, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(true) + expect( + isWebSessionCloseIntentPending(ENVIRONMENT_ID, WORKTREE_ID, 'other-host-tab', Date.now()) + ).toBe(true) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled() + }) }) describe('splitWebRuntimeTerminal', () => { diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index ef9bf8fae73..9777dfac84f 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -3,9 +3,11 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { BrowserTabCreateResult, RuntimeMobileSessionCreateTerminalResult, + RuntimeMobileSessionTabCloseResult, RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabsResult, + RuntimeSessionTabCloseReason, RuntimeTerminalClose, RuntimeTerminalSplit } from '../../../shared/runtime-types' @@ -20,7 +22,7 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-client' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { recordWebSessionFocusIntent } from './web-session-focus-intent' -import { recordWebSessionCloseIntent } from './web-session-close-intent' +import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent' import { recordWebSessionReorderIntent } from './web-session-reorder-intent' import { isWebTerminalSurfaceTabId, @@ -28,6 +30,7 @@ import { toWebTerminalSurfaceTabId } from './web-terminal-surface-id' import { deliverLaunchPromptToAgentTab } from '../lib/agent-launch-prompt-delivery' +import { listRemoteRuntimeSessionTabsDeduped } from './remote-runtime-session-tabs-inflight' export { HOST_TERMINAL_SURFACE_SEPARATOR, @@ -292,17 +295,23 @@ async function refreshWebRuntimeSessionTabsSnapshot( worktreeId: string ): Promise { try { - const response = await window.api.runtimeEnvironments.call({ - selector: environmentId, - method: 'session.tabs.list', - params: { - worktree: toRuntimeWorktreeSelector(worktreeId) - }, - timeoutMs: 15_000 + const snapshot = await listRemoteRuntimeSessionTabsDeduped({ + environmentId, + worktreeId, + load: async () => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { + worktree: toRuntimeWorktreeSelector(worktreeId) + }, + timeoutMs: 15_000 + }) + return unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + } }) - const snapshot = unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) const { applyFreshWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } = await import('./web-session-tabs-sync') applyWebSessionTabsStorePatch((state) => { @@ -366,6 +375,9 @@ export async function closeWebRuntimeSessionTab(args: { worktreeId: string tabId: string environmentId?: string | null + reason: RuntimeSessionTabCloseReason + publicationEpoch?: string | null + terminalHandle?: string | null }): Promise { return callWebRuntimeSessionTabMethod('session.tabs.close', args) } @@ -471,6 +483,9 @@ async function callWebRuntimeSessionTabMethod( worktreeId: string tabId: string environmentId?: string | null + reason?: RuntimeSessionTabCloseReason + publicationEpoch?: string | null + terminalHandle?: string | null } ): Promise { const environmentId = @@ -481,9 +496,25 @@ async function callWebRuntimeSessionTabMethod( return false } - if (method === 'session.tabs.close') { - // Why: sync best-effort intent before the async id resolution, so a snapshot in that gap can't flash the closed tab back. - recordWebSessionCloseIntent(args.worktreeId, toHostSessionTabId(args.tabId), Date.now()) + const isClose = method === 'session.tabs.close' + const isLifecycleClose = isClose && args.reason !== 'user' + if (isLifecycleClose && (!args.publicationEpoch || !args.terminalHandle)) { + // Why: missing host-generation or terminal-incarnation evidence means keep; + // a tab id alone can be stale or reused after reconnect. + const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync') + acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId) + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) + console.warn('[web-runtime-session] suppressed lifecycle close without incarnation evidence', { + closeReason: args.reason + }) + return false + } + + const immediateHostTabId = toHostSessionTabId(args.tabId) + let resolvedHostTabId = immediateHostTabId + if (isClose) { + // Why: record before async id resolution so a stale snapshot cannot flash the closed tab back. + recordWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId, Date.now()) } try { @@ -495,13 +526,16 @@ async function callWebRuntimeSessionTabMethod( worktreeId: args.worktreeId, tabId: args.tabId }) ?? toHostSessionTabId(args.tabId) - if (method === 'session.tabs.close') { + resolvedHostTabId = hostTabId + if (isClose) { // Why: suppress until the host confirms removal, else an in-flight pre-close snapshot flashes the tab back. - recordWebSessionCloseIntent(args.worktreeId, hostTabId, Date.now()) + recordWebSessionCloseIntent(environmentId, args.worktreeId, hostTabId, Date.now()) } const response = await window.api.runtimeEnvironments.call({ selector: environmentId, - method, + // Why: old hosts cannot route this additive method, so a generation + // cutover fails closed before their destructive legacy close handler. + method: isLifecycleClose ? 'session.tabs.closeLifecycle' : method, params: { worktree: toRuntimeWorktreeSelector(args.worktreeId), tabId: hostTabId, @@ -511,18 +545,44 @@ async function callWebRuntimeSessionTabMethod( notifyClients: false, navigation: 'caller' as const } - : {}) + : {}), + ...(isLifecycleClose + ? { + reason: args.reason, + publicationEpoch: args.publicationEpoch, + terminal: args.terminalHandle + } + : isClose + ? { reason: args.reason } + : {}) }, timeoutMs: 15_000 }) - unwrapRuntimeRpcResult(response as RuntimeRpcResponse) - if (method === 'session.tabs.close') { + const result = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + if (isClose) { + if (result?.refused === true && result.snapshotRepublished === true) { + // Why: the host kept an authoritative live PTY. Stop hiding its mirror + // only when it republished; dead-leaf refusals must stay suppressed. + clearWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId) + clearWebSessionCloseIntent(environmentId, args.worktreeId, hostTabId) + const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync') + acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId) + } await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) } return true } catch (error) { + if (isLifecycleClose) { + clearWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId) + clearWebSessionCloseIntent(environmentId, args.worktreeId, resolvedHostTabId) + const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync') + acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId) + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) + } console.warn( - `[web-runtime-session] failed to ${method === 'session.tabs.close' ? 'close' : 'activate'} tab:`, + `[web-runtime-session] failed to ${isClose ? 'close' : 'activate'} tab:`, error instanceof Error ? error.message : String(error) ) return false diff --git a/src/renderer/src/runtime/web-session-close-intent.test.ts b/src/renderer/src/runtime/web-session-close-intent.test.ts index 185342b99da..071b4d40178 100644 --- a/src/renderer/src/runtime/web-session-close-intent.test.ts +++ b/src/renderer/src/runtime/web-session-close-intent.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { + clearWebSessionCloseIntent, + clearWebSessionCloseIntentsForEnvironment, + clearWebSessionCloseIntentsForRuntimeWorktree, isWebSessionCloseIntentPending, reconcileWebSessionCloseIntents, recordWebSessionCloseIntent, @@ -7,37 +10,69 @@ import { } from './web-session-close-intent' const WT = 'repo::/wt' +const ENV = 'runtime-a' afterEach(() => resetWebSessionCloseIntentForTests()) describe('web session close intent', () => { it('marks a closing host tab pending until the host confirms removal', () => { - recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) - expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true) // A snapshot that still contains the tab keeps the intent (not confirmed). - reconcileWebSessionCloseIntents(WT, new Set(['host-tab-1', 'host-tab-2'])) - expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + reconcileWebSessionCloseIntents(ENV, WT, new Set(['host-tab-1', 'host-tab-2'])) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true) // A snapshot WITHOUT the tab confirms removal and clears the intent. - reconcileWebSessionCloseIntents(WT, new Set(['host-tab-2'])) - expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(false) + reconcileWebSessionCloseIntents(ENV, WT, new Set(['host-tab-2'])) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false) }) it('expires a never-confirmed close so the tab is not hidden forever', () => { - recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) - expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true) // Past the TTL with no confirming snapshot — stop suppressing. - expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000 + 11_000)).toBe(false) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000 + 11_000)).toBe(false) }) it('scopes intents per worktree', () => { - recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) - expect(isWebSessionCloseIntentPending('other::/wt', 'host-tab-1', 1000)).toBe(false) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending(ENV, 'other::/wt', 'host-tab-1', 1000)).toBe(false) + }) + + it('scopes intents per runtime environment', () => { + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending('runtime-b', WT, 'host-tab-1', 1000)).toBe(false) + reconcileWebSessionCloseIntents('runtime-b', WT, new Set()) + clearWebSessionCloseIntent('runtime-b', WT, 'host-tab-1') + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true) + }) + + it('clears only the refused host tab intent', () => { + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-2', 1000) + + clearWebSessionCloseIntent(ENV, WT, 'host-tab-1') + + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-2', 1000)).toBe(true) + }) + + it('clears intents when their worktree or runtime owner is removed', () => { + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000) + recordWebSessionCloseIntent(ENV, 'other-wt', 'host-tab-2', 1000) + recordWebSessionCloseIntent('runtime-b', WT, 'host-tab-3', 1000) + + clearWebSessionCloseIntentsForRuntimeWorktree(ENV, WT) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false) + expect(isWebSessionCloseIntentPending(ENV, 'other-wt', 'host-tab-2', 1000)).toBe(true) + clearWebSessionCloseIntentsForEnvironment(ENV) + expect(isWebSessionCloseIntentPending(ENV, 'other-wt', 'host-tab-2', 1000)).toBe(false) + expect(isWebSessionCloseIntentPending('runtime-b', WT, 'host-tab-3', 1000)).toBe(true) }) it('ignores empty ids', () => { - recordWebSessionCloseIntent(WT, ' ', 1000) - expect(isWebSessionCloseIntentPending(WT, '', 1000)).toBe(false) + recordWebSessionCloseIntent(ENV, WT, ' ', 1000) + expect(isWebSessionCloseIntentPending(ENV, WT, '', 1000)).toBe(false) }) }) diff --git a/src/renderer/src/runtime/web-session-close-intent.ts b/src/renderer/src/runtime/web-session-close-intent.ts index a8f6549c080..f655adfeb02 100644 --- a/src/renderer/src/runtime/web-session-close-intent.ts +++ b/src/renderer/src/runtime/web-session-close-intent.ts @@ -16,10 +16,15 @@ const CLOSE_INTENT_TTL_MS = 10_000 type CloseIntent = { recordedAt: number } -// worktreeId -> (hostTabId -> intent) -const pendingCloseByWorktree = new Map>() +// environment/worktree -> (hostTabId -> intent) +const pendingCloseByRuntimeWorktree = new Map>() + +function closeIntentScopeKey(environmentId: string, worktreeId: string): string { + return `${environmentId}\0${worktreeId}` +} export function recordWebSessionCloseIntent( + environmentId: string, worktreeId: string, hostTabId: string, now: number @@ -28,25 +33,60 @@ export function recordWebSessionCloseIntent( if (!worktreeId || !trimmed) { return } - let byTab = pendingCloseByWorktree.get(worktreeId) + const scopeKey = closeIntentScopeKey(environmentId, worktreeId) + let byTab = pendingCloseByRuntimeWorktree.get(scopeKey) if (!byTab) { byTab = new Map() - pendingCloseByWorktree.set(worktreeId, byTab) + pendingCloseByRuntimeWorktree.set(scopeKey, byTab) } byTab.set(trimmed, { recordedAt: now }) } +export function clearWebSessionCloseIntent( + environmentId: string, + worktreeId: string, + hostTabId: string +): void { + const scopeKey = closeIntentScopeKey(environmentId, worktreeId) + const byTab = pendingCloseByRuntimeWorktree.get(scopeKey) + if (!byTab) { + return + } + byTab.delete(hostTabId.trim()) + if (byTab.size === 0) { + pendingCloseByRuntimeWorktree.delete(scopeKey) + } +} + +export function clearWebSessionCloseIntentsForRuntimeWorktree( + environmentId: string, + worktreeId: string +): void { + pendingCloseByRuntimeWorktree.delete(closeIntentScopeKey(environmentId, worktreeId)) +} + +export function clearWebSessionCloseIntentsForEnvironment(environmentId: string): void { + const prefix = `${environmentId}\0` + for (const scopeKey of pendingCloseByRuntimeWorktree.keys()) { + if (scopeKey.startsWith(prefix)) { + pendingCloseByRuntimeWorktree.delete(scopeKey) + } + } +} + /** * Whether a host tab should be hidden because the client is closing it. Expired * intents are dropped (the close never confirmed — let the tab reappear rather * than hide it forever). */ export function isWebSessionCloseIntentPending( + environmentId: string, worktreeId: string, hostTabId: string, now: number ): boolean { - const byTab = pendingCloseByWorktree.get(worktreeId) + const scopeKey = closeIntentScopeKey(environmentId, worktreeId) + const byTab = pendingCloseByRuntimeWorktree.get(scopeKey) const intent = byTab?.get(hostTabId) if (!intent) { return false @@ -54,7 +94,7 @@ export function isWebSessionCloseIntentPending( if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) { byTab!.delete(hostTabId) if (byTab!.size === 0) { - pendingCloseByWorktree.delete(worktreeId) + pendingCloseByRuntimeWorktree.delete(scopeKey) } return false } @@ -66,10 +106,12 @@ export function isWebSessionCloseIntentPending( * NOT in `presentHostTabIds` has been removed host-side, so the intent is done. */ export function reconcileWebSessionCloseIntents( + environmentId: string, worktreeId: string, presentHostTabIds: ReadonlySet ): void { - const byTab = pendingCloseByWorktree.get(worktreeId) + const scopeKey = closeIntentScopeKey(environmentId, worktreeId) + const byTab = pendingCloseByRuntimeWorktree.get(scopeKey) if (!byTab) { return } @@ -83,10 +125,10 @@ export function reconcileWebSessionCloseIntents( byTab.delete(hostTabId) } if (byTab.size === 0) { - pendingCloseByWorktree.delete(worktreeId) + pendingCloseByRuntimeWorktree.delete(scopeKey) } } export function resetWebSessionCloseIntentForTests(): void { - pendingCloseByWorktree.clear() + pendingCloseByRuntimeWorktree.clear() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 66f4a1b70bd..6577fd5c801 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -11,6 +11,8 @@ import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent' import { + clearWebSessionCloseIntent, + isWebSessionCloseIntentPending, recordWebSessionCloseIntent, resetWebSessionCloseIntentForTests } from './web-session-close-intent' @@ -159,11 +161,12 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(false) acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree) + const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false) expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) // The replay reset re-primes tracking: ordering protection resumes for // subsequent frames (an older same-epoch frame is still rejected). - const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null }) expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false) const newer = makeSnapshot([], { snapshotVersion: 6, activeTabType: null }) expect(shouldApplyWebSessionTabsSnapshot(newer, ENV)).toBe(true) @@ -258,7 +261,7 @@ describe('applyWebSessionTabsSnapshot', () => { isActive: true } // Client closed host-tab-1; an in-flight pre-close snapshot still lists it. - recordWebSessionCloseIntent(WT, 'host-tab-1', NOW) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', NOW) const stalePreClose = applyWebSessionTabsSnapshot( makeState(), makeSnapshot([surface]), @@ -283,6 +286,56 @@ describe('applyWebSessionTabsSnapshot', () => { ) }) + it('reapplies an unchanged host snapshot after a lifecycle close is refused', () => { + const surface = { + type: 'terminal' as const, + id: HOST_SURFACE_ID, + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready' as const, + terminal: 'term_host', + isActive: true + } + const authoritative = makeSnapshot([surface], { snapshotVersion: 6 }) + + const initial = makeState() + recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', NOW) + const hiddenPatch = applyFreshWebSessionTabsSnapshot(initial, authoritative, ENV, NOW) + const hidden = { ...initial, ...(hiddenPatch as Partial) } + expect((hidden.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).not.toContain( + toWebTerminalSurfaceTabId('host-tab-1') + ) + + // The host vetoed lifecycle cleanup because the PTY is still live. Its + // unchanged snapshot must become usable immediately, without a new publish. + clearWebSessionCloseIntent(ENV, WT, 'host-tab-1') + acceptReplayedWebSessionTabsSnapshot(ENV, WT) + const restoredPatch = applyFreshWebSessionTabsSnapshot(hidden, authoritative, ENV, NOW + 1) + const restored = { ...hidden, ...(restoredPatch as Partial) } + expect((restored.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toContain( + toWebTerminalSurfaceTabId('host-tab-1') + ) + }) + + it('does not let a replay reset clear another close intent from an older snapshot', () => { + const current = makeSnapshot([], { snapshotVersion: 6, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(current, ENV)).toBe(true) + recordWebSessionCloseIntent(ENV, WT, 'host-tab-2', NOW) + + acceptReplayedWebSessionTabsSnapshot(ENV, WT) + const state = makeState() + const stalePatch = applyFreshWebSessionTabsSnapshot( + state, + makeSnapshot([], { snapshotVersion: 5, activeTabType: null }), + ENV, + NOW + 1 + ) + + expect(stalePatch).toBe(state) + expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-2', NOW + 1)).toBe(true) + }) + it('keeps a client reorder until the host echoes it (no order snap-back)', () => { const local1 = toWebTerminalSurfaceTabId('host-tab-1') const local2 = toWebTerminalSurfaceTabId('host-tab-2') diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 93481124df7..59ab5a7fe58 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -52,6 +52,8 @@ import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent' import { + clearWebSessionCloseIntentsForEnvironment, + clearWebSessionCloseIntentsForRuntimeWorktree, isWebSessionCloseIntentPending, reconcileWebSessionCloseIntents } from './web-session-close-intent' @@ -86,6 +88,7 @@ type SnapshotFreshness = { } const latestSessionTabsSnapshotByWorktree = new Map() +const replayableSessionTabsSnapshotByWorktree = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() @@ -176,12 +179,27 @@ export function getLastKnownHostTerminalTabCount( ) } -// Why: a post-reconnect replay re-emits the snapshot with unchanged epoch/version; dropping the freshness entry lets the monotonic gate accept it instead of freezing the mirror (#7718). +export function getLatestWebSessionTabsPublicationEpoch( + environmentId: string, + worktreeId: string +): string | null { + return ( + latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) + ?.publicationEpoch ?? null + ) +} + +// Why: a replay may repeat the current epoch/version; permit only that exact +// identity once so an older concurrent frame cannot bypass monotonic ordering. export function acceptReplayedWebSessionTabsSnapshot( environmentId: string, worktreeId: string ): void { - latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId)) + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsSnapshotByWorktree.get(key) + if (current) { + replayableSessionTabsSnapshotByWorktree.set(key, current) + } } export function shouldApplyWebSessionTabsSnapshot( @@ -201,14 +219,25 @@ export function shouldApplyWebSessionTabsSnapshot( } rememberHostTerminalTabCount(environmentId, snapshot) const current = latestSessionTabsSnapshotByWorktree.get(key) + const replayable = replayableSessionTabsSnapshotByWorktree.get(key) + const isExactCurrentReplay = Boolean( + current && + replayable && + current.publicationEpoch === replayable.publicationEpoch && + current.snapshotVersion === replayable.snapshotVersion && + snapshot.publicationEpoch === replayable.publicationEpoch && + snapshot.snapshotVersion === replayable.snapshotVersion + ) // Why: snapshotVersion is monotonic only within one publicationEpoch (resets on host restart); reject as stale only within the same epoch, since a different epoch is a new generation and must apply. if ( current && current.publicationEpoch === snapshot.publicationEpoch && - snapshot.snapshotVersion <= current.snapshotVersion + snapshot.snapshotVersion <= current.snapshotVersion && + !isExactCurrentReplay ) { return false } + replayableSessionTabsSnapshotByWorktree.delete(key) latestSessionTabsSnapshotByWorktree.set(key, { publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion @@ -283,6 +312,7 @@ export function shouldSyncAllRuntimeSessionTabs(args: { export function resetWebSessionTabsSnapshotFreshnessForTests(): void { latestSessionTabsSnapshotByWorktree.clear() + replayableSessionTabsSnapshotByWorktree.clear() lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() } @@ -300,9 +330,11 @@ export function _getWebSessionTabsTrackingCountsForTest(): { function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void { const key = sessionTabsFreshnessKey(environmentId, worktreeId) latestSessionTabsSnapshotByWorktree.delete(key) + replayableSessionTabsSnapshotByWorktree.delete(key) lastHostTerminalTabCountByWorktree.delete(key) clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) clearWebSessionReorderIntentsForWorktree(worktreeId) + clearWebSessionCloseIntentsForRuntimeWorktree(environmentId, worktreeId) const keyPrefix = `${environmentId}:${worktreeId}:` for (const key of hostSessionTabIdByLocalKey.keys()) { if (key.startsWith(keyPrefix)) { @@ -322,6 +354,11 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) latestSessionTabsSnapshotByWorktree.delete(key) } } + for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + replayableSessionTabsSnapshotByWorktree.delete(key) + } + } for (const key of lastHostTerminalTabCountByWorktree.keys()) { if (key.startsWith(keyPrefix)) { lastHostTerminalTabCountByWorktree.delete(key) @@ -333,6 +370,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) } } clearAllWebRuntimeWakeTerminalRespawn() + clearWebSessionCloseIntentsForEnvironment(trimmedEnvironmentId) } function hostSessionTabMappingKey(args: { @@ -1630,16 +1668,18 @@ export function applyWebSessionTabsSnapshot( const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => tab.type === 'terminal' ? tab.parentTabId : tab.id reconcileWebSessionCloseIntents( + environmentId, worktreeId, new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) ) const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => - isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now) + isWebSessionCloseIntentPending(environmentId, worktreeId, snapshotHostTabId(tab), now) ) ? { ...rawSnapshot, tabs: rawSnapshot.tabs.filter( - (tab) => !isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now) + (tab) => + !isWebSessionCloseIntentPending(environmentId, worktreeId, snapshotHostTabId(tab), now) ) } : rawSnapshot diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index f03ab399090..5eec34211d2 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -273,6 +273,25 @@ export type RuntimeMobileSessionTabMoveResult = { moved: true } +export type RuntimeMobileSessionTabCloseResult = { + closed: true + refused?: true + refusalReason?: + | 'missing-intent' + | 'stale-publication' + | 'stale-terminal' + | 'live-host-pty' + | 'unknown-liveness' + | 'retirement-owner' + // Why: only a republished snapshot can restore a live mirror; dead-leaf refusals intentionally omit this marker. + snapshotRepublished?: true +} + +// Why: lets the host tell a user's close from a client-lifecycle echo +// ('pty-exit'/'cleanup') and adjudicate against its own PTY liveness. +// Absent on the wire for legacy desktop clients, which new hosts conservatively refuse. +export type RuntimeSessionTabCloseReason = 'user' | 'pty-exit' | 'cleanup' + export type RuntimeMobileSessionTabsSnapshot = { worktree: string publicationEpoch: string diff --git a/tests/e2e/daemon-generation-reconnect-safety.spec.ts b/tests/e2e/daemon-generation-reconnect-safety.spec.ts new file mode 100644 index 00000000000..af16a288280 --- /dev/null +++ b/tests/e2e/daemon-generation-reconnect-safety.spec.ts @@ -0,0 +1,450 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test, type TestInfo } from '@playwright/test' +import { DaemonClient } from '../../src/main/daemon/client' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + cleanupDaemonGenerationFixtures, + createDaemonGenerationRuntime, + launchDaemonGeneration, + spawnGenerationCanary, + type DaemonGeneration, + type DaemonGenerationRuntime, + type GenerationCanary +} from './helpers/daemon-generation-safety-fixtures' +import { + processIdentityLiveness, + recordProcessIdentity, + recordProcessTree, + terminateRecordedTree, + waitForCondition, + type RecordedProcessIdentity +} from './helpers/daemon-generation-processes' + +const GENERATION_PROTOCOLS = [21, 22, 23] as const +const ALL_GENERATION_PROTOCOLS = [ + ...new Set([...GENERATION_PROTOCOLS, PROTOCOL_VERSION - 1, PROTOCOL_VERSION]) +] +const configuredReconnectBursts = Number.parseInt( + process.env.ORCA_DAEMON_GENERATION_RECONNECT_BURSTS ?? '3', + 10 +) +const RECONNECT_BURSTS = + Number.isInteger(configuredReconnectBursts) && configuredReconnectBursts > 0 + ? configuredReconnectBursts + : 3 + +type LivenessReport = { + daemons: Record + roots: Record + descendants: Record +} + +type CloseBurstReport = { + closeAttempts: Record +} + +function generationLabel(protocolVersion: number): string { + return `generation-v${protocolVersion}` +} + +function canaryLabel(canary: GenerationCanary): string { + return `${canary.generation.label}-${canary.role}` +} + +function killEvents(generation: DaemonGeneration, sessionId: string): Record[] { + return generation + .logEvents() + .filter((event) => event.event === 'session-killed' && event.sessionId === sessionId) +} + +function helloCount(generation: DaemonGeneration): number { + return generation.logEvents().filter((event) => event.event === 'client-hello-accepted').length +} + +async function collectLiveness( + generations: readonly DaemonGeneration[], + canaries: readonly GenerationCanary[] +): Promise { + const identities: RecordedProcessIdentity[] = [ + ...generations.map((generation) => generation.identity), + ...canaries.flatMap((canary) => [canary.rootIdentity, canary.descendantIdentity]) + ] + const live = await processIdentityLiveness(identities) + return { + daemons: Object.fromEntries( + generations.map((generation) => [ + generation.label, + live.get(generation.identity.pid) === true + ]) + ), + roots: Object.fromEntries( + canaries.map((canary) => [canaryLabel(canary), live.get(canary.rootIdentity.pid) === true]) + ), + descendants: Object.fromEntries( + canaries.map((canary) => [ + canaryLabel(canary), + live.get(canary.descendantIdentity.pid) === true + ]) + ) + } +} + +function launchReconnectClient(options: { + runtime: DaemonGenerationRuntime + generations: readonly DaemonGeneration[] + canaries: readonly GenerationCanary[] +}): { child: ChildProcess; ready: Promise; finish(): void; output(): string } { + const { runtime, generations, canaries } = options + const configPath = path.join(runtime.rootDir, 'reconnect-client-config.json') + writeFileSync( + configPath, + `${JSON.stringify({ + generations: generations.map((generation) => ({ + protocolVersion: generation.protocolVersion, + socketPath: generation.socketPath, + tokenPath: generation.tokenPath + })), + currentProtocolVersion: PROTOCOL_VERSION, + daemonDir: runtime.daemonDir, + historyDir: path.join(runtime.userDataDir, 'terminal-history'), + sessions: canaries.map((canary) => ({ + protocolVersion: canary.generation.protocolVersion, + sessionId: canary.sessionId, + rootPid: canary.rootIdentity.pid, + label: canaryLabel(canary), + role: canary.role + })), + reconnectBursts: RECONNECT_BURSTS, + cwd: runtime.rootDir + })}\n` + ) + let output = '' + const child = fork(runtime.reconnectClientEntryPath, ['--config', configPath], { + cwd: runtime.userDataDir, + execPath: runtime.electronPath, + windowsHide: true, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_PATH: path.join(process.cwd(), 'node_modules'), + ORCA_USER_DATA_PATH: runtime.userDataDir + }, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + child.stderr?.on('data', (chunk: Buffer) => { + output = `${output}${chunk.toString('utf8')}`.slice(-32_768) + }) + const ready = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Reconnect client timed out: ${output}`)), + 60_000 + ) + const settle = (callback: () => void): void => { + clearTimeout(timer) + child.off('message', onMessage) + child.off('exit', onExit) + callback() + } + const onExit = (code: number | null): void => + settle(() => reject(new Error(`Reconnect client exited with ${code}: ${output}`))) + const onMessage = (message: unknown): void => { + const payload = message as { + type?: unknown + message?: unknown + closeAttempts?: Record + } + if (payload.type === 'error') { + settle(() => reject(new Error(String(payload.message)))) + } else if (payload.type === 'close-bursts-complete') { + settle(() => resolve({ closeAttempts: payload.closeAttempts ?? {} })) + } + } + child.on('message', onMessage) + child.once('exit', onExit) + }) + return { + child, + ready, + finish: () => { + if (!child.connected) { + return + } + try { + child.send?.({ type: 'finish' }, () => {}) + } catch { + // The fixture can finish between the connected check and the IPC write. + } + }, + output: () => output + } +} + +async function finishReconnectClient( + client: ReturnType +): Promise { + if (!client.child.pid || client.child.exitCode !== null) { + return + } + const identity = await recordProcessIdentity(client.child.pid) + client.finish() + try { + await waitForCondition('reconnect client exit', () => client.child.exitCode !== null, 2_000) + } catch { + await terminateRecordedTree(await recordProcessTree(identity)) + } +} + +function writeEventReconstruction(options: { + testInfo: TestInfo + generations: readonly DaemonGeneration[] + beforeClose: LivenessReport + afterClose: LivenessReport + helloBaselines: ReadonlyMap + targetCanaries: readonly GenerationCanary[] + closeBurst: CloseBurstReport + clientPid: number | undefined +}): void { + const { + testInfo, + generations, + beforeClose, + afterClose, + helloBaselines, + targetCanaries, + closeBurst, + clientPid + } = options + writeFileSync( + testInfo.outputPath('daemon-generation-reconnect-events.json'), + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + reconnectClientPid: clientPid, + beforeClose, + afterClose, + closeBurst, + generations: generations.map((generation) => ({ + label: generation.label, + protocolVersion: generation.protocolVersion, + daemonPid: generation.identity.pid, + daemonStartedAtMs: generation.identity.startedAtMs, + acceptedHellosDuringReconnect: + helloCount(generation) - (helloBaselines.get(generation.protocolVersion) ?? 0), + targetKills: targetCanaries + .filter((canary) => canary.generation === generation) + .map((canary) => ({ + sessionId: canary.sessionId, + events: killEvents(generation, canary.sessionId) + })) + })) + }, + null, + 2 + )}\n` + ) +} + +async function cleanupGenerationTestFixtures(options: { + runtime: DaemonGenerationRuntime + generations: readonly DaemonGeneration[] + canaries: readonly GenerationCanary[] + retainDiagnostics: boolean +}): Promise { + const { runtime, generations, canaries, retainDiagnostics } = options + if (retainDiagnostics) { + runtime.retainDiagnostics(generations) + } + try { + await cleanupDaemonGenerationFixtures({ generations, canaries }) + } catch (error) { + runtime.retainDiagnostics(generations) + throw error + } + runtime.remove() +} + +test.describe.configure({ mode: 'serial' }) + +test('native Windows reconnect cannot turn stale mirror exits into cross-generation kills', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument before testInfo. +{}, testInfo) => { + test.skip(process.platform !== 'win32', 'Native Windows named pipes and ConPTY are required') + test.setTimeout(180_000) + const fixtureRuntime = await createDaemonGenerationRuntime(testInfo) + const generations: DaemonGeneration[] = [] + const canaries: GenerationCanary[] = [] + let client: ReturnType | null = null + let assertionsComplete = false + + try { + for (const protocolVersion of ALL_GENERATION_PROTOCOLS) { + const generation = await launchDaemonGeneration({ + runtime: fixtureRuntime, + label: generationLabel(protocolVersion), + protocolVersion + }) + generations.push(generation) + for (const role of ['live', 'stale-mirror'] as const) { + canaries.push(await spawnGenerationCanary({ runtime: fixtureRuntime, generation, role })) + } + } + expect(new Set(generations.map((generation) => generation.socketPath)).size).toBe( + ALL_GENERATION_PROTOCOLS.length + ) + expect( + generations.every( + (generation) => + generation.socketPath.startsWith('\\\\') && + generation.socketPath.includes( + `\\pipe\\orca-terminal-host-v${generation.protocolVersion}-` + ) + ) + ).toBe(true) + + const helloBaselines = new Map( + generations.map((generation) => [generation.protocolVersion, helloCount(generation)]) + ) + for (const canary of canaries) { + await canary.adapter.disconnectOnly() + canary.adapter.dispose() + } + const beforeClose = await collectLiveness(generations, canaries) + expect(Object.values(beforeClose.daemons).every(Boolean)).toBe(true) + expect(Object.values(beforeClose.roots).every(Boolean)).toBe(true) + expect(Object.values(beforeClose.descendants).every(Boolean)).toBe(true) + + client = launchReconnectClient({ runtime: fixtureRuntime, generations, canaries }) + const closeBurst = await client.ready + expect(client.child.exitCode).toBeNull() + for (const generation of generations) { + expect( + helloCount(generation) - helloBaselines.get(generation.protocolVersion)! + ).toBeGreaterThanOrEqual(RECONNECT_BURSTS * 2) + } + + const targetCanaries = canaries.filter((canary) => canary.role === 'stale-mirror') + expect(Object.values(closeBurst.closeAttempts)).toHaveLength(targetCanaries.length) + expect(Object.values(closeBurst.closeAttempts).every((attempts) => attempts === 3)).toBe(true) + const afterFirstClient = await collectLiveness(generations, canaries) + expect(client.child.exitCode).toBeNull() + expect(Object.values(afterFirstClient.roots).every(Boolean)).toBe(true) + expect(Object.values(afterFirstClient.descendants).every(Boolean)).toBe(true) + + const firstClient = client + await finishReconnectClient(firstClient) + client = launchReconnectClient({ runtime: fixtureRuntime, generations, canaries }) + const relaunchCloseBurst = await client.ready + expect(client.child.exitCode).toBeNull() + const combinedCloseBurst: CloseBurstReport = { + closeAttempts: Object.fromEntries( + Object.entries(closeBurst.closeAttempts).map(([tabId, attempts]) => [ + tabId, + attempts + (relaunchCloseBurst.closeAttempts[tabId] ?? 0) + ]) + ) + } + expect( + Object.values(combinedCloseBurst.closeAttempts).every((attempts) => attempts === 6) + ).toBe(true) + const afterClose = await collectLiveness(generations, canaries) + writeEventReconstruction({ + testInfo, + generations, + beforeClose, + afterClose, + helloBaselines, + targetCanaries, + closeBurst: combinedCloseBurst, + clientPid: client.child.pid + }) + expect(client.child.exitCode).toBeNull() + expect(Object.values(afterClose.daemons).every(Boolean)).toBe(true) + expect(Object.values(afterClose.roots).every(Boolean)).toBe(true) + expect(Object.values(afterClose.descendants).every(Boolean)).toBe(true) + // Why: each stale mirror crossed desktop and two remote-profile close paths + // before and after an app-process relaunch; process health cannot hide a kill. + expect( + targetCanaries.every((canary) => killEvents(canary.generation, canary.sessionId).length === 0) + ).toBe(true) + expect(targetCanaries.every((canary) => afterClose.roots[canaryLabel(canary)])).toBe(true) + assertionsComplete = true + } finally { + if (client) { + await finishReconnectClient(client) + } + await cleanupGenerationTestFixtures({ + runtime: fixtureRuntime, + generations, + canaries, + retainDiagnostics: !assertionsComplete + }) + } +}) + +test('shutdown disposal failure drops authority within a bounded window', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument before testInfo. +{}, testInfo) => { + test.skip(process.platform !== 'win32', 'Native Windows named pipes and ConPTY are required') + test.setTimeout(60_000) + const fixtureRuntime = await createDaemonGenerationRuntime(testInfo) + const generations: DaemonGeneration[] = [] + const canaries: GenerationCanary[] = [] + let assertionsComplete = false + + try { + const generation = await launchDaemonGeneration({ + runtime: fixtureRuntime, + label: 'generation-v23-refused-dispose', + protocolVersion: 23, + refuseDispose: true + }) + generations.push(generation) + const canary = await spawnGenerationCanary({ + runtime: fixtureRuntime, + generation, + role: 'live' + }) + canaries.push(canary) + const shutdownClient = new DaemonClient({ + socketPath: generation.socketPath, + tokenPath: generation.tokenPath, + protocolVersion: generation.protocolVersion + }) + await shutdownClient.ensureConnected() + + const startedAt = Date.now() + await expect(shutdownClient.request('shutdown', { killSessions: true })).resolves.toEqual({}) + shutdownClient.disconnect() + expect(Date.now() - startedAt).toBeLessThan(15_000) + await waitForCondition('shutdown-dispose-failed log', () => + generation.logEvents().some((event) => event.event === 'shutdown-dispose-failed') + ) + const lateClient = new DaemonClient({ + socketPath: generation.socketPath, + tokenPath: generation.tokenPath, + protocolVersion: generation.protocolVersion + }) + await expect(lateClient.ensureConnected()).rejects.toThrow() + lateClient.disconnect() + const fencedLiveness = await collectLiveness(generations, canaries) + // Why: disposal failure may strand the fixture process, but fencing must + // remove authority without pretending that the retained PTY disappeared. + expect(fencedLiveness.daemons[generation.label]).toBe(true) + expect(fencedLiveness.roots[canaryLabel(canary)]).toBe(true) + expect(fencedLiveness.descendants[canaryLabel(canary)]).toBe(true) + expect(generation.logEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ event: 'shutdown', reason: 'rpc', killSessions: true }), + expect.objectContaining({ event: 'shutdown-dispose-failed' }) + ]) + ) + assertionsComplete = true + } finally { + await cleanupGenerationTestFixtures({ + runtime: fixtureRuntime, + generations, + canaries, + retainDiagnostics: !assertionsComplete + }) + } +}) diff --git a/tests/e2e/fixtures/daemon-generation-canary.cjs b/tests/e2e/fixtures/daemon-generation-canary.cjs new file mode 100644 index 00000000000..f045635e5a9 --- /dev/null +++ b/tests/e2e/fixtures/daemon-generation-canary.cjs @@ -0,0 +1,36 @@ +const { spawn } = require('node:child_process') +const readline = require('node:readline') + +const [label, nonce] = process.argv.slice(2) +if (!label || !nonce) { + throw new Error('Usage: daemon-generation-canary