From 4a6d12959f52c2fcb70f9641cefb30e489531bb7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:33:58 -0700 Subject: [PATCH] fix: recover fresh local terminal spawns from a deleted saved cwd (#7847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #7750 removed the containment guard, the residual #7239 failure mode is a persisted/inherited startupCwd whose directory no longer exists: every spawn dies with the provider's missing-directory error. Fresh local renderer spawns now opt in (cwdFallback: 'worktree') to recover at the workspace root with a generic in-terminal notice; reattach, SSH, remote-runtime, runtime/API and mobile callers keep exact cwd semantics, and existing directories — including outside the worktree (#7685) — spawn as requested. Co-authored-by: Orca --- config/reliability-gates.jsonc | 159 +++++++++++++++++- src/main/ipc/pty.test.ts | 92 ++++++++++ src/main/ipc/pty.ts | 55 +++++- src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../terminal-pane/pty-connection.test.ts | 27 +++ .../terminal-pane/pty-connection.ts | 17 ++ .../terminal-pane/pty-transport-types.ts | 2 + .../terminal-pane/pty-transport.test.ts | 50 ++++++ .../components/terminal-pane/pty-transport.ts | 14 +- src/shared/terminal-startup-cwd.test.ts | 115 ++++++++++++- src/shared/terminal-startup-cwd.ts | 30 +++- 12 files changed, 554 insertions(+), 11 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 73162aee6f7..36291362d1e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-07", + "updatedAt": "2026-07-08", "policy": { "maturityLevels": [ "experimental", @@ -105,6 +105,163 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.startup-cwd-missing-dir-recovery", + "title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "shared-main-renderer-contract", + "surfaces": [ + "terminal lifecycle", + "tab creation", + "PTY spawn", + "startup cwd persistence" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "ssh", + "remote-runtime" + ], + "coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7239", + "https://github.com/stablyai/orca/pull/7750", + "https://github.com/stablyai/orca/pull/7678" + ], + "invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.", + "oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "src/shared/terminal-startup-cwd.test.ts", + "src/main/ipc/pty.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/terminal-startup-cwd.test.ts", + "assertions": [ + "a missing requested cwd falls back to the workspace root and reports the missing path to the callback", + "existing cwds — nested or outside the worktree (#7685) — are never remapped", + "no fallback happens when the workspace root is missing too", + "floating terminal cwds and root-equal requests are never probed", + "non-ASCII worktree roots and folder workspace roots are recovered verbatim" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "local pty:spawn with cwdFallback worktree spawns at the worktree root when the saved cwd is missing and returns fallback metadata", + "a missing cwd without the flag still surfaces the provider's missing-directory error", + "an existing outside-worktree cwd spawns as requested without fallback metadata", + "session reattach spawns ignore the fallback flag and keep exact cwd semantics" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "IPC transport sends cwdFallback only for local fresh spawns", + "SSH-tagged and session reattach spawns omit cwdFallback", + "IPC transport returns startup cwd fallback metadata to the connection layer" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "fresh local IPC worktree spawns are marked with cwdFallback worktree", + "startup cwd fallback metadata prints a generic in-terminal notice", + "remote-runtime worktree spawns are not marked with cwdFallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "1 test file passed, 21 tests passed; covers the missing-dir fallback policy, #7685 outside-worktree preservation, and root-missing/floating exemptions." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 0.9, + "summary": "1 test file passed, 225 tests passed; covers main pty:spawn recovery, fallback metadata, and no-flag/reattach provider-error strictness." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "result": "passed", + "durationSeconds": 0.4, + "summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 6.5, + "summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused unit and IPC contract tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate added with local deterministic evidence only; needs CI soak before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured." + }, + "performanceBudget": { + "required": true, + "evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added." + }, + "promotionCriteria": [ + "Attach CI evidence for all declared test files.", + "Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.", + "Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope." + ], + "knownGaps": [ + "No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.", + "Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.", + "WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised." + ], + "demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d54fd316271..ae48d9f6e8d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6306,6 +6306,98 @@ describe('registerPtyHandlers', () => { expect(options.cwd).toBe('/tmp/floating-notes') }) + it('falls back to the worktree root when a saved local cwd no longer exists', async () => { + registerPtyHandlers(mainWindow as never) + // Why: issue #7239 reproduced in a Japanese-named worktree; the fallback + // must return the selected worktree path verbatim. + const worktreePath = '/Users/motoki/orca/workspaces/nakamuramotoki/Fableと議論' + const missingCwd = `${worktreePath}/deleted-folder` + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}` + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }] + expect(options.cwd).toBe(worktreePath) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + }) + + it('keeps a missing cwd unchanged without the fallback flag', async () => { + registerPtyHandlers(mainWindow as never) + existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') + statSyncMock.mockImplementation((target: string) => { + if (target === '/repo/app/deleted-folder') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + // Why: without the renderer opt-in the provider still surfaces its normal + // missing-directory error — API/runtime callers keep exact cwd semantics. + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app/deleted-folder', + worktreeId: 'repo-1::/repo/app' + }) + ).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.') + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('spawns at an existing outside-worktree cwd without falling back (#7685)', async () => { + registerPtyHandlers(mainWindow as never) + + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app-other', + cwdFallback: 'worktree', + worktreeId: 'repo-1::/repo/app' + })) as { startupCwdFallback?: unknown } + + const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }] + expect(options.cwd).toBe('/repo/app-other') + expect(result.startupCwdFallback).toBeUndefined() + }) + + it('ignores the cwd fallback flag for session reattach spawns', async () => { + registerPtyHandlers(mainWindow as never) + existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') + statSyncMock.mockImplementation((target: string) => { + if (target === '/repo/app/deleted-folder') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + // Why: a reattach must keep the session's exact cwd; remapping it would + // silently detach the restored terminal from its recorded state. + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app/deleted-folder', + cwdFallback: 'worktree', + sessionId: 'session-1', + worktreeId: 'repo-1::/repo/app' + }) + ).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.') + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { const originalPlatform = process.platform const originalUserProfile = process.env.USERPROFILE diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index bdd4cc13b77..6848e411c06 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5,6 +5,7 @@ boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'node:path' import { randomUUID } from 'node:crypto' +import { statSync } from 'node:fs' import { type BrowserWindow, type IpcMainEvent, @@ -82,7 +83,11 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd' +import { + resolveTerminalStartupCwdForWorkspace, + type TerminalStartupCwdMissingDirFallback +} from '../../shared/terminal-startup-cwd' +import { isWslUncPath } from '../../shared/wsl-paths' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -2043,15 +2048,31 @@ export function registerPtyHandlers( const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, - cwd: string | undefined + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback ): string | undefined => resolveTerminalStartupCwdForWorkspace({ workspaceId: worktreeId, requestedCwd: cwd, + missingDirFallback, resolveFolderWorkspacePath: (folderWorkspaceId) => store?.getFolderWorkspace(folderWorkspaceId)?.folderPath }) + const localStartupCwdDirectoryExists = (path: string): boolean => { + // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report + // ENOENT for directories that exist on the Linux side; never fall back on + // that signal — the provider's WSL-aware validation decides instead. + if (isWslUncPath(path)) { + return true + } + try { + return statSync(path).isDirectory() + } catch { + return false + } + } + // Why: the runtime controller must route through getProviderForPty() so that // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. @@ -2642,6 +2663,10 @@ export function registerPtyHandlers( cols: number rows: number cwd?: string + // Why: fresh local renderer spawns opt into recovering a saved cwd + // whose directory was deleted (#7239); reattach/remote callers must + // keep exact cwd semantics, so the flag alone is not sufficient. + cwdFallback?: 'worktree' env?: Record envToDelete?: string[] command?: string @@ -2685,7 +2710,26 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + // Why: honor the fallback only for fresh local spawns even if a caller + // sends the flag — reattach must keep the session's exact cwd and + // remote/SSH paths cannot probe the local filesystem meaningfully. + const allowMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + let didFallbackToWorkspaceRootCwd = false + const cwd = resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: localStartupCwdDirectoryExists, + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + const startupCwdFallback = + didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) @@ -3347,7 +3391,10 @@ export function registerPtyHandlers( ...result, ...(!result.isReattach && effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } - : {}) + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted + // session id, and a reattach must never claim its cwd was remapped. + ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}) } return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) } catch (err) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 78d7eb38e79..0c789c5b931 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1115,6 +1115,7 @@ export type PreloadApi = { cols: number rows: number cwd?: string + cwdFallback?: 'worktree' env?: Record command?: string launchConfig?: SleepingAgentLaunchConfig @@ -1151,6 +1152,7 @@ export type PreloadApi = { replay?: string sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } + startupCwdFallback?: { kind: 'worktree'; cwd: string } }> write: (id: string, data: string) => void writeAccepted: (id: string, data: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index e47ae057cd8..54fe1215abe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -756,6 +756,7 @@ const api = { cols: number rows: number cwd?: string + cwdFallback?: 'worktree' env?: Record command?: string launchConfig?: SleepingAgentLaunchConfig @@ -791,6 +792,7 @@ const api = { replay?: string sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } + startupCwdFallback?: { kind: 'worktree'; cwd: string } }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string): void => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6e3746ab7e1..31957b87613 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11769,6 +11769,7 @@ describe('connectPanePty', () => { 'owner-runtime', expect.any(Object) ) + expect(createdTransportOptions[0]?.cwdFallback).toBeUndefined() expect(transport.connect).toHaveBeenCalled() }) @@ -11806,9 +11807,35 @@ describe('connectPanePty', () => { expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() expect(createIpcPtyTransport).toHaveBeenCalled() + expect(createdTransportOptions[0]?.cwdFallback).toBe('worktree') expect(transport.connect).toHaveBeenCalled() }) + it('prints a terminal notice when the startup cwd fell back to the workspace root', async () => { + const { connectPanePty, STARTUP_CWD_FALLBACK_NOTICE } = await import('./pty-connection') + const transport = createMockTransport('pty-fallback') + transport.connect.mockResolvedValueOnce({ + id: 'pty-fallback', + startupCwdFallback: { kind: 'worktree', cwd: '/tmp/wt-1' } + }) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + } + } as StoreState + + const pane = createPane(2) + const { writes } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + + expect(writes).toContain(STARTUP_CWD_FALLBACK_NOTICE) + }) + it('attaches restored remote PTYs for later split panes instead of spawning host tabs', async () => { const { connectPanePty } = await import('./pty-connection') const existingTransport = createMockTransport('remote:env-1@@terminal-1') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2e984d7865d..dc43ed3b0a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -190,6 +190,10 @@ const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 +// Why: the notice deliberately omits the rejected path — saved cwds can +// contain private repo/user names; the terminal itself shows where it opened. +export const STARTUP_CWD_FALLBACK_NOTICE = + '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 @@ -2748,6 +2752,10 @@ export function connectPanePty( : undefined const transportOptions = { cwd: deps.cwd, + // Why: only fresh local IPC spawns may recover from a saved startup cwd + // whose directory was deleted (#7239); remote-runtime and SSH spawns + // resolve cwd on another host and must keep exact cwd semantics. + ...(runtimeEnvironmentId === null && !connectionId ? { cwdFallback: 'worktree' as const } : {}), env: paneEnv, command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, startupCommandDelivery: shouldDeliverStartupViaTerminalPaste @@ -3780,6 +3788,15 @@ export function connectPanePty( }) } if (resolvedPtyId) { + if ( + spawnedPtyId && + typeof spawnedPtyId === 'object' && + spawnedPtyId.startupCwdFallback?.kind === 'worktree' + ) { + writeTerminalOutput(pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { + foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current) + }) + } if (coldRestoreOverride?.hasSleepingRecord) { showSessionRestoredBanner() } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 312945ed3f6..61320e60f56 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -37,6 +37,7 @@ export type PtyConnectResult = { sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } replay?: string + startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Trailing partial escape the daemon emulator held mid-parse; the reattach * replay writes it LAST (after the reset) so a racing live continuation * completes it instead of rendering literally (#7329). */ @@ -104,6 +105,7 @@ export type PtyTransport = { export type IpcPtyTransportOptions = { cwd?: string + cwdFallback?: 'worktree' env?: Record command?: string launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index dc0096d899d..a3c9f51eebc 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -163,6 +163,56 @@ describe('createIpcPtyTransport', () => { expect(sshTransport.getLocalSessionMetadata?.()).toBeNull() }) + it('sends the missing-cwd fallback flag only for local IPC spawns', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + + const transport = createIpcPtyTransport({ cwdFallback: 'worktree' }) + await transport.connect({ url: '', callbacks: {} }) + + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwdFallback: 'worktree' })) + transport.disconnect() + }) + + it('omits the missing-cwd fallback flag when the IPC transport is SSH-tagged', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + + const transport = createIpcPtyTransport({ connectionId: 'ssh-1', cwdFallback: 'worktree' }) + await transport.connect({ url: '', callbacks: {} }) + + expect(spawn).toHaveBeenCalledWith(expect.not.objectContaining({ cwdFallback: 'worktree' })) + transport.disconnect() + }) + + it('omits the missing-cwd fallback flag for session reattach spawns', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + + const transport = createIpcPtyTransport({ cwdFallback: 'worktree' }) + await transport.connect({ url: '', callbacks: {}, sessionId: 'session-1' }) + + expect(spawn).toHaveBeenCalledWith(expect.not.objectContaining({ cwdFallback: 'worktree' })) + transport.disconnect() + }) + + it('returns startup cwd fallback metadata to the connection layer', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + spawn.mockResolvedValueOnce({ + id: 'pty-1', + startupCwdFallback: { kind: 'worktree', cwd: '/repo/app' } + }) + + const transport = createIpcPtyTransport({ cwdFallback: 'worktree' }) + + await expect(transport.connect({ url: '', callbacks: {} })).resolves.toEqual({ + id: 'pty-1', + startupCwdFallback: { kind: 'worktree', cwd: '/repo/app' } + }) + transport.disconnect() + }) + it('defers title side effects until after terminal data is delivered', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const onTitleChange = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 6e6c80a3932..71999b7134b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -451,6 +451,7 @@ export function createPtyOutputProcessor({ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { const { cwd, + cwdFallback, env, command, launchConfig, @@ -618,10 +619,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } try { + // Why: missing-cwd recovery is only valid for fresh local spawns — + // reattach must keep the session's exact cwd and SSH-tagged transports + // resolve cwd on the remote host. + const shouldSendLocalCwdFallback = + cwdFallback === 'worktree' && !connectionId && !options.sessionId const result = await window.api.pty.spawn({ cols: options.cols ?? 80, rows: options.rows ?? 24, cwd, + ...(shouldSendLocalCwdFallback ? { cwdFallback } : {}), env: options.env ?? env, command: options.command ?? command, ...((options.launchConfig ?? launchConfig) @@ -687,10 +694,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi } satisfies PtyConnectResult } - if (spawnResult.launchConfig) { + if (spawnResult.launchConfig || spawnResult.startupCwdFallback) { return { id: spawnResult.id, - launchConfig: spawnResult.launchConfig + ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), + ...(spawnResult.startupCwdFallback + ? { startupCwdFallback: spawnResult.startupCwdFallback } + : {}) } satisfies PtyConnectResult } return spawnResult.id diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts index 2b1fe0b2f86..879465e4435 100644 --- a/src/shared/terminal-startup-cwd.test.ts +++ b/src/shared/terminal-startup-cwd.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { resolveTerminalStartupCwd, @@ -87,6 +87,119 @@ describe('resolveTerminalStartupCwd', () => { ).toBeUndefined() }) + it('falls back to the workspace root when the requested cwd directory is missing', () => { + const onFallbackToWorkspaceRoot = vi.fn() + expect( + resolveTerminalStartupCwd('/repo/app', '/repo/app/deleted-folder', { + directoryExists: (path) => path === '/repo/app', + onFallbackToWorkspaceRoot + }) + ).toBe('/repo/app') + expect(onFallbackToWorkspaceRoot).toHaveBeenCalledWith('/repo/app/deleted-folder') + }) + + it('falls back to a non-ASCII workspace root for a missing cwd (#7239)', () => { + // Why: issue #7239 reproduced in a Japanese-named worktree; the fallback + // must preserve the selected worktree path verbatim. + const worktreePath = '/Users/motoki/orca/workspaces/nakamuramotoki/Fableと議論' + expect( + resolveTerminalStartupCwd(worktreePath, '/var/tmp/orca-stale', { + directoryExists: (path) => path === worktreePath + }) + ).toBe(worktreePath) + }) + + it('keeps an existing cwd outside the worktree when fallback is enabled (#7685)', () => { + const onFallbackToWorkspaceRoot = vi.fn() + expect( + resolveTerminalStartupCwd('/repo/app', '/repo/app-other', { + directoryExists: () => true, + onFallbackToWorkspaceRoot + }) + ).toBe('/repo/app-other') + expect(onFallbackToWorkspaceRoot).not.toHaveBeenCalled() + }) + + it('keeps an existing nested cwd when fallback is enabled', () => { + const onFallbackToWorkspaceRoot = vi.fn() + expect( + resolveTerminalStartupCwd('/repo/app', 'packages/web', { + directoryExists: () => true, + onFallbackToWorkspaceRoot + }) + ).toBe('/repo/app/packages/web') + expect(onFallbackToWorkspaceRoot).not.toHaveBeenCalled() + }) + + it('keeps the requested cwd when the workspace root is missing too', () => { + // Why: unmounted volume / stopped WSL distro — falling back would spawn a + // misleading shell; let the provider surface its normal error instead. + const onFallbackToWorkspaceRoot = vi.fn() + expect( + resolveTerminalStartupCwd('/repo/app', '/repo/app/deleted-folder', { + directoryExists: () => false, + onFallbackToWorkspaceRoot + }) + ).toBe('/repo/app/deleted-folder') + expect(onFallbackToWorkspaceRoot).not.toHaveBeenCalled() + }) + + it('does not probe when the requested cwd resolves to the workspace root', () => { + const directoryExists = vi.fn(() => false) + expect( + resolveTerminalStartupCwd('/repo/app', '/repo/app', { + directoryExists, + onFallbackToWorkspaceRoot: () => {} + }) + ).toBe('/repo/app') + expect(directoryExists).not.toHaveBeenCalled() + }) + + it('falls back from a missing parent-traversal cwd to the workspace root', () => { + expect( + resolveTerminalStartupCwd('/repo/app', '../deleted', { + directoryExists: (path) => path === '/repo/app' + }) + ).toBe('/repo/app') + }) + + it('recovers missing renderer cwd values against raw worktree IDs', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app/deleted-folder', + missingDirFallback: { + directoryExists: (path) => path === '/repo/app' + } + }) + ).toBe('/repo/app') + }) + + it('recovers missing cwd values against a resolved folder workspace root', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: folderWorkspaceKey('folder-1'), + requestedCwd: 'deleted-folder', + resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null), + missingDirFallback: { + directoryExists: (path) => path === '/repo/app' + } + }) + ).toBe('/repo/app') + }) + + it('never probes floating terminal cwds', () => { + const directoryExists = vi.fn(() => false) + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + requestedCwd: '/Volumes/work/notes', + missingDirFallback: { directoryExists } + }) + ).toBe('/Volumes/work/notes') + expect(directoryExists).not.toHaveBeenCalled() + }) + it('resolves renderer PTY cwd values against folder workspace keys', () => { expect( resolveTerminalStartupCwdForWorkspace({ diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index ae97ca6dd46..a6b3f935e1d 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -3,9 +3,17 @@ import { resolveRuntimePath } from './cross-platform-path' import { parseWorkspaceKey } from './workspace-scope' import { splitWorktreeIdForFilesystem } from './worktree-id' +export type TerminalStartupCwdMissingDirFallback = { + // Why: only local callers can probe the filesystem — SSH/remote worktree + // paths live on another host — so the existence check is injected. + directoryExists: (path: string) => boolean + onFallbackToWorkspaceRoot?: (missingCwd: string) => void +} + export function resolveTerminalStartupCwd( worktreePath: string, - requestedCwd?: string | null + requestedCwd?: string | null, + missingDirFallback?: TerminalStartupCwdMissingDirFallback ): string | undefined { const trimmedCwd = requestedCwd?.trim() if (!trimmedCwd) { @@ -14,13 +22,29 @@ export function resolveTerminalStartupCwd( // Why: resolve relative requests against the worktree root and normalize // `..`; the cwd is intentionally not constrained to the worktree, so opening // or splitting a terminal outside it (e.g. after `cd ..`) is allowed. (#7685) - return resolveRuntimePath(worktreePath, trimmedCwd) + const resolvedCwd = resolveRuntimePath(worktreePath, trimmedCwd) + if ( + missingDirFallback && + resolvedCwd !== worktreePath && + !missingDirFallback.directoryExists(resolvedCwd) && + missingDirFallback.directoryExists(worktreePath) + ) { + // Why: a persisted/inherited startup folder can be deleted later; spawning + // into it fails on every retry and bricks terminal creation for that tab + // (#7239), so recover at the workspace root. If the root is missing too + // (unmounted volume, stopped WSL distro), keep the requested cwd so the + // provider surfaces its normal error instead of a misleading fallback. + missingDirFallback.onFallbackToWorkspaceRoot?.(resolvedCwd) + return worktreePath + } + return resolvedCwd } export function resolveTerminalStartupCwdForWorkspace(args: { workspaceId?: string requestedCwd?: string | null resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined + missingDirFallback?: TerminalStartupCwdMissingDirFallback }): string | undefined { if (!args.requestedCwd || args.requestedCwd.trim().length === 0) { return undefined @@ -39,7 +63,7 @@ export function resolveTerminalStartupCwdForWorkspace(args: { // back to the provider default rather than guessing a base. return undefined } - return resolveTerminalStartupCwd(workspacePath, args.requestedCwd) + return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, args.missingDirFallback) } function resolveTerminalWorkspacePath(