mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
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:
@@ -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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user