mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: recover fresh local terminal spawns from a deleted saved cwd (#7847)
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 <help@stably.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-4
@@ -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<string, string>
|
||||
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) {
|
||||
|
||||
@@ -1115,6 +1115,7 @@ export type PreloadApi = {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
cwdFallback?: 'worktree'
|
||||
env?: Record<string, string>
|
||||
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<boolean>
|
||||
|
||||
@@ -756,6 +756,7 @@ const api = {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
cwdFallback?: 'worktree'
|
||||
env?: Record<string, string>
|
||||
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 => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
command?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
|
||||
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<typeof vi.fn>
|
||||
|
||||
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<typeof vi.fn>
|
||||
|
||||
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<typeof vi.fn>
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user