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_*:<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.
This commit is contained in:
Neil
2026-11-01 23:17:46 -08:00
committed by GitHub
parent 4f0aaccc5f
commit d8a66e41c9
12 changed files with 239 additions and 16 deletions
+4 -1
View File
@@ -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 &&
+35
View File
@@ -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')
})
})
+22
View File
@@ -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.`
}
@@ -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 })
}
@@ -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."
)
}
)
})
@@ -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'}`
)
}
}
@@ -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({})
})
})
@@ -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()
})
})
@@ -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<string> {
const shell = failedSpawnShell(error)
return resolveClientEnvironmentFooter(shell ? { shell } : {})
}
@@ -32,8 +32,10 @@ function resolveFallbackPlatform(): NodeJS.Platform | 'unknown' {
}
}
export async function resolveClientEnvironmentFooter(): Promise<string> {
return formatClientEnvironmentFooter(await resolveClientEnvironmentInfo())
export async function resolveClientEnvironmentFooter(
overrides: { shell?: string } = {}
): Promise<string> {
return formatClientEnvironmentFooter({ ...(await resolveClientEnvironmentInfo()), ...overrides })
}
async function resolveAppVersion(): Promise<string> {
@@ -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
@@ -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
}