fix(wsl): keep a stopped distro's worktrees and re-list once it runs (#35733)

* fix(wsl): keep a stopped distro's worktrees and re-list once it runs

After a relaunch with a WSL distro stopped, the E9 gate refused the
worktree scan and the sidebar showed "Worktree scan failed" with no rows
until the user pressed Retry. The listing now answers with the stored
rows (non-authoritative, so nothing is pruned), marks the distro stopped
or unverifiable in a new optional field, and re-lists once a wake or a
settled running read shows the distro running. The re-scan is held once
per repo and dropped when the repo is removed. The sidebar shows a
neutral state instead of a failure.

* fix(wsl): make the stopped-distro Retry a user act that may start the distro

The indicator's click now runs the scan inside runAsWslUserAct for the
repo's distro (new worktrees:scanStartingWslDistro IPC), then re-lists,
so clicking it can start the distro instead of re-running the gated
background scan that never could.
This commit is contained in:
Neil
2026-10-29 06:54:51 -07:00
committed by GitHub
parent 2a5f6c79c1
commit 6eebdbbaf8
32 changed files with 742 additions and 15 deletions
@@ -28,6 +28,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:listKnownForExecutionHost',
'worktrees:forgetRemovedForExecutionHost',
'worktrees:cancelListDetected',
'worktrees:scanStartingWslDistro',
'worktrees:create',
'worktrees:adoptProvisionedRoot',
'worktrees:prefetchCreateBase',
+1
View File
@@ -33,6 +33,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:listKnownForExecutionHost',
'worktrees:forgetRemovedForExecutionHost',
'worktrees:cancelListDetected',
'worktrees:scanStartingWslDistro',
'worktrees:create',
'worktrees:adoptProvisionedRoot',
'worktrees:prefetchCreateBase',
@@ -39,6 +39,12 @@ import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-
import { classifyWorktreeScanFailure } from '../../../../shared/worktree-scan-failure'
import { scanUntilNotOvertaken } from './overtaken-scan-rerun'
import { withoutInFlightWorktreeCreates } from '../../../worktree-create-in-flight'
import { releaseWslStoppedWorktreeRescan } from '../../../wsl-stopped-worktree-rescan-registry'
import {
listWslStoppedWorktrees,
rescanWorktreesWhenWslDistroRuns,
wslDistroNotRunningForScanFailure
} from './wsl-stopped-worktree-listing'
export async function listDetectedWorktreesForCapturedRepo(
store: Store,
@@ -153,6 +159,7 @@ export async function listDetectedWorktreesForCapturedRepo(
}
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
releaseWslStoppedWorktreeRescan(repo.id)
// Rows describe scan-start generation, which clients order against applied create/remove replies.
return {
repoId: repo.id,
@@ -174,15 +181,30 @@ export async function listDetectedWorktreesForCapturedRepo(
if (!isCurrent()) {
return null
}
// Retained rows need their scan-failure cause to explain why they are unavailable.
const unavailableReason = describeWorktreeScanFailure(err)
const failureKind = classifyWorktreeScanFailure(unavailableReason)
// A distro the gate would not start (E9) is expected: keep stored rows and re-list once it runs.
const wslDistroStopped = wslDistroNotRunningForScanFailure(err)
if (wslDistroStopped) {
rescanWorktreesWhenWslDistroRuns(repo.id, wslDistroStopped.distro)
return {
repoId: repo.id,
authoritative: false,
source: 'metadata-fallback',
worktrees: listWslStoppedWorktrees(store, repo, allMeta),
unavailableReason,
failureKind,
wslDistroStopped,
catalogVersion: getLocalWorktreeCatalogVersion(repo.id)
}
}
warnOnce(
loggedWorktreeListFailures,
`${repo.id}:${repo.path}`,
`[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
// Retained rows need their scan-failure cause to explain why they are unavailable.
const unavailableReason = describeWorktreeScanFailure(err)
const failureKind = classifyWorktreeScanFailure(unavailableReason)
return {
repoId: repo.id,
authoritative: false,
@@ -21,11 +21,17 @@ import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-owner
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { dropWorkspacesTheListingInvalidates } from '../../../persistence/restoring-sessions/invalid-workspace-layout-purge'
import { notifyWatchedWorktreeCatalogChanged } from '../../watched-worktree-catalog-notification'
import { setWslStoppedWorktreeRescanNotifier } from './wsl-stopped-worktree-listing'
import { scanWorktreesStartingWslDistro } from './wsl-distro-starting-scan'
export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000
export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): void {
const { store, detectedWorktreeCancellations } = context
setWslStoppedWorktreeRescanNotifier((repoId) =>
notifyWatchedWorktreeCatalogChanged(context.mainWindow, repoId)
)
ipcMain.handle(
'worktrees:listDetected',
@@ -125,6 +131,15 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v
}
)
ipcMain.handle(
'worktrees:scanStartingWslDistro',
async (_event, args: { repoId: string }): Promise<void> => {
if (typeof args?.repoId === 'string') {
await scanWorktreesStartingWslDistro(store, args.repoId)
}
}
)
ipcMain.handle(
'worktrees:cancelListDetected',
(event, args: { providerRequestId: ProviderRequestId }): void => {
@@ -0,0 +1,25 @@
import type { Store } from '../../../persistence/loading-store/store'
import { getRepoDialableSshTargetId } from '../../../../shared/execution-host'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
import { runAsWslUserAct, wslDistroForPath } from '../../../wsl/wsl-unc-access-gate'
import { listDetectedWorktreesForCapturedRepo } from './detected-provider-listing'
import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership'
/** A clicked Retry is a user act (E9): the scan may start the repo's stopped distro. Callers re-list after. */
export async function scanWorktreesStartingWslDistro(store: Store, repoId: string): Promise<void> {
const repo = findExactRepoOwner(store, repoId)
if (!repo || isFolderRepo(repo) || getRepoDialableSshTargetId(repo)) {
return
}
const distro = wslDistroForPath(
repo.path,
getLocalProjectWorktreeGitOptions(store, repo).wslDistro
)
if (!distro) {
return
}
await runAsWslUserAct(distro, () =>
listDetectedWorktreesForCapturedRepo(store, repo, () => isCapturedRepoCurrent(store, repo))
)
}
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
import type { Store } from '../../../persistence/loading-store/store'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
const mocks = vi.hoisted(() => ({ listDetectedGitWorktrees: vi.fn(), runProcess: vi.fn() }))
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() },
app: { getPath: () => '/tmp/orca-test' }
}))
vi.mock('./detected-worktree-scan-cache', async (importOriginal) => ({
...(await importOriginal<object>()),
listDetectedGitWorktrees: mocks.listDetectedGitWorktrees
}))
// Any `wsl --list --running` probe would land here; a deferred re-scan must never make one.
vi.mock('@orca/process-host', async (importOriginal) => ({
...(await importOriginal<object>()),
runProcess: mocks.runProcess
}))
const { listDetectedWorktreesForCapturedRepo } = await import('./detected-provider-listing')
const { setWslStoppedWorktreeRescanNotifier } = await import('./wsl-stopped-worktree-listing')
const { WslDistroStoppedError, WslDistroUnverifiableError } =
await import('../../../wsl/wsl-unc-access-gate')
const { _resetWslBackgroundDistroGateForTests, noteWslDistroWoken, noteWslDistrosRunning } =
await import('../../../wsl-background-distro-gate')
const { scanWorktreesStartingWslDistro } = await import('./wsl-distro-starting-scan')
const { isWslUserActFor } = await import('../../../wsl/wsl-user-act-scope')
const { _pendingWslStoppedWorktreeRescanCount, _resetWslStoppedWorktreeRescansForTests } =
await import('../../../wsl-stopped-worktree-rescan-registry')
const REPO_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
const FEATURE_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\me\\feature'
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
...overrides
}
}
function createStore(repo: Repo, meta: Record<string, Partial<WorktreeMeta>> = {}): Store {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listing reads only these Store members.
return {
getRepo: () => repo,
getRepos: () => [repo],
getProjects: () => [],
getSettings: () => ({}),
getAllWorktreeMeta: () => meta,
getProjectHostSetups: () => [],
getWorktreeMeta: (id: string) => meta[id],
setWorktreeMeta: vi.fn((_target: unknown, updates: Partial<WorktreeMeta>) => updates),
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage: vi.fn()
} as unknown as Store
}
async function list(store: Store, repo: Repo): Promise<DetectedWorktreeListResult> {
const result = await listDetectedWorktreesForCapturedRepo(store, repo, () => true)
if (!result || 'providerAbortStatus' in result) {
throw new Error('expected a listing')
}
return result
}
const notify = vi.fn()
describe('worktree listing for a stopped WSL distro (E9)', () => {
beforeEach(() => {
mocks.listDetectedGitWorktrees.mockReset()
mocks.runProcess.mockReset()
notify.mockReset()
setWslStoppedWorktreeRescanNotifier(notify)
})
afterEach(() => {
setWslStoppedWorktreeRescanNotifier(null)
_resetWslStoppedWorktreeRescansForTests()
_resetWslBackgroundDistroGateForTests()
})
it('keeps the stored rows, non-authoritative, and says the distro is stopped', async () => {
const repo = makeRepo()
mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu'))
const result = await list(
createStore(repo, { [`${repo.id}::${FEATURE_PATH}`]: { displayName: 'feature' } }),
repo
)
expect(result.authoritative).toBe(false)
expect(result.source).toBe('metadata-fallback')
expect(result.wslDistroStopped).toEqual({ distro: 'Ubuntu', state: 'stopped' })
expect(result.unavailableReason).toContain("WSL distro 'Ubuntu' is not running")
// The main checkout always exists, stored or not.
expect(result.worktrees.map((worktree) => [worktree.path, worktree.isMainWorktree])).toEqual([
[REPO_PATH, true],
[FEATURE_PATH, false]
])
})
it('names an unverifiable distro as such', async () => {
const repo = makeRepo()
mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroUnverifiableError('Ubuntu'))
const result = await list(createStore(repo), repo)
expect(result.authoritative).toBe(false)
expect(result.wslDistroStopped).toEqual({ distro: 'Ubuntu', state: 'unverifiable' })
expect(result.worktrees.map((worktree) => worktree.path)).toEqual([REPO_PATH])
})
it('leaves other scan failures as they were', async () => {
const repo = makeRepo()
mocks.listDetectedGitWorktrees.mockRejectedValue(new Error('Command failed: wsl.exe'))
const result = await list(createStore(repo), repo)
expect(result.wslDistroStopped).toBeUndefined()
expect(result.worktrees).toEqual([])
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
})
it('re-lists once on the next settled running read, without probing', async () => {
const repo = makeRepo()
const store = createStore(repo)
mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu'))
await list(store, repo)
await list(store, repo)
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(1)
noteWslDistrosRunning(['Debian'])
await Promise.resolve()
expect(notify).not.toHaveBeenCalled()
noteWslDistrosRunning(['ubuntu'])
await Promise.resolve()
expect(notify).toHaveBeenCalledExactlyOnceWith(repo.id)
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
expect(mocks.runProcess).not.toHaveBeenCalled()
// Drained: a later wake does not re-list again.
noteWslDistroWoken('Ubuntu')
expect(notify).toHaveBeenCalledOnce()
})
it('re-lists when a user act wakes the distro', async () => {
const repo = makeRepo()
mocks.listDetectedGitWorktrees.mockRejectedValue(new WslDistroStoppedError('Ubuntu'))
await list(createStore(repo), repo)
noteWslDistroWoken('Debian')
expect(notify).not.toHaveBeenCalled()
noteWslDistroWoken('Ubuntu')
expect(notify).toHaveBeenCalledExactlyOnceWith(repo.id)
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
noteWslDistrosRunning(['Ubuntu'])
await Promise.resolve()
expect(notify).toHaveBeenCalledOnce()
expect(mocks.runProcess).not.toHaveBeenCalled()
})
it('drops the pending re-scan once a scan succeeds', async () => {
const repo = makeRepo()
const store = createStore(repo)
mocks.listDetectedGitWorktrees.mockRejectedValueOnce(new WslDistroStoppedError('Ubuntu'))
await list(store, repo)
mocks.listDetectedGitWorktrees.mockResolvedValueOnce({
gitWorktrees: [
{
path: REPO_PATH,
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
],
fresh: false,
superseded: false,
generation: 1
})
const result = await list(store, repo)
expect(result.authoritative).toBe(true)
expect(result.wslDistroStopped).toBeUndefined()
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
})
it('lists a folder workspace on a WSL path as before', async () => {
const repo = makeRepo({ kind: 'folder' })
const result = await list(createStore(repo), repo)
expect(result.authoritative).toBe(true)
expect(result.wslDistroStopped).toBeUndefined()
expect(mocks.listDetectedGitWorktrees).not.toHaveBeenCalled()
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
})
})
describe('Retry on a stopped WSL distro (E9)', () => {
let originalPlatform: NodeJS.Platform
beforeEach(() => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.listDetectedGitWorktrees.mockReset()
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
_resetWslStoppedWorktreeRescansForTests()
_resetWslBackgroundDistroGateForTests()
})
it("scans as a user act on the repo's distro, so the scan may start it", async () => {
const repo = makeRepo()
const insideAct: boolean[] = []
mocks.listDetectedGitWorktrees.mockImplementation(async () => {
insideAct.push(isWslUserActFor('Ubuntu'))
return {
gitWorktrees: [
{
path: REPO_PATH,
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
],
fresh: false,
superseded: false,
generation: 1
}
})
await scanWorktreesStartingWslDistro(createStore(repo), repo.id)
expect(insideAct).toEqual([true])
expect(isWslUserActFor('Ubuntu')).toBe(false)
})
it('does nothing for a folder workspace or a repo outside WSL', async () => {
for (const repo of [makeRepo({ kind: 'folder' }), makeRepo({ path: 'C:\\work\\repo' })]) {
await scanWorktreesStartingWslDistro(createStore(repo), repo.id)
}
await scanWorktreesStartingWslDistro(createStore(makeRepo()), 'unknown-repo')
expect(mocks.listDetectedGitWorktrees).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,88 @@
import type { Repo } from '../../../../shared/repo-types'
import type {
DetectedWorktree,
DetectedWorktreeListResult
} from '../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { Store } from '../../../persistence/loading-store/store'
import { areWorktreePathsEqual } from '../../../git/worktree-path-comparison'
import {
deferUntilWslDistroRunning,
onWslDistroGateNote
} from '../../../wsl-background-distro-gate'
import { WslDistroStoppedError, WslDistroUnverifiableError } from '../../../wsl/wsl-unc-access-gate'
import {
holdWslStoppedWorktreeRescan,
releaseWslStoppedWorktreeRescan
} from '../../../wsl-stopped-worktree-rescan-registry'
import { mergeWorktree } from '../../worktree-metadata-merge'
import { buildDisconnectedDetectedWorktrees } from './folder-workspace-catalog'
import {
createSshWorktreeMetaIndexForRepo,
listDisconnectedSshWorktrees
} from './ssh-worktree-fallback'
export type WslDistroNotRunning = NonNullable<DetectedWorktreeListResult['wslDistroStopped']>
let notifyRepoWorktreesChanged: ((repoId: string) => void) | null = null
export function setWslStoppedWorktreeRescanNotifier(
notify: ((repoId: string) => void) | null
): void {
notifyRepoWorktreesChanged = notify
}
export function wslDistroNotRunningForScanFailure(error: unknown): WslDistroNotRunning | null {
if (error instanceof WslDistroStoppedError) {
return { distro: error.distro, state: 'stopped' }
}
if (error instanceof WslDistroUnverifiableError) {
return { distro: error.distro, state: 'unverifiable' }
}
return null
}
/** Stored rows for a repo whose distro the gate refused to start; the main checkout always exists. */
export function listWslStoppedWorktrees(
store: Store,
repo: Repo,
allMeta: Record<string, WorktreeMeta> | undefined
): DetectedWorktree[] {
const known = listDisconnectedSshWorktrees(
store,
repo,
createSshWorktreeMetaIndexForRepo(allMeta ?? {}, repo.id)
)
const worktrees = known.some((worktree) => areWorktreePathsEqual(worktree.path, repo.path))
? known
: [
mergeWorktree(
repo.id,
{ path: repo.path, head: '', branch: '', isBare: false, isMainWorktree: true },
undefined,
repo.displayName
),
...known
]
return buildDisconnectedDetectedWorktrees(store, repo, worktrees)
}
/** Re-list on a wake or a settled running read; never probes, and holds one pending re-scan per repo. */
export function rescanWorktreesWhenWslDistroRuns(repoId: string, distro: string): void {
const wanted = distro.trim().toLowerCase()
const rescan = (): void => {
releaseWslStoppedWorktreeRescan(repoId, cancel)
notifyRepoWorktreesChanged?.(repoId)
}
const cancelDeferred = deferUntilWslDistroRunning(distro, `worktree-scan:${repoId}`, rescan)
const unsubscribeNotes = onWslDistroGateNote((note) => {
if (note.kind === 'woken' && note.distro.trim().toLowerCase() === wanted) {
rescan()
}
})
const cancel = (): void => {
cancelDeferred()
unsubscribeNotes()
}
holdWslStoppedWorktreeRescan(repoId, cancel)
}
@@ -17,6 +17,7 @@ import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-ope
import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning'
import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue'
import { retireLocalWorktreeMetadataPruneStateForRepo } from '../../local-worktree-metadata-prune-gate'
import { releaseWslStoppedWorktreeRescan } from '../../wsl-stopped-worktree-rescan-registry'
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
@@ -100,6 +101,7 @@ export class RepoLifecycleOperations {
)
if (!idStillPresent) {
retireLocalWorktreeScanGeneration(id)
releaseWslStoppedWorktreeRescan(id)
dropRepoRestoreHints(this, id)
delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id]
delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id]
@@ -140,6 +142,7 @@ export class RepoLifecycleOperations {
}
for (const repoId of orphanRepoIds) {
retireLocalWorktreeScanGeneration(repoId)
releaseWslStoppedWorktreeRescan(repoId)
dropRepoRestoreHints(this, repoId)
pruneWorktreeStateForRepo(this, repoId, null)
state.workspaceSession = removeLocalRepoSession(state.workspaceSession, repoId)
@@ -194,6 +194,15 @@ describe('main drops the layout of workspaces the catalog no longer validates',
store,
listing([KEPT], { authoritative: false, source: 'metadata-fallback' })
)
// Stored rows from a stopped WSL distro are not a scan either.
dropWorkspacesTheListingInvalidates(
store,
listing([KEPT], {
authoritative: false,
source: 'metadata-fallback',
wslDistroStopped: { distro: 'Ubuntu', state: 'stopped' }
})
)
dropWorkspacesTheListingInvalidates(store, null)
dropWorkspacesTheListingInvalidates(store, { providerAbortStatus: 'timed-out' })
@@ -29,6 +29,10 @@ import {
isLocalWorktreeScanGenerationCurrent
} from '../../local-worktree-scan-generation'
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
import {
_pendingWslStoppedWorktreeRescanCount,
holdWslStoppedWorktreeRescan
} from '../../wsl-stopped-worktree-rescan-registry'
import type { ExecutionHostId } from '../../../shared/execution-host'
const at = (id: string, hostId: ExecutionHostId) => hostWorktreeTarget({ id, hostId })
@@ -124,6 +128,18 @@ describe('Store', () => {
expect(_getLocalWorktreeScanGenerationCacheSize()).toBe(initialCacheSize)
})
it('drops a pending stopped-WSL worktree re-scan when its repo is removed', async () => {
const store = await createStore()
const cancel = vi.fn()
store.addRepo(makeRepo({ id: 'wsl-repo' }))
holdWslStoppedWorktreeRescan('wsl-repo', cancel)
store.removeProjectForHost({ id: 'wsl-repo', executionHostId: 'local' })
expect(cancel).toHaveBeenCalledOnce()
expect(_pendingWslStoppedWorktreeRescanCount()).toBe(0)
})
it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => {
const store = await createStore()
store.addRepo(makeRepo())
@@ -0,0 +1,30 @@
// Dependency-free so repo removal in persistence can drop a pending re-scan without loading the WSL gate.
const pendingByRepoId = new Map<string, () => void>()
/** One pending re-scan per repo: a newer hold cancels the older one. */
export function holdWslStoppedWorktreeRescan(repoId: string, cancel: () => void): void {
const previous = pendingByRepoId.get(repoId)
pendingByRepoId.set(repoId, cancel)
previous?.()
}
/** `cancel` releases only that hold, so a stale callback cannot drop a newer one. */
export function releaseWslStoppedWorktreeRescan(repoId: string, cancel?: () => void): void {
const current = pendingByRepoId.get(repoId)
if (!current || (cancel && current !== cancel)) {
return
}
pendingByRepoId.delete(repoId)
current()
}
export function _pendingWslStoppedWorktreeRescanCount(): number {
return pendingByRepoId.size
}
export function _resetWslStoppedWorktreeRescansForTests(): void {
for (const cancel of pendingByRepoId.values()) {
cancel()
}
pendingByRepoId.clear()
}
+2
View File
@@ -59,6 +59,8 @@ export type WorktreeApi = {
args: ForgetRemovedWorktreesForExecutionHostArgs
) => Promise<ForgetRemovedWorktreesForExecutionHostResult>
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
/** User act: scan a local repo inside its WSL distro's user-act scope, which may start it. */
scanStartingWslDistro?: (args: { repoId: string }) => Promise<void>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise<CreateWorktreeResult>
/** Local creates report progress by creationId; remote/runtime creates emit nothing. */
+2
View File
@@ -23,6 +23,8 @@ export const worktreesApi = {
cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args),
scanStartingWslDistro: (args) => ipcRenderer.invoke('worktrees:scanStartingWslDistro', args),
create: (args) => ipcRenderer.invoke('worktrees:create', args),
adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args),
@@ -98,4 +98,51 @@ describe('RepoScanUnavailableIndicator', () => {
})
expect(fetchWorktrees).toHaveBeenCalledWith(repo.id, { executionHostId: 'local' })
})
it.each([
['stopped', 'WSL distro Ubuntu is stopped'],
['unverifiable', "Couldn't confirm WSL distro Ubuntu is running"]
] as const)(
'shows a %s WSL distro as a neutral state whose click starts it and scans',
async (state, title) => {
const calls: string[] = []
const fetchWorktrees = vi.fn(async () => {
calls.push('fetch')
return true
})
const scanStartingWslDistro = vi.fn(async () => {
calls.push('start')
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { worktrees: { scanStartingWslDistro } }
})
useAppStore.setState({
fetchWorktrees: fetchWorktrees as never,
detectedWorktreesByRepo: {
[repo.id]: makeDetectedResult(repo.id, [], {
authoritative: false,
source: 'metadata-fallback',
unavailableReason: "WSL distro 'Ubuntu' is not running.",
wslDistroStopped: { distro: 'Ubuntu', state }
})
}
})
const container = await render()
const button = container.querySelector('button')
expect(button?.getAttribute('aria-label')).toBe(`${title}. Start distro and scan`)
expect(button?.className).toContain('text-muted-foreground')
expect(button?.className).not.toContain('text-destructive')
await act(async () => {
button?.click()
})
// The click is the user act that may start the distro; the re-list follows it.
expect(scanStartingWslDistro).toHaveBeenCalledWith({ repoId: repo.id })
expect(fetchWorktrees).toHaveBeenCalledWith(repo.id, { executionHostId: 'local' })
expect(calls).toEqual(['start', 'fetch'])
Reflect.deleteProperty(window, 'api')
}
)
})
@@ -12,6 +12,7 @@ import {
stopRepoHeaderKeyboardToggle
} from './header-event-guards'
import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors'
import { RepoWslDistroStoppedIndicator } from './RepoWslDistroStoppedIndicator'
/** Retain failed-scan rows with a warning and explicit retry instead of waiting for an incidental refresh. */
export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JSX.Element | null {
@@ -19,6 +20,9 @@ export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JS
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const [pending, setPending] = React.useState(false)
const failure = resolveRepoScanFailure(repo, detected)
if (detected && !detected.authoritative && detected.wslDistroStopped) {
return <RepoWslDistroStoppedIndicator repo={repo} notRunning={detected.wslDistroStopped} />
}
// Why: machine-wide failures are explained once by the sidebar banner, not on every repo.
if (!failure || isLocalToolchainFailure(failure)) {
return null
@@ -0,0 +1,98 @@
import React from 'react'
import { PowerOff } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { Repo } from '../../../../../../shared/repo-types'
import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import {
handleRepoHeaderActionPointerDown,
stopRepoHeaderKeyboardToggle
} from './header-event-guards'
type WslDistroNotRunning = NonNullable<DetectedWorktreeListResult['wslDistroStopped']>
/** A stopped distro is a state, not a failure: neutral, with stored rows and a click that may start it. */
export function RepoWslDistroStoppedIndicator({
repo,
notRunning
}: {
repo: Repo
notRunning: WslDistroNotRunning
}): React.JSX.Element {
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const [pending, setPending] = React.useState(false)
const title =
notRunning.state === 'stopped'
? translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.wslStoppedTitle',
'WSL distro {{value0}} is stopped',
{ value0: notRunning.distro }
)
: translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.wslUnverifiableTitle',
"Couldn't confirm WSL distro {{value0}} is running",
{ value0: notRunning.distro }
)
// The click is a user act, so this scan may start the distro; a failed start still re-lists.
const startAndScan = async (): Promise<void> => {
try {
await window.api.worktrees.scanStartingWslDistro?.({ repoId: repo.id })
} catch {
// The re-list below reports the distro's state.
}
await fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
}
const startAndScanLabel = translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.wslStartAndScan',
'Start distro and scan'
)
return (
<TooltipProvider disableHoverableContent={false}>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
data-repo-header-action=""
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground',
pending && 'opacity-60'
)}
aria-label={`${title}. ${startAndScanLabel}`}
aria-busy={pending}
disabled={pending}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setPending(true)
void startAndScan().finally(() => setPending(false))
}}
>
<PowerOff className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="text-muted-foreground">
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.wslRetained',
'Showing the worktrees Orca already knew about. They refresh when Orca sees the distro running.'
)}
</div>
<div className="text-muted-foreground">
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.wslStartAndScanHint',
'Click the icon to start the distro and scan again.'
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+6 -1
View File
@@ -7076,7 +7076,12 @@
"xcodeLicense": "Die Apple-Entwicklerwerkzeuge erfordern die Annahme der Lizenz, bevor Git ausgeführt werden kann.",
"developerTools": "Die Apple-Entwicklerwerkzeuge für die Befehlszeile fehlen oder sind nicht verfügbar.",
"architectureMismatch": "Eine zu Git gehörende ausführbare Datei konnte nicht ausgeführt werden, da ihre CPU-Architektur mit diesem Ausführungshost inkompatibel ist. Git und zugehörige Werkzeuge für die Architektur des Hosts installieren.",
"copyDiagnostics": "Diagnosedaten kopieren"
"copyDiagnostics": "Diagnosedaten kopieren",
"wslStoppedTitle": "WSL-Distribution {{value0}} ist gestoppt",
"wslUnverifiableTitle": "Konnte nicht bestätigen, dass die WSL-Distribution {{value0}} läuft",
"wslRetained": "Angezeigt werden die Worktrees, die Orca bereits kannte. Sie werden aktualisiert, sobald Orca die Distribution laufen sieht.",
"wslStartAndScan": "Distribution starten und scannen",
"wslStartAndScanHint": "Klicke auf das Symbol, um die Distribution zu starten und erneut zu scannen."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Xcode-Lizenz akzeptieren, um Git zu verwenden",
+6 -1
View File
@@ -7126,7 +7126,12 @@
"xcodeLicense": "Apple developer tools require license acceptance before Git can run.",
"developerTools": "Apple command-line developer tools are missing or unavailable.",
"architectureMismatch": "A Git-related executable could not run because its CPU architecture is incompatible with this execution host. Install Git and related tools for the host architecture.",
"copyDiagnostics": "Copy diagnostics"
"copyDiagnostics": "Copy diagnostics",
"wslStoppedTitle": "WSL distro {{value0}} is stopped",
"wslUnverifiableTitle": "Couldn't confirm WSL distro {{value0}} is running",
"wslRetained": "Showing the worktrees Orca already knew about. They refresh when Orca sees the distro running.",
"wslStartAndScan": "Start distro and scan",
"wslStartAndScanHint": "Click the icon to start the distro and scan again."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Accept the Xcode license to use Git",
+6 -1
View File
@@ -6708,7 +6708,12 @@
"xcodeLicense": "Las herramientas de desarrollo de Apple requieren la aceptación de la licencia antes de que Git pueda ejecutarse.",
"developerTools": "Las herramientas de desarrollo de línea de comandos de Apple faltan o no están disponibles.",
"architectureMismatch": "No se pudo ejecutar un ejecutable relacionado con Git porque su arquitectura de CPU es incompatible con este host de ejecución. Instale Git y herramientas relacionadas para la arquitectura del host.",
"copyDiagnostics": "Copiar diagnóstico"
"copyDiagnostics": "Copiar diagnóstico",
"wslStoppedTitle": "La distribución WSL {{value0}} está detenida",
"wslUnverifiableTitle": "No se pudo confirmar que la distribución WSL {{value0}} esté en ejecución",
"wslRetained": "Se muestran los worktrees que Orca ya conocía. Se actualizan cuando Orca detecta la distribución en ejecución.",
"wslStartAndScan": "Iniciar distribución y escanear",
"wslStartAndScanHint": "Haz clic en el icono para iniciar la distribución y volver a escanear."
},
"manualSortSwitch": {
"title": "Orden cambiado a Manual",
+6 -1
View File
@@ -6918,7 +6918,12 @@
"xcodeLicense": "Les outils de développement Apple nécessitent l’acceptation d’une licence avant que Git puisse s’exécuter.",
"developerTools": "Les outils de développement en ligne de commande Apple sont manquants ou indisponibles.",
"architectureMismatch": "Un exécutable lié à Git n'a pas pu s'exécuter car son architecture CPU est incompatible avec cet hôte d'exécution. Installez Git et les outils associés pour l'architecture hôte.",
"copyDiagnostics": "Copier les diagnostics"
"copyDiagnostics": "Copier les diagnostics",
"wslStoppedTitle": "La distribution WSL {{value0}} est arrêtée",
"wslUnverifiableTitle": "Impossible de confirmer que la distribution WSL {{value0}} est en cours d'exécution",
"wslRetained": "Affichage des worktrees déjà connus d'Orca. Ils sont actualisés dès qu'Orca voit la distribution en cours d'exécution.",
"wslStartAndScan": "Démarrer la distribution et analyser",
"wslStartAndScanHint": "Cliquez sur l'icône pour démarrer la distribution et relancer l'analyse."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Acceptez la licence Xcode pour utiliser Git",
+6 -1
View File
@@ -6440,7 +6440,12 @@
"xcodeLicense": "Apple 開発者ツールは、Git を実行する前にライセンスへの同意が必要です。",
"developerTools": "Apple コマンドライン開発者ツールが見つからないか、使用できません。",
"architectureMismatch": "Git 関連の実行可能ファイルは、CPU アーキテクチャがこの実行ホストと互換性がないため、実行できませんでした。ホストアーキテクチャ用の Git と関連ツールをインストールします。",
"copyDiagnostics": "コピー診断"
"copyDiagnostics": "コピー診断",
"wslStoppedTitle": "WSL ディストリビューション {{value0}} は停止しています",
"wslUnverifiableTitle": "WSL ディストリビューション {{value0}} が実行中か確認できませんでした",
"wslRetained": "Orca が把握済みのワークツリーを表示しています。ディストリビューションの実行を Orca が確認すると更新されます。",
"wslStartAndScan": "ディストリビューションを起動してスキャン",
"wslStartAndScanHint": "アイコンをクリックすると、ディストリビューションを起動して再スキャンします。"
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Git を使うには Xcode ライセンスに同意してください",
+6 -1
View File
@@ -6440,7 +6440,12 @@
"xcodeLicense": "Apple 개발자 도구를 사용하려면 Git을 실행하기 전에 라이선스 동의가 필요합니다.",
"developerTools": "Apple 명령줄 개발자 도구가 없거나 사용할 수 없습니다.",
"architectureMismatch": "CPU 아키텍처가 이 실행 호스트와 호환되지 않기 때문에 Git 관련 실행 파일을 실행할 수 없습니다. 호스트 아키텍처용 Git 및 관련 도구를 설치합니다.",
"copyDiagnostics": "진단 복사"
"copyDiagnostics": "진단 복사",
"wslStoppedTitle": "WSL 배포판 {{value0}}이(가) 중지되었습니다",
"wslUnverifiableTitle": "WSL 배포판 {{value0}}이(가) 실행 중인지 확인할 수 없습니다",
"wslRetained": "Orca가 이미 알고 있던 워크트리를 표시합니다. Orca가 배포판 실행을 확인하면 새로 고쳐집니다.",
"wslStartAndScan": "배포판 시작 및 스캔",
"wslStartAndScanHint": "아이콘을 클릭하면 배포판을 시작하고 다시 스캔합니다."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Git을 사용하려면 Xcode 라이선스에 동의하세요",
+6 -1
View File
@@ -11873,7 +11873,12 @@
"xcodeLicense": "As ferramentas de desenvolvedor da Apple exigem aceitação de licença antes que o Git possa ser executado.",
"developerTools": "As ferramentas de linha de comando para desenvolvedores da Apple estão faltando ou indisponíveis.",
"architectureMismatch": "Um executável relacionado ao Git não pôde ser executado porque sua arquitetura de CPU é incompatível com este host de execução. Instale o Git e as ferramentas relacionadas para a arquitetura do host.",
"copyDiagnostics": "Copiar diagnósticos"
"copyDiagnostics": "Copiar diagnósticos",
"wslStoppedTitle": "A distribuição WSL {{value0}} está parada",
"wslUnverifiableTitle": "Não foi possível confirmar que a distribuição WSL {{value0}} está em execução",
"wslRetained": "Mostrando as worktrees que o Orca já conhecia. Elas são atualizadas quando o Orca vê a distribuição em execução.",
"wslStartAndScan": "Iniciar distribuição e verificar",
"wslStartAndScanHint": "Clique no ícone para iniciar a distribuição e verificar novamente."
},
"AutoRenameFailedDialog": {
"aed1623b1e": "Fechar",
+6 -1
View File
@@ -7404,7 +7404,12 @@
"xcodeLicense": "Для работы Git нужно принять лицензию инструментов разработки Apple.",
"developerTools": "Инструменты командной строки Apple для разработки отсутствуют или недоступны.",
"architectureMismatch": "Не удалось запустить исполняемый файл, связанный с Git: его архитектура несовместима с архитектурой процессора этого сервера. Установите Git и связанные инструменты для архитектуры этого сервера.",
"copyDiagnostics": "Копировать диагностику"
"copyDiagnostics": "Копировать диагностику",
"wslStoppedTitle": "Дистрибутив WSL {{value0}} остановлен",
"wslUnverifiableTitle": "Не удалось подтвердить, что дистрибутив WSL {{value0}} запущен",
"wslRetained": "Показаны рабочие деревья, уже известные Orca. Они обновятся, когда Orca увидит, что дистрибутив запущен.",
"wslStartAndScan": "Запустить дистрибутив и сканировать",
"wslStartAndScanHint": "Нажмите на значок, чтобы запустить дистрибутив и выполнить сканирование снова."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Примите лицензию Xcode для работы Git",
+6 -1
View File
@@ -7062,7 +7062,12 @@
"xcodeLicense": "Git'in çalışabilmesi için önce Apple geliştirici araçlarının lisansını kabul etmeniz gerekir.",
"developerTools": "Apple komut satırı geliştirici araçları eksik veya kullanılamıyor.",
"architectureMismatch": "Bir Git ile ilgili çalıştırılabilir dosya, CPU mimarisi bu çalıştırma host'uyla uyumsuz olduğu için çalıştırılamadı. Git ve ilgili araçları host mimarisi için yükleyin.",
"copyDiagnostics": "Tanılamayı kopyala"
"copyDiagnostics": "Tanılamayı kopyala",
"wslStoppedTitle": "WSL dağıtımı {{value0}} durduruldu",
"wslUnverifiableTitle": "WSL dağıtımı {{value0}} öğesinin çalıştığı doğrulanamadı",
"wslRetained": "Orca'nın zaten bildiği worktree'ler gösteriliyor. Orca dağıtımın çalıştığını gördüğünde yenilenirler.",
"wslStartAndScan": "Dağıtımı başlat ve tara",
"wslStartAndScanHint": "Dağıtımı başlatıp yeniden taramak için simgeye tıklayın."
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "Git'i kullanmak için Xcode lisansını kabul edin",
+6 -1
View File
@@ -6902,7 +6902,12 @@
"xcodeLicense": "Apple 開發者工具需要接受授權才能執行 Git。",
"developerTools": "Apple 命令列開發人員工具遺失或不可用。",
"architectureMismatch": "與 Git 相關的可執行檔無法執行,因為其 CPU 架構與此執行主機不相容。為主機架構安裝 Git 和相關工具。",
"copyDiagnostics": "複製診斷"
"copyDiagnostics": "複製診斷",
"wslStoppedTitle": "WSL 發行版 {{value0}} 已停止",
"wslUnverifiableTitle": "無法確認 WSL 發行版 {{value0}} 正在執行",
"wslRetained": "正在顯示 Orca 已知的工作樹。Orca 偵測到該發行版執行後會重新整理。",
"wslStartAndScan": "啟動發行版並掃描",
"wslStartAndScanHint": "點擊圖示啟動該發行版並重新掃描。"
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "接受 Xcode 授權條款以使用 Git",
+6 -1
View File
@@ -6440,7 +6440,12 @@
"xcodeLicense": "Apple 开发者工具需要接受许可证才能运行 Git。",
"developerTools": "Apple 命令行开发人员工具丢失或不可用。",
"architectureMismatch": "与 Git 相关的可执行文件无法运行,因为其 CPU 架构与此执行主机不兼容。为主机架构安装 Git 和相关工具。",
"copyDiagnostics": "复制诊断"
"copyDiagnostics": "复制诊断",
"wslStoppedTitle": "WSL 发行版 {{value0}} 已停止",
"wslUnverifiableTitle": "无法确认 WSL 发行版 {{value0}} 正在运行",
"wslRetained": "正在显示 Orca 已知的工作树。Orca 检测到该发行版运行后会刷新。",
"wslStartAndScan": "启动发行版并扫描",
"wslStartAndScanHint": "点击图标启动该发行版并重新扫描。"
},
"LocalGitToolchainScanBanner": {
"xcodeLicenseTitle": "接受 Xcode 许可协议以使用 Git",
@@ -597,6 +597,46 @@ describe('fetchWorktrees', () => {
expect(result).toBe(false)
})
it('keeps the scanned rows and their tabs when a stopped WSL distro returns stored rows', async () => {
const store = createTestStore()
const scanned = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
branch: 'refs/heads/feature'
})
const notStored = makeWorktree({ id: 'repo1::/path/wt2', repoId: 'repo1', path: '/path/wt2' })
const stored = { ...scanned, branch: '', head: '' }
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('repo1', [stored], {
authoritative: false,
source: 'metadata-fallback',
unavailableReason: "WSL distro 'Ubuntu' is not running.",
wslDistroStopped: { distro: 'Ubuntu', state: 'stopped' }
})
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the merge reads only these tab fields.
store.setState({
worktreesByRepo: { repo1: [scanned, notStored] },
sortEpoch: 7,
tabsByWorktree: { [notStored.id]: [{ id: 'tab-2', worktreeId: notStored.id }] }
} as unknown as Partial<AppState>)
const result = await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([scanned, notStored])
expect(store.getState().tabsByWorktree[notStored.id]).toEqual([
{ id: 'tab-2', worktreeId: notStored.id }
])
expect(store.getState().detectedWorktreesByRepo.repo1?.wslDistroStopped).toEqual({
distro: 'Ubuntu',
state: 'stopped'
})
expect(store.getState().sortEpoch).toBe(7)
expect(result).toBe(false)
})
it('reports unchanged non-authoritative refreshes as not fully refreshed', async () => {
const store = createTestStore()
const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
@@ -25,6 +25,8 @@ export function mergeDetectedWorktreesForHost(
current.source === refreshed.source &&
current.unavailableReason === refreshed.unavailableReason &&
current.failureKind === refreshed.failureKind &&
current.wslDistroStopped?.distro === refreshed.wslDistroStopped?.distro &&
current.wslDistroStopped?.state === refreshed.wslDistroStopped?.state &&
current.worktrees === worktrees
) {
return current
@@ -212,7 +212,9 @@ export function mergeFetchedWorktrees(
args.setup,
matchOptions
)
if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) {
// Stored rows from a stopped WSL distro lack branch state the scanned rows already have.
const keepCurrent = worktrees.length === 0 || !!args.refresh.result.wslDistroStopped
if (!args.refresh.result.authoritative && keepCurrent && currentForHost.length > 0) {
return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected)
? s
: {
@@ -38,6 +38,8 @@ export function areDetectedWorktreeResultsEqual(
current.source === next.source &&
current.unavailableReason === next.unavailableReason &&
current.failureKind === next.failureKind &&
current.wslDistroStopped?.distro === next.wslDistroStopped?.distro &&
current.wslDistroStopped?.state === next.wslDistroStopped?.state &&
catalogRowsEqual(current.worktrees, next.worktrees)
)
}
+2
View File
@@ -250,4 +250,6 @@ export type DetectedWorktreeListResult = {
failureKind?: WorktreeScanFailureKind
/** Which catalog this listing describes; additive, older hosts omit it. */
catalogVersion?: WorktreeCatalogVersion
/** The repo's WSL distro is stopped or unverifiable, so rows are stored ones; additive, older hosts omit it. */
wslDistroStopped?: { distro: string; state: 'stopped' | 'unverifiable' }
}