From 584b11288dea158da59b230daf8c6c41534b080b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:55:30 -0700 Subject: [PATCH] fix(agent-hooks): the WSL hook relay stays down while its distro is stopped (E9) (#34195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --- src/main/agent-hooks/wsl-hook-relay-deps.ts | 4 +- .../wsl-hook-relay-manager.test.ts | 1 + .../agent-hooks/wsl-hook-relay-manager.ts | 18 +- .../wsl-hook-relay-recovery.test.ts | 185 +++++++++++++++++- .../agent-hooks/wsl-hook-relay-recovery.ts | 39 +++- .../wsl-hook-relay-state-machine.ts | 86 ++++++++ src/main/agent-hooks/wsl-hook-relay-state.ts | 12 ++ src/main/wsl-background-distro-gate.ts | 14 +- 8 files changed, 336 insertions(+), 23 deletions(-) diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index e8f2cfe84a1..7fb6ac7eec5 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -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 /** 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, diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index a6f63d3dccf..427594ed346 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -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()), diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 0a14f293554..dad34d49048 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -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 = {}) { 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 { diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index dce51906837..83f6a83822a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -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 { + 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() + 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() + 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']]) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts index 02495fc2cf7..ec8d9a36e90 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -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 + /** A confirmed running list read started after `startedAfter`; a failed read counts as stopped. */ + isDistroRunning: (distro: string, startedAfter: number) => Promise + /** 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) } } diff --git a/src/main/agent-hooks/wsl-hook-relay-state-machine.ts b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts index 04944712a57..4256c44f1f4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state-machine.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts @@ -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 + +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, + ensure: WslRelayEnsure, + distro: string, + setup: WslRelayRestartSetup +): Promise { + 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 + 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) + } + } + }) +} diff --git a/src/main/agent-hooks/wsl-hook-relay-state.ts b/src/main/agent-hooks/wsl-hook-relay-state.ts index a245c18ca29..93e6cc47825 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state.ts @@ -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')[] } diff --git a/src/main/wsl-background-distro-gate.ts b/src/main/wsl-background-distro-gate.ts index fd4ece1a840..3a0f30f91df 100644 --- a/src/main/wsl-background-distro-gate.ts +++ b/src/main/wsl-background-distro-gate.ts @@ -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 { - return listRunningWslDistrosAsync({ requireConfirmed: true }).catch(() => []) +export async function listConfirmedRunningWslDistros( + options: { startedAfter?: number } = {} +): Promise { + return listRunningWslDistrosAsync({ requireConfirmed: true, ...options }).catch(() => []) } export type WslRunningDistrosRead = @@ -35,9 +37,13 @@ export async function readConfirmedRunningWslDistros( } } -export async function isWslDistroRunning(distro: string): Promise { +/** `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 { const wanted = distroKey(distro) - const running = await listConfirmedRunningWslDistros() + const running = await listConfirmedRunningWslDistros(options) return running.some((candidate) => distroKey(candidate) === wanted) }