fix(wsl-host): stopped-host follow-ups — no stuck Start card, card-rule retry and dot, SSH pin (#35822)

* fix(wsl-host): a stop published after the host came back says ready again; retry the card's panes

- e9-reviewer: a runtime call's stop could land after liveness had already reconnected and said
  ready, leaving Start on a working host. Once the distro runs, the deferred un-park re-reads the
  host and says ready from the live connection, or reconnects open work as before.
- fix-reviewer-2: the return retry picks panes by the Start card's rule, so a pane whose create
  failed before it reached the host is retried too.
- Pins that the refusal stays a plain, final Error with the same words for the move and automation
  callers.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(wsl-host): the stopped dot keeps panes on another, running server live

e9-reviewer: match the Start card's rule; only a pane with no server or on the stopped host ended.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(terminal): pin that the host-refusal unwrap leaves SSH words and other channels as they were

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-29 01:04:12 -07:00
committed by GitHub
co-authored by Claude
parent ab0e87c6a7
commit 1f13ec6f70
8 changed files with 113 additions and 13 deletions
+22 -2
View File
@@ -10,6 +10,7 @@ import {
wslHostRunRefusal
} from './wsl-host-add-admission'
import { admitConnectToPendingHost } from './wsl-host-removal'
import type { WslOrcadConnection } from './wsl-orcad-connection'
export type WslHostRuntimeCallPort = {
record: WslHostDeploymentRecord | null
@@ -21,6 +22,7 @@ export type WslHostRuntimeCallPort = {
isVisible: () => boolean
/** Shown as liveness shows a stop, so the host's panes offer Start. */
publishStopped: (record: WslHostDeploymentRecord) => void
publishReady: (record: WslHostDeploymentRecord, connection: WslOrcadConnection) => void
/** The same call again, once the distro is confirmed running. */
retry: () => Promise<void>
}
@@ -73,8 +75,26 @@ function parkStopped(record: WslHostDeploymentRecord, port: WslHostRuntimeCallPo
}
const deferUntilRunning = port.deps.deferUntilRunning ?? deferUntilWslDistroRunning
deferUntilRunning(distro, `wsl-host-parked:${environmentId}`, () => {
if (port.parked.delete(environmentId) && port.isVisible()) {
void port.retry().catch(() => {})
if (port.parked.delete(environmentId)) {
void resumeAfterStop(record, port).catch(() => {})
}
})
}
async function resumeAfterStop(
record: WslHostDeploymentRecord,
port: WslHostRuntimeCallPort
): Promise<void> {
// A stop published after liveness had already reconnected would leave Start on a working host.
const connection = port.connector?.get(record.distro) ?? null
if (
connection &&
(await port.connector?.reachability(record.distro, record.environmentId)) === 'available'
) {
port.publishReady(record, connection)
return
}
if (port.isVisible()) {
await port.retry()
}
}
@@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime/remote-runtime-client-error'
import type { WslHostStatus } from '../../shared/wsl-host-types'
import { WslHostService, type WslHostConnector } from './wsl-host-service'
import { UBUNTU, makeConnector } from './wsl-host-service-test-connector'
@@ -84,4 +85,31 @@ describe('a runtime call that finds the WSL host stopped', () => {
await Promise.resolve()
expect(connector.connect).not.toHaveBeenCalled()
})
// e9-reviewer: the stop can land after liveness already reconnected and said ready.
it('says ready again when the host was already back by the time the distro ran', async () => {
const { connector, service } = await stoppedUnderVisibleWork()
await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow('Ubuntu was stopped')
connector.distro.state = 'running'
// Liveness reconnected on its own.
await connector.connect('Ubuntu')
connector.connect.mockClear()
deferred.get('Ubuntu:wsl-host-parked:wsl:ubuntu')?.()
await vi.waitFor(() =>
expect(published.at(-1)).toMatchObject({ environmentId: 'wsl:ubuntu', state: 'ready' })
)
expect(connector.connect).not.toHaveBeenCalled()
})
// What wsl-move-host-connect and wsl-host-automation-enable read: unchanged by this fix.
it('keeps the refusal a plain, final Error with the same words', async () => {
const { service } = await stoppedUnderVisibleWork()
const refusal = await service.ensureForRuntimeCall('wsl:ubuntu').catch((error) => error)
expect(refusal).toBeInstanceOf(Error)
expect(refusal).not.toBeInstanceOf(RemoteRuntimeClientError)
expect(refusal).toMatchObject({
message:
'Ubuntu was stopped. Switch to one of its projects, or reconnect it in Settings → Remote Orca Servers, to start it again.'
})
})
})
+2
View File
@@ -219,6 +219,8 @@ export class WslHostService {
isVisible: () => this.visible.has(environmentId),
publishStopped: (record) =>
this.setStatus(record, { state: 'distro-stopped', terminals: 'exited', recovering: false }),
publishReady: (record, connection) =>
this.setStatus(record, { state: 'ready', census: null }, connection),
retry: () => this.ensureForRuntimeCall(environmentId)
})
}
@@ -5,6 +5,7 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age
import type { Tab } from '../../../../shared/tab-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -182,6 +183,15 @@ describe('useWorktreeActivityStatus', () => {
expect(renderToStaticMarkup(<StatusProbe worktreeId={worktreeId} />)).toBe(
'<span>inactive</span>'
)
// e9-reviewer: a pane bound to another, running server is the card's exception too.
mockState = {
...mockState,
ptyIdsByTabId: { 'tab-1': [toRemoteRuntimePtyId('h1', 'ssh:desk')] }
}
expect(renderToStaticMarkup(<StatusProbe worktreeId={worktreeId} />)).toBe(
'<span>active</span>'
)
})
it('keeps a restored offscreen working agent yellow from the hook snapshot', () => {
@@ -12,8 +12,26 @@ import {
import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
import { selectStoppedWslDistro } from '@/lib/wsl-host-stopped-status'
import { selectWorkspaceRuntimeEnvironmentId } from '../terminal-pane/remote-server-outage'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
const NO_LIVE_PTYS: Record<string, string[]> = {}
/** The Start card's rule: a pane with no server yet, or on the stopped one, ended with its distro. */
function withoutStoppedHostPtys(
ptyIdsByTabId: Record<string, string[]>,
stoppedEnvironmentId: string
): Record<string, string[]> {
const live: Record<string, string[]> = {}
for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) {
const kept = ptyIds.filter((ptyId) => {
const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId
return environmentId != null && environmentId !== stoppedEnvironmentId
})
// Same array when nothing dropped, so useShallow keeps the card from re-rendering.
if (kept.length > 0) {
live[tabId] = kept.length === ptyIds.length ? ptyIds : kept
}
}
return live
}
// Why the cheap check first: every card's selector runs on each store update, and few hosts stop.
function hasStoppedWslHost(state: {
@@ -38,13 +56,17 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
)
// Why one selector: a second subscription per card costs a store listener on every workspace.
const ptyIdsForWorktree = useAppStore(
useShallow((s) =>
// A stopped distro ended every terminal in it, whatever bindings the panes keep for its Start.
hasStoppedWslHost(s) &&
selectStoppedWslDistro(s, selectWorkspaceRuntimeEnvironmentId(s, worktreeId)) !== null
? NO_LIVE_PTYS
: selectLivePtyIdsForWorktree(s, worktreeId)
)
useShallow((s) => {
const ptyIds = selectLivePtyIdsForWorktree(s, worktreeId)
if (!hasStoppedWslHost(s)) {
return ptyIds
}
// A stopped distro ended its terminals, whatever bindings the panes keep for its Start.
const owner = selectWorkspaceRuntimeEnvironmentId(s, worktreeId)
return owner && selectStoppedWslDistro(s, owner) !== null
? withoutStoppedHostPtys(ptyIds, owner)
: ptyIds
})
)
const terminalLayoutRootsByTabId = useAppStore(
useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId))
@@ -219,6 +219,17 @@ describe('humanizeTerminalError', () => {
)
})
// SSH pin: only the wrapper goes; the SSH host's own words, and other channels' wrappers, stay.
it("leaves an SSH host's refusal words and other IPC channels' wrappers as they were", () => {
expect(
humanizeTerminalError(
"Error invoking remote method 'runtimeEnvironments:call': Error: Remote Orca runtime is not connected: ssh desk"
)
).toBe('Remote Orca runtime is not connected: ssh desk')
const otherChannel = "Error invoking remote method 'ssh:connect': Error: Permission denied"
expect(humanizeTerminalError(otherChannel)).toBe(otherChannel)
})
it('replaces the terminal-host-gone code with copy that explains the loss', () => {
const humanized = humanizeTerminalError('terminal_host_gone')
expect(humanized).not.toContain('terminal_host_gone')
@@ -29,11 +29,14 @@ describe('a stopped WSL host coming back on its own', () => {
it("retries the host's panes once it is ready again, and only those", () => {
hostStatus('ready')
const hostPane = pane('wsl:ubuntu')
// A pane whose create failed before it reached the host has no server yet: still the host's.
const unboundPane = pane(null)
const otherPane = pane('ssh:desk')
const transports = {
current: new Map([
[1, hostPane],
[2, otherPane]
[2, unboundPane],
[3, otherPane]
])
}
const { rerender } = renderHook(() =>
@@ -46,6 +49,7 @@ describe('a stopped WSL host coming back on its own', () => {
rerender()
}
expect(hostPane.retryRecovery).toHaveBeenCalledOnce()
expect(unboundPane.retryRecovery).toHaveBeenCalledOnce()
expect(otherPane.retryRecovery).not.toHaveBeenCalled()
// Ready again with no stop between leaves healthy panes alone.
@@ -23,8 +23,11 @@ export function useRetryPanesWhenWslHostReturns(
}
stoppedHost.current = null
for (const transport of paneTransports.current.values()) {
// Why only the host's panes: one on another server never met this stop.
if (transport.getRuntimeEnvironmentId?.() === ownerEnvironmentId) {
// The card's rule: a pane with no server yet is still the workspace's; one on another never met this stop.
const paneEnvironmentId = transport.getRuntimeEnvironmentId
? transport.getRuntimeEnvironmentId()
: ownerEnvironmentId
if (paneEnvironmentId == null || paneEnvironmentId === ownerEnvironmentId) {
transport.retryRecovery?.()
}
}