From d8a66e41c9ceefd3ab9dde2c94dfc3e1621ccc90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:17:46 -0800 Subject: [PATCH] fix(wsl): name the stopped distro, not its raw hold code, wherever a held terminal is shown (#37380) The terminal error banner and a paired host's held pane showed terminal_held_wsl_distro_*: as is; the banner now swaps either the code (older hosts) or the English sentence (newer hosts) for the localized copy, and drops the issue link for it. A held start that arrives as the sentence now gets the Start card too. The CLI names the distro for an older server's raw hold code and for an unavailable WSL folder. The banner's footer reports the shell whose spawn failed (wsl.exe, say) instead of the client's default shell. --- src/cli/cli-error.ts | 5 +- src/cli/wsl-distro-cli-refusal.test.ts | 35 ++++++++ src/cli/wsl-distro-cli-refusal.ts | 22 +++++ .../terminal-pane/TerminalErrorToast.tsx | 18 ++-- .../main-pane-start-wait.test.ts | 37 +++++++++ .../pty-connection/main-pane-start-wait.ts | 13 ++- .../terminal-error-toast-wsl-hold.test.ts | 82 +++++++++++++++++++ .../terminal-spawn-error-display.test.ts | 13 +++ .../terminal-spawn-error-display.ts | 14 ++++ .../src/lib/client-environment-info.ts | 6 +- .../src/lib/folder-workspace-path-status.ts | 5 ++ src/renderer/src/lib/wsl-distro-hold-copy.ts | 5 ++ 12 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 src/cli/wsl-distro-cli-refusal.test.ts create mode 100644 src/cli/wsl-distro-cli-refusal.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts index 975fb2fd17e..af4fa1b241b 100644 --- a/src/cli/cli-error.ts +++ b/src/cli/cli-error.ts @@ -8,6 +8,7 @@ import { worktreeSelectorRecovery } from './worktree-selector-recovery' import { ambiguousWorkspaceChoices, selectorAmbiguityRecovery } from './selector-ambiguity-recovery' import type { RuntimeRpcFailure } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' +import { readableWslDistroCliRefusal } from './wsl-distro-cli-refusal' export type CliErrorContext = { commandPath?: readonly string[] @@ -66,7 +67,9 @@ export function cliErrorCode(error: unknown): string | undefined { } export function formatCliError(error: unknown, context: CliErrorContext = {}): string { - const message = error instanceof Error ? error.message : String(error) + const message = readableWslDistroCliRefusal( + error instanceof Error ? error.message : String(error) + ) const selector = contextRecovery(cliErrorCode(error), context) const workspaceChoices = error instanceof RuntimeClientError && diff --git a/src/cli/wsl-distro-cli-refusal.test.ts b/src/cli/wsl-distro-cli-refusal.test.ts new file mode 100644 index 00000000000..900aa794235 --- /dev/null +++ b/src/cli/wsl-distro-cli-refusal.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { formatCliError } from './cli-error' +import { RuntimeClientError } from './runtime/types' +import { readableWslDistroCliRefusal } from './wsl-distro-cli-refusal' + +describe('readableWslDistroCliRefusal', () => { + it("names the distro in an older server's raw hold code", () => { + expect(readableWslDistroCliRefusal('terminal_held_wsl_distro_stopped:Ubuntu')).toBe( + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + }) + + it.each(['\\\\wsl.localhost\\Ubuntu\\home\\me\\notes', '\\\\wsl$\\Ubuntu\\home\\me\\notes'])( + 'names the distro behind an unavailable WSL folder (%s)', + (path) => { + expect(readableWslDistroCliRefusal(`folder_workspace_path_unavailable:${path}`)).toBe( + `Orca couldn't reach ${path}. The WSL distro Ubuntu may be stopped, and Orca doesn't start a stopped distro for this. Start Ubuntu (for example, open a terminal in it), then try again.` + ) + } + ) + + it('leaves other refusals as they were', () => { + for (const message of ['folder_workspace_path_unavailable:/srv/notes', 'terminal_not_found']) { + expect(readableWslDistroCliRefusal(message)).toBe(message) + } + }) + + it('reads in the human CLI error output', () => { + const error = new RuntimeClientError( + 'runtime_error', + 'folder_workspace_path_unavailable:\\\\wsl.localhost\\Debian\\w' + ) + expect(formatCliError(error)).toContain('The WSL distro Debian may be stopped') + }) +}) diff --git a/src/cli/wsl-distro-cli-refusal.ts b/src/cli/wsl-distro-cli-refusal.ts new file mode 100644 index 00000000000..cfdd17abffe --- /dev/null +++ b/src/cli/wsl-distro-cli-refusal.ts @@ -0,0 +1,22 @@ +import { readableWslDistroHoldText } from '../shared/wsl-distro-stopped-hold' +import { parseWslUncPath } from '../shared/wsl-paths' + +const FOLDER_UNAVAILABLE_PREFIX = 'folder_workspace_path_unavailable:' + +/** + * A refusal over a WSL distro, named: older servers send the raw hold code, and a folder on a + * distro Orca won't start in the background reads only as "path unavailable". + */ +export function readableWslDistroCliRefusal(message: string): string { + const held = readableWslDistroHoldText(message) + if (held !== message) { + return held + } + const start = message.indexOf(FOLDER_UNAVAILABLE_PREFIX) + const path = start === -1 ? '' : message.slice(start + FOLDER_UNAVAILABLE_PREFIX.length).trim() + const distro = parseWslUncPath(path)?.distro + if (!distro) { + return message + } + return `${message.slice(0, start)}Orca couldn't reach ${path}. The WSL distro ${distro} may be stopped, and Orca doesn't start a stopped distro for this. Start ${distro} (for example, open a terminal in it), then try again.` +} diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index b8f76771b62..804ff45e79b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react' import { unwrapHostRefusalIpcLines } from './terminal-error-ipc-unwrap' import { translate } from '@/i18n/i18n' -import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { - humanizeFolderWorkspacePathError, + humanizeFolderWorkspacePathErrors, isFolderWorkspacePathError } from '@/lib/folder-workspace-path-status' import { Button } from '@/components/ui/button' @@ -13,9 +12,11 @@ import { stripClientEnvironmentFooter } from '../../../../shared/client-environment-info' import { + failedSpawnEnvironmentFooter, localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest } from './terminal-spawn-error-display' +import { isWslDistroHoldText, localizeWslDistroHoldText } from '@/lib/wsl-distro-hold-copy' import { describeSshSignInRequiredTerminalError, isSshSignInRequiredTerminalError @@ -142,11 +143,6 @@ function isFolderWorkspacePathOnlyError(error: string): boolean { return lines.length > 0 && lines.every(isFolderWorkspacePathError) } -// Why: a moved or deleted folder is the user's to fix, so it gets actionable copy, not an issue link. -function humanizeFolderWorkspacePathErrors(error: string): string { - return error.split('\n').map(humanizeFolderWorkspacePathError).join('\n') -} - /** An SSH host state (reconnecting, or waiting for the user to sign in), not a bug to report. */ function isSshHostStateTerminalError(error: string): boolean { return isSshSignInRequiredTerminalError(error) || isSshReconnectOwnedTerminalError(error) @@ -157,7 +153,8 @@ export function humanizeTerminalError(error: string, agentId?: string | null): s if (isSshSignInRequiredTerminalError(error)) { return describeSshSignInRequiredTerminalError(error) } - const unwrapped = unwrapHostRefusalIpcLines(error) + // Hosts send a held start as its code (older) or English sentence (newer); either reads in the user's language. + const unwrapped = localizeWslDistroHoldText(unwrapHostRefusalIpcLines(error)) let humanized = humanizeFolderWorkspacePathErrors( localizeTerminalSpawnHints(agentLaunchErrorText(unwrapped, agentId) ?? unwrapped) ) @@ -255,7 +252,8 @@ export function TerminalErrorToast({ !showDaemonRestart && !isAgentLaunchOnlyError(error) && !isExplainedTerminalError(error) && - !isFolderWorkspacePathOnlyError(error) + !isFolderWorkspacePathOnlyError(error) && + !isWslDistroHoldText(error) const humanizedError = humanizeTerminalError(error, agentId) // Why: the toast appends its own linked request, so the host's plain-text one would repeat it. const displayError = showIssueLink @@ -279,7 +277,7 @@ export function TerminalErrorToast({ return } let cancelled = false - void resolveClientEnvironmentFooter().then((footer) => { + void failedSpawnEnvironmentFooter(displayError).then((footer) => { if (!cancelled) { setEnvironmentFooter({ error: displayError, footer }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts index 5e7420561cb..b68aae6afbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts @@ -156,4 +156,41 @@ describe('waitsForMainPaneStart', () => { }) expect(pane.deps.onPtyErrorRef?.current).not.toHaveBeenCalled() }) + + it('shows the Start card when the refusal carries the readable sentence, not the code', async () => { + const { showPaneStartFailure } = await import('./main-pane-start-wait') + const { wslDistroStoppedHoldStore } = await import('../wsl-distro-stopped-hold-state') + const pane = session() + + showPaneStartFailure( + pane, + "The WSL distro Debian is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + + expect(wslDistroStoppedHoldStore.getState().panes[PANE_KEY]).toMatchObject({ + distro: 'Debian', + state: 'stopped' + }) + expect(pane.deps.onPtyErrorRef?.current).not.toHaveBeenCalled() + }) + + it.each([ + ['terminal_held_wsl_distro_stopped:Ubuntu'], + [ + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ] + ])( + "names the distro on a paired host's held pane instead of the raw code (%s)", + async (reason) => { + const { showPaneStartFailure } = await import('./main-pane-start-wait') + const pane = { ...session(), runtimeEnvironmentId: 'env-1' } + + showPaneStartFailure(pane, reason) + + expect(pane.deps.onPtyErrorRef?.current).toHaveBeenCalledWith( + 1, + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + ) + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts index 0900b762f69..1459313b00e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts @@ -6,7 +6,10 @@ import { recordPtyConnectDiagnostic } from './pty-connect-limits' import type { PaneStartStatus } from '../../../../../shared/workspace-layout/workspace-layout-published' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { resolveTerminalTabId } from './terminal-tab-id' -import { parseWslDistroHold } from '../../../../../shared/wsl-distro-stopped-hold' +import { + findWslDistroHoldInText, + wslDistroHoldUserMessage +} from '../../../../../shared/wsl-distro-stopped-hold' import { holdPaneForStoppedWslDistro } from '../wsl-distro-stopped-hold-state' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -110,7 +113,8 @@ export function showPaneStartFailure( session: ConnectPanePtySession, reason: string | undefined ): void { - const hold = reason ? parseWslDistroHold(reason) : null + // Main publishes the code; an RPC refusal carries the sentence instead. + const hold = reason ? (findWslDistroHoldInText(reason)?.hold ?? null) : null if (hold && !session.disposed && !session.runtimeEnvironmentId) { // Main held background work on a stopped (or unchecked) distro: the card's Start asks main to start it. const state = useAppStore.getState() @@ -132,7 +136,10 @@ export function showPaneStartFailure( if (!session.disposed) { session.deps.onPtyErrorRef?.current?.( session.pane.id, - `The terminal could not start: ${reason ?? 'unknown error'}` + // A paired host's held pane has no local Start: the sentence, which the banner localizes. + hold + ? wslDistroHoldUserMessage(hold) + : `The terminal could not start: ${reason ?? 'unknown error'}` ) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts b/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts new file mode 100644 index 00000000000..dc2c7cea329 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-error-toast-wsl-hold.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import React from 'react' +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const environmentMocks = vi.hoisted(() => ({ resolveFooter: vi.fn() })) + +vi.mock('@/lib/client-environment-info', () => ({ + resolveClientEnvironmentFooter: environmentMocks.resolveFooter +})) + +import { TerminalErrorToast, humanizeTerminalError } from './TerminalErrorToast' + +const STOPPED_SENTENCE = + "The WSL distro Ubuntu is stopped, so Orca didn't start this terminal. Start the distro, then try again." + +beforeEach(() => { + environmentMocks.resolveFooter.mockReset() + environmentMocks.resolveFooter.mockImplementation(async (overrides: { shell?: string } = {}) => + ['---', 'Orca: 1.0.0', 'OS: win32 10.0 (x64)', `Shell: ${overrides.shell ?? 'cmd.exe'}`].join( + '\n' + ) + ) +}) + +afterEach(() => { + cleanup() +}) + +describe('a held WSL terminal in the error banner', () => { + it.each([ + ['terminal_held_wsl_distro_stopped:Ubuntu'], + ["Error invoking remote method 'pty:spawn': Error: terminal_held_wsl_distro_stopped:Ubuntu"], + ['Failed to create the startup terminal for C:\\w: terminal_held_wsl_distro_stopped:Ubuntu'] + ])('names the distro instead of the raw code (%s)', (error) => { + const humanized = humanizeTerminalError(error) + expect(humanized).toContain(STOPPED_SENTENCE) + expect(humanized).not.toContain('terminal_held_wsl_distro') + }) + + it('reads an unchecked distro as unverifiable, not stopped', () => { + expect(humanizeTerminalError('terminal_held_wsl_distro_unverifiable:Debian')).toBe( + "Orca couldn't check whether the WSL distro Debian is running, so it didn't start this terminal. Start the distro, then try again." + ) + }) + + it('does not ask the user to file an issue for a held terminal', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: 'terminal_held_wsl_distro_stopped:Ubuntu', + onDismiss: vi.fn() + }) + ) + expect(view.container.textContent).toContain(STOPPED_SENTENCE) + expect(view.container.textContent).not.toContain('file an issue') + }) +}) + +describe('the error banner footer', () => { + it('reports the shell whose spawn failed, not the default shell', async () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: + 'Daemon failed to spawn shell "C:\\WINDOWS\\system32\\wsl.exe" with cwd "C:\\Users\\me": spawn failed', + onDismiss: vi.fn() + }) + ) + await waitFor(() => + expect(view.container.textContent).toContain('Shell: C:\\WINDOWS\\system32\\wsl.exe') + ) + expect(view.container.textContent).not.toContain('Shell: cmd.exe') + }) + + it('keeps the default shell when the error names none', async () => { + const view = render( + React.createElement(TerminalErrorToast, { error: 'Paste failed.', onDismiss: vi.fn() }) + ) + await waitFor(() => expect(view.container.textContent).toContain('Shell: cmd.exe')) + expect(environmentMocks.resolveFooter).toHaveBeenCalledWith({}) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts index e61504a2d71..abc8b3c7e8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts @@ -18,6 +18,7 @@ vi.mock('@/i18n/i18n', () => ({ })) import { + failedSpawnShell, localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest } from './terminal-spawn-error-display' @@ -48,3 +49,15 @@ describe('withoutTerminalSpawnIssueRequest', () => { expect(withoutTerminalSpawnIssueRequest(error)).toBe(error) }) }) + +describe('failedSpawnShell', () => { + it('reads the shell from daemon and local spawn failures', () => { + expect( + failedSpawnShell( + 'Daemon failed to spawn shell "C:\\Windows\\System32\\wsl.exe" with cwd "C:\\"' + ) + ).toBe('C:\\Windows\\System32\\wsl.exe') + expect(failedSpawnShell(RAW)).toBe('/bin/zsh') + expect(failedSpawnShell('Paste failed.')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts index 33682a95233..311bf4e0919 100644 --- a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { LEGACY_PTY_ALLOCATION_HINT, LEGACY_TERMINAL_PROCESS_LIMIT_HINT, @@ -43,3 +44,16 @@ export function withoutTerminalSpawnIssueRequest(error: string): string { ) .join('\n') } + +const FAILED_SPAWN_SHELL = /failed to spawn shell "([^"\r\n]+)"/i + +/** The shell a failed spawn names, which the footer reports instead of the client's default shell. */ +export function failedSpawnShell(error: string): string | null { + return FAILED_SPAWN_SHELL.exec(error)?.[1] ?? null +} + +/** The client environment footer, naming the failed spawn's own shell (wsl.exe, say) over the default one. */ +export function failedSpawnEnvironmentFooter(error: string): Promise { + const shell = failedSpawnShell(error) + return resolveClientEnvironmentFooter(shell ? { shell } : {}) +} diff --git a/src/renderer/src/lib/client-environment-info.ts b/src/renderer/src/lib/client-environment-info.ts index 1749e6d1ac5..fda7a1a295b 100644 --- a/src/renderer/src/lib/client-environment-info.ts +++ b/src/renderer/src/lib/client-environment-info.ts @@ -32,8 +32,10 @@ function resolveFallbackPlatform(): NodeJS.Platform | 'unknown' { } } -export async function resolveClientEnvironmentFooter(): Promise { - return formatClientEnvironmentFooter(await resolveClientEnvironmentInfo()) +export async function resolveClientEnvironmentFooter( + overrides: { shell?: string } = {} +): Promise { + return formatClientEnvironmentFooter({ ...(await resolveClientEnvironmentInfo()), ...overrides }) } async function resolveAppVersion(): Promise { diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 86b9cab2793..aec5a3dde48 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -171,6 +171,11 @@ export function humanizeFolderWorkspacePathError(message: string): string { : message } +// Why: a moved or deleted folder is the user's to fix, so it gets actionable copy, not an issue link. +export function humanizeFolderWorkspacePathErrors(error: string): string { + return error.split('\n').map(humanizeFolderWorkspacePathError).join('\n') +} + export function formatFolderWorkspaceCreateError(error: unknown): { title: string description: string diff --git a/src/renderer/src/lib/wsl-distro-hold-copy.ts b/src/renderer/src/lib/wsl-distro-hold-copy.ts index 41669f11f8f..6ebbdfcaf67 100644 --- a/src/renderer/src/lib/wsl-distro-hold-copy.ts +++ b/src/renderer/src/lib/wsl-distro-hold-copy.ts @@ -26,3 +26,8 @@ export function localizeWslDistroHoldText(text: string): string { const found = findWslDistroHoldInText(text) return found ? text.split(found.matched).join(localizedWslDistroHoldMessage(found.hold)) : text } + +/** Whether text names a held terminal, in either form. */ +export function isWslDistroHoldText(text: string): boolean { + return findWslDistroHoldInText(text) !== null +}