fix(agent-hooks): the WSL hook relay stays down while its distro is stopped (E9) (#34195)

* fix(agent-hooks): the WSL hook relay stays down while its distro is stopped (E9)

verify-15b: after `wsl --terminate`, the relay link died ("mux disposed
(connection_lost); scheduling restart") and about 10 s later a `wsl.exe … launch.sh`
restart booted the stopped distro. The restart already asked whether the distro
runs, but any running-list answer would do, including one from a read that started
before the link died. Now:
- only a confirmed running-list read started after the link died can allow the
  restart (the shared startedAfter rule; a failed read counts as stopped);
- while the distro is stopped the relay stays down, and it comes back on its own once
  something else confirms the distro runs again (deferUntilWslDistroRunning), or when
  a WSL terminal re-ensures it as before. Nothing in recovery boots a distro.

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

* fix(agent-hooks): keep the Codex home and pi/omp setups across a deferred relay restart

A stopped verdict dropped the relay state before the deferred restart ran, so the
relay came back without the managed Codex home or the pi/omp launch setups.
Capture them first and pass them through. Also re-probe once after 30 s so a
failed running-list read while the distro is up does not leave the relay down.

* fix(agent-hooks): anchor the relay restart check at check time and keep all relay config

The running-list read must start when the restart check runs, not when the link
died: a read begun right after a terminate can still list the distro. A deferred
restart now seeds every relay config field (Codex home, Claude agent view and
config dirs, OpenCode overlays, pi/omp setups) so ensure brings them back.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 18:55:30 -07:00
committed by GitHub
co-authored by Claude
parent 7f846fd4f9
commit 584b11288d
8 changed files with 336 additions and 23 deletions
+3 -1
View File
@@ -20,7 +20,7 @@ import {
} from './wsl-hook-relay-launch'
import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel'
import { listWslDistrosAsync } from '../wsl'
import { isWslDistroRunning } from '../wsl-background-distro-gate'
import { deferUntilWslDistroRunning, isWslDistroRunning } from '../wsl-background-distro-gate'
import { isWslAgentHookRelayEnabled } from '../../shared/agent-hook-relay'
// Concurrent wsl.exe spawns can trigger transient E_UNEXPECTED failures.
@@ -49,6 +49,7 @@ export type WslHookRelayManagerDeps = {
listDistros: () => Promise<string[]>
/** Recovery must not boot a distro the user shut down. */
isDistroRunning: typeof isWslDistroRunning
deferUntilRunning: typeof deferUntilWslDistroRunning
spawnRelay: typeof spawnWslRelayProcess
runInstall: typeof runWslInstallProcess
waitForSentinel: typeof waitForWslRelaySentinel
@@ -91,6 +92,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
readBundle: (jsPath) => readFileSync(jsPath),
listDistros: () => listWslDistrosAsync(),
isDistroRunning: isWslDistroRunning,
deferUntilRunning: deferUntilWslDistroRunning,
spawnRelay: spawnWslRelayProcess,
runInstall: runWslInstallProcess,
waitForSentinel: waitForWslRelaySentinel,
@@ -236,6 +236,7 @@ describe('WslHookRelayManager', () => {
readBundle: () => Buffer.from('// bundle'),
listDistros: async () => ['Ubuntu'],
isDistroRunning: vi.fn(async () => true),
deferUntilRunning: vi.fn(),
spawnRelay: vi.fn(() => fakeChild()),
runInstall: vi.fn(async () => ({ code: 0, stderr: '' })),
waitForSentinel: vi.fn(async () => guestTransport()),
+6 -12
View File
@@ -15,7 +15,7 @@ import {
type WslHookRelayManagerDeps
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import type { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
@@ -29,6 +29,7 @@ import {
wslRuntimeHomePathsEqual
} from '../codex/managed-wsl-codex-home-registry'
import {
createWslRelayRecoveryForManager,
markWslRelayFailed,
resolveWslDefaultDistro,
resumeWslStoppedRelays
@@ -44,18 +45,11 @@ export class WslHookRelayManager {
private warnedBundleMissing = false
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
this.deps = { ...defaultWslHookRelayDeps, ...deps }
this.recovery = new WslRelayRecovery({
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
this.recovery = createWslRelayRecoveryForManager({
deps: () => this.deps,
states: this.states,
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
dropState: (state) => {
const key = wslHookRelayStateKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
}
ensure: (...args) => this.ensureForDistro(...args)
})
}
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery'
import type { WslRelayDistroState, WslRelayRestartSetup } from './wsl-hook-relay-state'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { captureWslRelaySetup, restartWslRelay } from './wsl-hook-relay-state-machine'
beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }))
@@ -19,6 +22,8 @@ describe('WslRelayRecovery', () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
@@ -29,7 +34,7 @@ describe('WslRelayRecovery', () => {
recovery.scheduleRestart(state)
await vi.advanceTimersByTimeAsync(250)
await waitFor(() => restart.mock.calls.length === 1)
expect(restart).toHaveBeenCalledWith('Ubuntu')
expect(restart).toHaveBeenCalledWith('Ubuntu', { launchKinds: [] })
})
it('drops the state instead of booting a stopped distro', async () => {
@@ -38,6 +43,8 @@ describe('WslRelayRecovery', () => {
const warn = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => false,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn,
isDisposed: () => false,
isCurrent: () => true,
@@ -52,11 +59,116 @@ describe('WslRelayRecovery', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running'))
})
it('after a terminate, stays down and comes back only once the distro runs again (E9)', async () => {
const restart = vi.fn()
const dropState = vi.fn()
const deferred: (() => void)[] = []
const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false)
const recovery = new WslRelayRecovery({
isDistroRunning,
deferUntilRunning: (_distro, task) => deferred.push(task),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState
})
const state = makeState()
recovery.scheduleRestart(state)
await vi.advanceTimersByTimeAsync(250)
await waitFor(() => dropState.mock.calls.length === 1)
expect(restart).not.toHaveBeenCalled()
expect(deferred).toHaveLength(1)
// Something else (the user) started the distro: only now does the relay come back.
deferred[0]()
expect(restart).toHaveBeenCalledExactlyOnceWith('Ubuntu', { launchKinds: [] })
})
it('restarts with the relay config captured before dropState discarded it', async () => {
const restart = vi.fn()
const deferred: (() => void)[] = []
let live: WslRelayRestartSetup | undefined = {
codexHomePath: '/home/u/.orca-codex',
claudeConfigDir: '/home/u/.orca/claude',
launchKinds: ['pi', 'omp']
}
const expected = live
const recovery = new WslRelayRecovery({
isDistroRunning: async () => false,
deferUntilRunning: (_distro, task) => deferred.push(task),
captureSetup: () => live ?? { launchKinds: [] },
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: () => {
live = undefined
}
})
recovery.scheduleRestart(makeState())
await vi.advanceTimersByTimeAsync(250)
await waitFor(() => deferred.length === 1)
deferred[0]()
expect(restart).toHaveBeenCalledExactlyOnceWith('Ubuntu', expected)
})
it('accepts only a running-list read started at check time, not one begun at the terminate', async () => {
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false)
const recovery = new WslRelayRecovery({
isDistroRunning,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart: vi.fn(),
dropState: vi.fn()
})
recovery.scheduleRestart(makeState())
// A read begun just after the terminate may still list the distro; the check runs later.
now.mockReturnValue(11_000)
await vi.advanceTimersByTimeAsync(250)
await waitFor(() => isDistroRunning.mock.calls.length === 1)
expect(isDistroRunning.mock.calls[0]?.[1]).toBe(11_000)
now.mockRestore()
})
it('re-probes once after a stopped verdict and restarts only once', async () => {
const restart = vi.fn()
const deferred: (() => void)[] = []
const isDistroRunning = vi.fn(async () => false)
const recovery = new WslRelayRecovery({
isDistroRunning,
deferUntilRunning: (_distro, task) => deferred.push(task),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: vi.fn()
})
recovery.scheduleRestart(makeState())
await vi.advanceTimersByTimeAsync(250)
await waitFor(() => deferred.length === 1)
// The first read was wrong (the distro was still running): the re-probe recovers it.
isDistroRunning.mockResolvedValue(true)
await vi.advanceTimersByTimeAsync(30_000)
await waitFor(() => restart.mock.calls.length === 1)
deferred[0]()
await vi.advanceTimersByTimeAsync(60_000)
expect(isDistroRunning).toHaveBeenCalledTimes(2)
expect(restart).toHaveBeenCalledTimes(1)
})
it('does nothing when the state was replaced before the restart probe', async () => {
const restart = vi.fn()
const probe = vi.fn(async () => true)
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => false,
@@ -78,6 +190,8 @@ describe('WslRelayRecovery', () => {
const dropState = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
@@ -102,6 +216,8 @@ describe('WslRelayRecovery', () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
@@ -122,6 +238,8 @@ describe('WslRelayRecovery', () => {
const run = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => true,
isCurrent: () => true,
@@ -139,6 +257,8 @@ describe('WslRelayRecovery', () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
deferUntilRunning: vi.fn(),
captureSetup: () => ({ launchKinds: [] }),
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
@@ -157,3 +277,66 @@ describe('WslRelayRecovery', () => {
expect(state.reinstallTimer).toBeUndefined()
})
})
describe('restartWslRelay', () => {
function relayState(overrides: Partial<WslRelayDistroState> = {}): WslRelayDistroState {
return {
distro: 'Ubuntu',
phase: 'running',
failures: 0,
cooldownUntil: 0,
launchKinds: new Set(),
...overrides
}
}
it('seeds a dropped relay so ensure inherits every captured config field', async () => {
const states = new Map<string, WslRelayDistroState>()
const key = wslHookRelayStateKey('Ubuntu')
const source = relayState({
codexHomePath: '/h',
claudeAgentViewDir: '/av',
claudeConfigDir: '/cc',
opencodeOverlayDir: '/oc',
opencode2OverlayDir: '/oc2',
piAgentDir: '/pi',
ompStatusExtension: '/omp',
launchKinds: new Set(['pi', 'omp'])
})
const setup = captureWslRelaySetup(source)
const seen: (WslRelayDistroState | undefined)[] = []
const ensure = vi.fn(async () => {
seen.push(states.get(key))
states.set(key, relayState({ ...states.get(key), phase: 'starting' }))
})
await restartWslRelay(states, ensure, 'Ubuntu', setup)
const { phase: _p, ...expectedConfig } = source
expect(seen[0]).toMatchObject({ ...expectedConfig, phase: 'failed', cooldownUntil: 0 })
expect(ensure.mock.calls).toEqual([
['Ubuntu'],
['Ubuntu', undefined, 'pi'],
['Ubuntu', undefined, 'omp']
])
})
it('removes the seed when ensure bails without replacing it', async () => {
const states = new Map<string, WslRelayDistroState>()
await restartWslRelay(
states,
vi.fn(async () => {}),
'Ubuntu',
{ codexHomePath: '/h', launchKinds: [] }
)
expect(states.size).toBe(0)
})
it('leaves a relay a terminal already re-ensured in place', async () => {
const live = relayState({ codexHomePath: '/new' })
const states = new Map([[wslHookRelayStateKey('Ubuntu'), live]])
const ensure = vi.fn(async () => {})
await restartWslRelay(states, ensure, 'Ubuntu', { codexHomePath: '/old', launchKinds: ['pi'] })
expect(states.get(wslHookRelayStateKey('Ubuntu'))).toBe(live)
expect(live.codexHomePath).toBe('/new')
expect(ensure.mock.calls).toEqual([['Ubuntu'], ['Ubuntu', undefined, 'pi']])
})
})
@@ -1,3 +1,5 @@
import type { WslRelayRestartSetup } from './wsl-hook-relay-state'
export type WslRelayRecoveryState = {
distro: string
cooldownUntil: number
@@ -6,14 +8,22 @@ export type WslRelayRecoveryState = {
}
export type WslRelayRecoveryIo = {
isDistroRunning: (distro: string) => Promise<boolean>
/** A confirmed running list read started after `startedAfter`; a failed read counts as stopped. */
isDistroRunning: (distro: string, startedAfter: number) => Promise<boolean>
/** Runs `task` once something else confirms the distro runs again; never boots it. */
deferUntilRunning: (distro: string, task: () => void) => void
warn: (message: string) => void
isDisposed: () => boolean
isCurrent: (state: WslRelayRecoveryState) => boolean
restart: (distro: string) => void
/** Snapshot of the relay config; read before `dropState` discards it. */
captureSetup: (state: WslRelayRecoveryState) => WslRelayRestartSetup
restart: (distro: string, setup: WslRelayRestartSetup) => void
dropState: (state: WslRelayRecoveryState) => void
}
// A failed running-list read is not proof the distro stopped; look once more after WSL's backoff.
const REPROBE_AFTER_STOPPED_MS = 30_000
export class WslRelayRecovery {
constructor(private io: WslRelayRecoveryIo) {}
@@ -56,18 +66,37 @@ export class WslRelayRecovery {
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
const running = await this.io.isDistroRunning(state.distro)
// Captured now: once the state is dropped, a later restart would otherwise come back bare.
const setup = this.io.captureSetup(state)
// Anchored at check time: a read begun right after the terminate may still list the distro.
const running = await this.io.isDistroRunning(state.distro, performance.now())
// The probe await may outlive state replacement; never drop or restart the replacement relay.
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
if (!running) {
this.io.warn(
`[agent-hooks] WSL hook relay (${state.distro}): distro not running (or probe failed); restart skipped (next WSL terminal re-ensures)`
`[agent-hooks] WSL hook relay (${state.distro}): distro not running (or probe failed); staying down until it runs again or a WSL terminal re-ensures`
)
this.io.dropState(state)
// E9: come back when the distro runs again (started by the user or another act), never boot it.
let restarted = false
const restartOnce = (): void => {
if (!restarted && !this.io.isDisposed()) {
restarted = true
this.io.restart(state.distro, setup)
}
}
this.io.deferUntilRunning(state.distro, restartOnce)
const reprobe = setTimeout(() => {
void this.io
.isDistroRunning(state.distro, performance.now())
.then((running) => running && restartOnce())
.catch(() => {})
}, REPROBE_AFTER_STOPPED_MS)
reprobe.unref?.()
return
}
this.io.restart(state.distro)
this.io.restart(state.distro, setup)
}
}
@@ -1,3 +1,7 @@
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import type { WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import type { WslRelayDistroState, WslRelayRestartSetup } from './wsl-hook-relay-state'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { FAILURE_COOLDOWN_MAX_MS } from './wsl-hook-relay-deps'
type RelayFailureState = {
@@ -70,3 +74,85 @@ export async function resolveWslDefaultDistro(
return null
}
}
type WslRelayEnsure = (
distro: string,
codexHomePath?: string,
launchKind?: 'pi' | 'omp'
) => Promise<void>
export function captureWslRelaySetup(state: WslRelayDistroState): WslRelayRestartSetup {
return {
codexHomePath: state.codexHomePath,
claudeAgentViewDir: state.claudeAgentViewDir,
claudeConfigDir: state.claudeConfigDir,
opencodeOverlayDir: state.opencodeOverlayDir,
opencode2OverlayDir: state.opencode2OverlayDir,
piAgentDir: state.piAgentDir,
ompStatusExtension: state.ompStatusExtension,
launchKinds: [...state.launchKinds]
}
}
/** Brings a relay back with everything it served before, even after its state was dropped. */
export async function restartWslRelay(
states: Map<string, WslRelayDistroState>,
ensure: WslRelayEnsure,
distro: string,
setup: WslRelayRestartSetup
): Promise<void> {
const key = wslHookRelayStateKey(distro)
let seed: WslRelayDistroState | undefined
if (!states.has(key)) {
// A cooled-down failed state: ensure replaces it with a starting relay that inherits its config.
seed = {
distro,
...setup,
launchKinds: new Set(setup.launchKinds),
phase: 'failed',
failures: 0,
cooldownUntil: 0
}
states.set(key, seed)
}
// No Codex home argument: ensure keeps the seed's, and never overwrites a newer relay's.
const ensures = [ensure(distro)]
for (const launchKind of setup.launchKinds) {
ensures.push(ensure(distro, undefined, launchKind))
}
await Promise.all(ensures)
// An ensure that bailed (gate closed, no bundle) must not leave the seed behind.
if (seed && states.get(key) === seed) {
states.delete(key)
}
}
/** The relay manager's recovery: restarts only into a distro confirmed running, never boots it. */
export function createWslRelayRecoveryForManager(manager: {
deps: () => WslHookRelayManagerDeps
states: Map<string, WslRelayDistroState>
isDisposed: () => boolean
ensure: WslRelayEnsure
}): WslRelayRecovery {
const { states } = manager
return new WslRelayRecovery({
isDistroRunning: (distro, startedAfter) =>
manager.deps().isDistroRunning(distro, { startedAfter }),
deferUntilRunning: (distro, task) =>
manager.deps().deferUntilRunning(distro, 'wsl-hook-relay-restart', task),
warn: (message) => manager.deps().warn(message),
isDisposed: manager.isDisposed,
isCurrent: (state) => states.get(wslHookRelayStateKey(state.distro)) === state,
captureSetup: (state) => {
const current = states.get(wslHookRelayStateKey(state.distro))
return current ? captureWslRelaySetup(current) : { launchKinds: [] }
},
restart: (distro, setup) => void restartWslRelay(states, manager.ensure, distro, setup),
dropState: (state) => {
const key = wslHookRelayStateKey(state.distro)
if (states.get(key) === state) {
states.delete(key)
}
}
})
}
@@ -27,3 +27,15 @@ export type WslRelayDistroState = {
lastAttemptOpenCodeSettings?: string
lastInstallMux?: SshChannelMultiplexer
}
/** The config a relay served, kept across a stopped-distro drop so its restart brings it all back. */
export type WslRelayRestartSetup = Pick<
WslRelayDistroState,
| 'codexHomePath'
| 'claudeAgentViewDir'
| 'claudeConfigDir'
| 'opencodeOverlayDir'
| 'opencode2OverlayDir'
| 'piAgentDir'
| 'ompStatusExtension'
> & { launchKinds: readonly ('pi' | 'omp')[] }
+10 -4
View File
@@ -13,8 +13,10 @@ function distroKey(distro: string): string {
}
/** Require a fresh running-list result; last-known-good fallback could boot a distro that has since stopped. */
export async function listConfirmedRunningWslDistros(): Promise<string[]> {
return listRunningWslDistrosAsync({ requireConfirmed: true }).catch(() => [])
export async function listConfirmedRunningWslDistros(
options: { startedAfter?: number } = {}
): Promise<string[]> {
return listRunningWslDistrosAsync({ requireConfirmed: true, ...options }).catch(() => [])
}
export type WslRunningDistrosRead =
@@ -35,9 +37,13 @@ export async function readConfirmedRunningWslDistros(
}
}
export async function isWslDistroRunning(distro: string): Promise<boolean> {
/** `startedAfter`: only a running-list read started after it counts, e.g. after a link died. */
export async function isWslDistroRunning(
distro: string,
options: { startedAfter?: number } = {}
): Promise<boolean> {
const wanted = distroKey(distro)
const running = await listConfirmedRunningWslDistros()
const running = await listConfirmedRunningWslDistros(options)
return running.some((candidate) => distroKey(candidate) === wanted)
}