fix(wsl): await Pi and OMP guest relay materialization (#21721)

* fix(wsl): await Pi and OMP guest relay materialization

* fix(wsl): materialize Pi extension before guest launch

* fix(wsl): keep relay state under lint limit

* fix(wsl): preserve guest agent readiness across launches

* test(wsl): expect guest status path translation

* ci: rerun PR checks after rebase

* ci: retrigger PR checks

* ci: run final PR verification
This commit is contained in:
Neil
2026-09-20 16:17:54 -07:00
committed by GitHub
parent 5b8ac36f41
commit 3b055c869f
17 changed files with 450 additions and 138 deletions
@@ -16,29 +16,39 @@ type GuestPluginInstallDeps = {
* with `unavailable` (no handler / teardown): only `none` means the previously
* recorded dir is now unusable and must stop being advertised to PTYs. */
export type GuestOverlayResult =
| { kind: 'dir'; dir?: string; dir2?: string }
| { kind: 'dir'; dir?: string; dir2?: string; piDir?: string; ompDir?: string }
| { kind: 'none' }
| { kind: 'unavailable' }
export async function requestGuestOpenCodeOverlayDir(
mux: SshChannelMultiplexer,
deps: GuestPluginInstallDeps,
distro: string
distro: string,
launchKind?: 'pi' | 'omp'
): Promise<GuestOverlayResult> {
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape.
const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as {
overlayDirs?: { opencode?: unknown; opencode2?: unknown }
const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, {
...deps.pluginSources(),
...(launchKind ? { launchKind } : {})
})) as {
overlayDirs?: { opencode?: unknown; opencode2?: unknown; pi?: unknown; omp?: unknown }
}
const dir = res?.overlayDirs?.opencode
const dir2 = res?.overlayDirs?.opencode2
const piDir = res?.overlayDirs?.pi
const ompDir = res?.overlayDirs?.omp
const opencodeDir = typeof dir === 'string' && dir.length > 0 ? dir : undefined
const opencode2Dir = typeof dir2 === 'string' && dir2.length > 0 ? dir2 : undefined
return opencodeDir || opencode2Dir
const guestPiDir = typeof piDir === 'string' && piDir.length > 0 ? piDir : undefined
const guestOmpDir = typeof ompDir === 'string' && ompDir.length > 0 ? ompDir : undefined
return opencodeDir || opencode2Dir || guestPiDir || guestOmpDir
? {
kind: 'dir',
...(opencodeDir ? { dir: opencodeDir } : {}),
...(opencode2Dir ? { dir2: opencode2Dir } : {})
...(opencode2Dir ? { dir2: opencode2Dir } : {}),
...(guestPiDir ? { piDir: guestPiDir } : {}),
...(guestOmpDir ? { ompDir: guestOmpDir } : {})
}
: { kind: 'none' }
} catch (err) {
+4 -1
View File
@@ -10,6 +10,7 @@ import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCode2PluginSource, getOpenCodePluginSource } from '../opencode/hook-service'
import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source'
import { codexHookService } from '../codex/hook-service'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { PluginSources } from '../../relay/plugin-overlay'
@@ -121,7 +122,9 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({
opencodePluginSource: getOpenCodePluginSource(),
opencode2PluginSource: getOpenCode2PluginSource()
opencode2PluginSource: getOpenCode2PluginSource(),
piExtensionSource: getPiAgentStatusExtensionSource('pi'),
ompExtensionSource: getPiAgentStatusExtensionSource('omp')
}),
warn: (message) => console.warn(message),
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
@@ -27,7 +27,20 @@ type GuestInstallState = {
codexHomePath?: string
opencodeOverlayDir?: string
opencode2OverlayDir?: string
piAgentDir?: string
ompStatusExtension?: string
lastInstallAt?: number
launchKinds?: Set<'pi' | 'omp'>
installation?: Promise<void>
}
function* requestedKinds(state: GuestInstallState): Generator<'pi' | 'omp' | undefined> {
if (!state.launchKinds?.size) {
yield undefined
}
if (state.launchKinds) {
yield* state.launchKinds
}
}
export async function runWslRelayGuestInstall(
@@ -35,6 +48,23 @@ export async function runWslRelayGuestInstall(
state: GuestInstallState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
if (state.installation) {
return state.installation
}
state.installation = installGuestHooksAndPlugins(deps, state, mux, guestHome)
try {
await state.installation
} finally {
state.installation = undefined
}
}
async function installGuestHooksAndPlugins(
deps: GuestInstallDeps,
state: GuestInstallState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
state.lastInstallAt = Date.now()
await installWslGuestHooks({
@@ -49,12 +79,21 @@ export async function runWslRelayGuestInstall(
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
// PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown.
const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro)
if (state.mux === mux && overlay.kind !== 'unavailable') {
// Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir
// present but plugin-less, and advertising it would hide the user's own config.
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined
const kinds = requestedKinds(state)
for (const kind of kinds) {
const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro, kind)
if (state.mux !== mux) {
return
}
if (overlay.kind !== 'unavailable') {
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined
if (kind === 'pi') {
state.piAgentDir = overlay.kind === 'dir' ? overlay.piDir : undefined
} else if (kind === 'omp') {
state.ompStatusExtension = overlay.kind === 'dir' ? overlay.ompDir : undefined
}
}
}
}
@@ -63,6 +102,10 @@ export async function maybeRerunWslRelayGuestInstall(
deps: GuestInstallDeps,
state: GuestInstallState
): Promise<void> {
if (state.installation) {
await state.installation
return
}
const mux = state.mux
const guestHome = state.guestHome
if (
@@ -16,6 +16,7 @@ import {
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
} from './remote-managed-hook-installers'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { awaitExplicitPiOmpGuestReadiness } from './wsl-pi-omp-guest-readiness'
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
@@ -202,7 +203,12 @@ describe('WslHookRelayManager', () => {
if (registerInstallPlugins) {
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
installed: { opencode: true, opencode2: true, pi: false, omp: false },
overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir }
overlayDirs: {
opencode: opencodeOverlayDir,
opencode2: opencode2OverlayDir,
pi: `${home}/.pi/agent`,
omp: `${home}/.omp/agent/extensions/orca-agent-status.ts`
}
}))
}
return harness.transport
@@ -287,6 +293,89 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('waits for guest materialization when an explicit Pi or OMP launch needs it', async () => {
const { manager } = createManager({})
await expect(
awaitExplicitPiOmpGuestReadiness({
isWsl: true,
distro: 'Ubuntu',
codexHomePath: codexHome,
launchAgent: 'pi',
manager
})
).resolves.toBe(true)
expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)
manager.disposeAll()
})
it('does not treat the endpoint as ready before guest install completes', async () => {
let releaseInstall!: () => void
const installGate = new Promise<void>((resolve) => {
releaseInstall = resolve
})
const { manager } = createManager({
installHooks: vi.fn(async () => installGate.then(() => []))
})
const readiness = awaitExplicitPiOmpGuestReadiness({
isWsl: true,
distro: 'Ubuntu',
launchAgent: 'pi',
timeoutMs: 50,
manager
})
await vi.waitFor(() => expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull())
releaseInstall()
await expect(readiness).resolves.toBe(true)
manager.disposeAll()
})
it('reports relay startup failure without blocking the explicit launch forever', async () => {
const { manager } = createManager({
waitForSentinel: vi.fn(async () => {
throw startupError(17, 'guest unavailable')
})
})
await expect(
awaitExplicitPiOmpGuestReadiness({
isWsl: true,
distro: 'Ubuntu',
launchAgent: 'omp',
manager,
timeoutMs: 50
})
).resolves.toBe(false)
manager.disposeAll()
})
it('times out a relay that never reaches guest materialization', async () => {
const { manager } = createManager({
waitForSentinel: vi.fn(() => new Promise<MultiplexerTransport>(() => {}))
})
await expect(
awaitExplicitPiOmpGuestReadiness({
isWsl: true,
distro: 'Ubuntu',
launchAgent: 'pi',
timeoutMs: 5,
manager
})
).resolves.toBe(false)
manager.disposeAll()
})
it('does not wait or start a relay for a bare shell', async () => {
const { manager } = createManager({})
await expect(
awaitExplicitPiOmpGuestReadiness({
isWsl: true,
distro: 'Ubuntu',
launchCommand: 'bash',
manager
})
).resolves.toBe(true)
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
})
it('forwards the WSL guest Claude version to the shared remote installer', async () => {
const waitForSentinel = vi.fn(async () =>
guestTransport({ detectedAgents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' })
+67 -116
View File
@@ -1,5 +1,4 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import {
runWslRelayGuestInstall,
maybeRerunWslRelayGuestInstall
@@ -9,7 +8,6 @@ import {
defaultWslHookRelayDeps,
isWslHookRelayAllowed,
FAILURE_COOLDOWN_BASE_MS,
FAILURE_COOLDOWN_MAX_MS,
NO_NODE_COOLDOWN_MS,
REINSTALL_ONE_SHOT_DELAY_MS,
RUNNING_TEARDOWN_COOLDOWN_MS,
@@ -30,37 +28,20 @@ import {
recordManagedWslCodexHome,
wslRuntimeHomePathsEqual
} from '../codex/managed-wsl-codex-home-registry'
import { resolveWslHookDefaultDistro } from './wsl-hook-default-distro'
import { resumeStoppedWslHookRelays } from './wsl-hook-relay-resume'
type DistroState = {
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
distro: string
phase: 'starting' | 'running' | 'failed'
child?: ChildProcessWithoutNullStreams
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
guestEndpointFilePath?: string
opencodeOverlayDir?: string
opencode2OverlayDir?: string
failures: number
cooldownUntil: number
connectedAt?: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
lastInstallAt?: number
}
import {
markWslRelayFailed,
resolveWslDefaultDistro,
resumeWslStoppedRelays
} from './wsl-hook-relay-state-machine'
import type { WslRelayDistroState } from './wsl-hook-relay-state'
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
private states = new Map<string, WslRelayDistroState>()
private stoppedByHooksOff = new Map<string, string | undefined>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
this.deps = { ...defaultWslHookRelayDeps, ...deps }
this.recovery = new WslRelayRecovery({
@@ -70,8 +51,6 @@ export class WslHookRelayManager {
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = wslHookRelayStateKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
@@ -79,31 +58,32 @@ export class WslHookRelayManager {
}
})
}
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
this.deps.managedHookSettings = resolve
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
async ensureForDistro(
distro: string | null,
codexHomePath?: string | null,
launchKind?: 'pi' | 'omp'
): Promise<void> {
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
return
}
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
await this.ensureInternal(distro, codexHomePath ?? undefined, launchKind).catch((err) => {
const detail = err instanceof Error ? err.message : String(err)
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
})
}
private stateFor(distro: string | null): DistroState | undefined {
// Empty key never matches a real (non-empty) distro state.
private stateFor(distro: string | null): WslRelayDistroState | undefined {
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
}
/** Guest endpoint path once install completes. */
getGuestEndpointFilePath(distro: string | null): string | null {
return this.stateFor(distro)?.guestEndpointFilePath ?? null
return this.stateFor(distro)?.connectedAt
? (this.stateFor(distro)?.guestEndpointFilePath ?? null)
: null
}
getOpenCodeOverlayDir(
distro: string | null,
agent: 'opencode' | 'opencode2' = 'opencode'
@@ -113,7 +93,10 @@ export class WslHookRelayManager {
? (state?.opencode2OverlayDir ?? null)
: (state?.opencodeOverlayDir ?? null)
}
getGuestAgentPath(distro: string | null, kind: 'pi' | 'omp'): string | null {
const state = this.stateFor(distro)
return kind === 'pi' ? (state?.piAgentDir ?? null) : (state?.ompStatusExtension ?? null)
}
/** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the
* manager reusable, so re-enabling hooks can start relays again without an app restart. */
disposeAll({ permanent = true }: { permanent?: boolean } = {}): void {
@@ -128,20 +111,17 @@ export class WslHookRelayManager {
}
this.states.clear()
}
/** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut
* down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */
resumeStoppedRelays(): void {
resumeStoppedWslHookRelays(
this.stoppedByHooksOff,
this.deps.isDistroRunning,
(distro, codexHomePath) => this.ensureForDistro(distro, codexHomePath)
resumeWslStoppedRelays(this.stoppedByHooksOff, this.deps.isDistroRunning, (distro, home) =>
this.ensureForDistro(distro, home)
)
}
private async ensureInternal(
requestedDistro: string | null,
requestedCodexHomePath?: string
requestedCodexHomePath?: string,
launchKind?: 'pi' | 'omp'
): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
@@ -153,6 +133,10 @@ export class WslHookRelayManager {
recordManagedWslCodexHome(distro, requestedCodexHomePath)
}
if (existing) {
if (launchKind && !existing.launchKinds.has(launchKind)) {
existing.launchKinds.add(launchKind)
existing.lastInstallAt = 0
}
if (
requestedCodexHomePath &&
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
@@ -161,10 +145,11 @@ export class WslHookRelayManager {
existing.lastInstallAt = 0
}
if (existing.phase === 'running') {
void maybeRerunWslRelayGuestInstall(this.deps, existing)
await maybeRerunWslRelayGuestInstall(this.deps, existing)
return
}
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
await existing.startup
return
}
}
@@ -181,56 +166,47 @@ export class WslHookRelayManager {
}
return
}
// Why: restart-stable instance identity keeps the guest endpoint file at
// ONE path across restarts so daemon-surviving agents re-coordinate.
const instanceKey =
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
if (existing) {
this.recovery.clearTimers(existing)
}
const state: DistroState = {
const state: WslRelayDistroState = {
distro,
phase: 'starting',
failures: existing?.failures ?? 0,
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
// crash — dropping it would blank status on panes spawned mid-relaunch.
opencodeOverlayDir: existing?.opencodeOverlayDir,
opencode2OverlayDir: existing?.opencode2OverlayDir,
piAgentDir: existing?.piAgentDir,
ompStatusExtension: existing?.ompStatusExtension,
launchKinds: new Set(existing?.launchKinds ?? (launchKind ? [launchKind] : [])),
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
cooldownUntil: 0
}
this.states.set(key, state)
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
try {
await launchWslRelayWithInstall({
distro: state.distro,
env,
bundleJsPath: bundle.jsPath,
version: bundle.version,
io: this.deps,
// Why the identity half: a hooks-off teardown drops this state and kills its child, but
// that kill reads as a startup failure and the retry loop would respawn an untracked relay.
isDisposed: () => this.disposed || this.states.get(key) !== state,
onChild: (child) => {
state.child = child
},
onNoNode: () =>
this.markFailed(
state,
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
),
onFailure: (message) =>
this.markFailed(state, message, {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
}),
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
})
} catch (err) {
// Why: teardown may have already recorded this failure; don't double-
// count. A request-level error can leave a live child — never leak it.
state.startup = launchWslRelayWithInstall({
distro: state.distro,
env,
bundleJsPath: bundle.jsPath,
version: bundle.version,
io: this.deps,
isDisposed: () => this.disposed || this.states.get(key) !== state,
onChild: (child) => {
state.child = child
},
onNoNode: () =>
this.markFailed(
state,
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
),
onFailure: (message) =>
this.markFailed(state, message, {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
}),
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
}).catch((err) => {
state.child?.kill()
state.mux?.dispose()
if (state.phase !== 'failed') {
@@ -238,11 +214,11 @@ export class WslHookRelayManager {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
})
}
}
})
await state.startup
}
private async connect(
state: DistroState,
state: WslRelayDistroState,
transport: MultiplexerTransport,
child: ChildProcessWithoutNullStreams,
instanceKey: string
@@ -261,8 +237,6 @@ export class WslHookRelayManager {
}
state.mux = undefined
const wasRunning = state.phase === 'running'
// Why: only a stable run forgives past failures — a connect-then-die
// loop must escalate, not retry every 10s.
if (
wasRunning &&
state.connectedAt !== undefined &&
@@ -275,7 +249,6 @@ export class WslHookRelayManager {
})
}
})
const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as {
ok?: boolean
home?: string
@@ -293,52 +266,30 @@ export class WslHookRelayManager {
state.guestHome = homeResult.home
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
await runWslRelayGuestInstall(this.deps, state, mux, homeResult.home)
if (state.phase === 'failed' || state.mux !== mux) {
// Child died while installing — already recorded; don't revive.
return
}
state.phase = 'running'
state.connectedAt = Date.now()
// Why: one-shot catch-up so a single-spawn session (no later ensure)
// still writes Codex's deferred trust after the launch path seeds config.toml.
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
void maybeRerunWslRelayGuestInstall(this.deps, state)
})
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
// Fresh relays have nothing to replay; tolerate.
})
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {})
}
/** Records + breadcrumbs the failure and always arms the restart timer —
* one failed relaunch must not end self-recovery; the timer's
* distro-running probe keeps this from booting stopped distros. */
private markFailed(
state: DistroState,
state: WslRelayDistroState,
message: string,
options: { cooldownBaseMs: number }
): void {
state.phase = 'failed'
state.failures++
state.child = undefined
state.mux = undefined
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
state.cooldownUntil =
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
this.deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`)
this.recovery.scheduleRestart(state)
markWslRelayFailed(state, message, options, this.deps, this.recovery)
}
private async resolveDefaultDistro(): Promise<string | null> {
this.defaultDistro = await resolveWslHookDefaultDistro(
this.defaultDistro,
this.deps.listDistros
)
return this.defaultDistro
const distro = await resolveWslDefaultDistro(this.defaultDistro, this.deps.listDistros)
this.defaultDistro = distro
return distro
}
}
export const wslHookRelayManager = new WslHookRelayManager()
@@ -0,0 +1,72 @@
import { FAILURE_COOLDOWN_MAX_MS } from './wsl-hook-relay-deps'
type RelayFailureState = {
distro: string
phase: 'starting' | 'running' | 'failed'
failures: number
cooldownUntil: number
child?: unknown
mux?: { dispose(): void }
reinstallTimer?: ReturnType<typeof setTimeout>
}
type RelayFailureDeps = {
warn(message: string): void
}
type RelayRecovery = {
scheduleRestart(state: RelayFailureState): void
}
export function markWslRelayFailed(
state: RelayFailureState,
message: string,
options: { cooldownBaseMs: number },
deps: RelayFailureDeps,
recovery: RelayRecovery
): void {
state.phase = 'failed'
state.failures++
state.child = undefined
state.mux = undefined
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
state.cooldownUntil =
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
deps.warn(`[agent-hooks] WSL hook relay: ${message}`)
recovery.scheduleRestart(state)
}
export function resumeWslStoppedRelays(
stopped: Map<string, string | undefined>,
isDistroRunning: (distro: string) => Promise<boolean>,
ensure: (distro: string, home: string | undefined) => void
): void {
const distros = [...stopped]
stopped.clear()
for (const [distro, home] of distros) {
void isDistroRunning(distro)
.then((running) => {
if (running) {
ensure(distro, home)
}
})
.catch(() => undefined)
}
}
export async function resolveWslDefaultDistro(
current: string | null,
listDistros: () => Promise<string[]>
): Promise<string | null> {
if (current) {
return current
}
try {
return (await listDistros())[0] ?? null
} catch {
return null
}
}
@@ -0,0 +1,24 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
export type WslRelayDistroState = {
distro: string
phase: 'starting' | 'running' | 'failed'
child?: { kill: () => void }
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
guestEndpointFilePath?: string
opencodeOverlayDir?: string
opencode2OverlayDir?: string
piAgentDir?: string
ompStatusExtension?: string
launchKinds: Set<'pi' | 'omp'>
startup?: Promise<void>
installation?: Promise<void>
failures: number
cooldownUntil: number
connectedAt?: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
lastInstallAt?: number
}
@@ -0,0 +1,49 @@
import {
detectExplicitPiAgentKindFromCommand,
isPiCompatibleAgentType,
type PiAgentKind
} from '../../shared/pi-agent-kind'
import type { TuiAgent } from '../../shared/tui-agent'
import { wslHookRelayManager, type WslHookRelayManager } from './wsl-hook-relay-manager'
export async function awaitExplicitPiOmpGuestReadiness(args: {
isWsl: boolean
distro: string | null | undefined
codexHomePath?: string | null
launchAgent?: TuiAgent
launchCommand?: string
timeoutMs?: number
manager?: Pick<
WslHookRelayManager,
'ensureForDistro' | 'getGuestEndpointFilePath' | 'getGuestAgentPath'
>
}): Promise<boolean> {
if (!args.isWsl) {
return true
}
const kind: PiAgentKind | null = isPiCompatibleAgentType(args.launchAgent)
? args.launchAgent
: detectExplicitPiAgentKindFromCommand(args.launchCommand)
if (kind !== 'pi' && kind !== 'omp') {
return true
}
const distro = args.distro ?? null
const manager = args.manager ?? wslHookRelayManager
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
manager
.ensureForDistro(distro, args.codexHomePath, kind)
.then(() =>
Boolean(
manager.getGuestEndpointFilePath(distro) && manager.getGuestAgentPath(distro, kind)
)
),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), args.timeoutMs ?? 10_000)
})
])
} finally {
clearTimeout(timer)
}
}
@@ -505,7 +505,7 @@ describe('registerPtyHandlers', () => {
'ORCA_AGENT_HOOK_PORT/u',
'ORCA_AGENT_HOOK_TOKEN/u',
// Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196).
'ORCA_OMP_STATUS_EXTENSION/p',
'ORCA_OMP_STATUS_EXTENSION/u',
'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
])
)
@@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => {
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
const ensureForDistro = vi
.spyOn(wslHookRelayManager, 'ensureForDistro')
.mockImplementation(() => {})
.mockImplementation(async () => {})
try {
buildPtyHostEnv(
@@ -213,7 +213,7 @@ describe('registerPtyHandlers', () => {
agentStatusHooksEnabled: true
}
)
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome)
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome, undefined)
} finally {
ensureForDistro.mockRestore()
}
@@ -373,7 +373,7 @@ describe('registerPtyHandlers', () => {
// reattach call — the manager owns the hooks/platform gating this spy stands in for.
const ensureForDistro = vi
.spyOn(wslHookRelayManager, 'ensureForDistro')
.mockImplementation(() => {})
.mockImplementation(async () => {})
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })),
write: vi.fn(),
+20 -1
View File
@@ -136,7 +136,11 @@ export function buildPtyHostEnv(
if (opts.isWsl === true) {
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
const distro = opts.wslDistro ?? null
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath)
const wslLaunchKind =
explicitPiAgentKind === 'pi' || explicitPiAgentKind === 'omp'
? explicitPiAgentKind
: undefined
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath, wslLaunchKind)
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
if (guestEndpoint) {
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
@@ -222,6 +226,21 @@ export function buildPtyHostEnv(
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
}
if (opts.isWsl && opts.agentStatusHooksEnabled) {
const distro = opts.wslDistro ?? null
if (explicitPiAgentKind === 'pi') {
const guestPiDir = wslHookRelayManager.getGuestAgentPath(distro, 'pi')
if (guestPiDir) {
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = guestPiDir
}
} else if (explicitPiAgentKind === 'omp') {
const guestOmpExtension = wslHookRelayManager.getGuestAgentPath(distro, 'omp')
if (guestOmpExtension) {
baseEnv.ORCA_OMP_STATUS_EXTENSION = guestOmpExtension
}
}
}
// Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex.
if (opts.skipCodexHomeEnv) {
delete baseEnv.CODEX_HOME
@@ -21,6 +21,7 @@ import type { GetSelectedCodexHomePath } from '../host-env/types'
import { isCurrentPtyExit, ptyOwnership } from './ownership-state'
import { localProvider } from './registry'
import { clearProviderPtyState } from './state-cleanup'
import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness'
export function configureLocalPtyProvider(args: {
runtime?: OrcaRuntimeService
@@ -63,6 +64,13 @@ export function configureLocalPtyProvider(args: {
launchAgent: ctx?.launchAgent,
launchCommand: ctx?.command
})
await awaitExplicitPiOmpGuestReadiness({
isWsl: ctx?.isWsl === true,
distro: ctx?.wslDistro,
codexHomePath: selectedCodexHomePath,
launchAgent: ctx?.launchAgent,
launchCommand: ctx?.command
})
const env = buildPtyHostEnv(id, baseEnv, {
isPackaged: getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
@@ -42,6 +42,7 @@ import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env'
import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness'
import type { RuntimePtySpawnState } from './spawn-state'
export async function prepareRuntimePtySpawn(
@@ -265,6 +266,13 @@ export async function prepareRuntimePtySpawn(
launchAgent: args.launchAgent,
launchCommand: ctx.launchCommand
})
await awaitExplicitPiOmpGuestReadiness({
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd),
distro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
codexHomePath: ctx.selectedCodexHomePath,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
launchCommand: ctx.launchCommand
})
ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, {
isPackaged: getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
+2 -1
View File
@@ -98,7 +98,8 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_WSL_HOOK_RELAY_VERSION/u',
'ORCA_WSL_HOOK_INSTANCE/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
'ORCA_OMP_STATUS_EXTENSION/p',
`ORCA_OMP_STATUS_EXTENSION/${env.ORCA_OMP_STATUS_EXTENSION?.startsWith('/') ? 'u' : 'p'}`,
...(env.ORCA_PI_SOURCE_AGENT_DIR?.startsWith('/') ? ['ORCA_PI_SOURCE_AGENT_DIR/u'] : []),
`${ORCA_IMAGE_PROTOCOL_ENV}/u`,
'ORCA_OMP_FRESH_CONFIG/p',
...worktreeSetupWslenvEntries(env)
@@ -43,6 +43,20 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
})
})
it('materializes the requested Pi extension in the guest home', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst-pi'
})
const source = '// @orca-managed-pi-extension\nexport default {}\n'
const res = install({ piExtensionSource: source, launchKind: 'pi' })
expect(res.overlayDirs.pi).toBe(join(home, '.pi', 'agent'))
const extension = join(home, '.pi', 'agent', 'extensions', 'orca-agent-status.ts')
expect(readFileSync(extension, 'utf8')).toContain(source)
})
})
it('writes the OpenCode 2 plugin to its separate overlay', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
+23 -2
View File
@@ -21,7 +21,7 @@ export type InstallPluginsResult = {
omp: boolean
primeAgent: boolean
}
overlayDirs: { opencode?: string; opencode2?: string }
overlayDirs: { opencode?: string; opencode2?: string; pi?: string; omp?: string }
}
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
@@ -64,6 +64,10 @@ export function createInstallPluginsHandler(
primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined
})
let opencodeDir: string | undefined
const launchKind =
params.launchKind === 'pi' || params.launchKind === 'omp' ? params.launchKind : undefined
let piDir: string | undefined
let ompDir: string | undefined
if (pluginOverlay.hasOpenCodeSource()) {
// An omitted source leaves the manager's cache untouched, so it counts as unchanged.
const incoming = typeof opencode === 'string' ? opencode : null
@@ -112,6 +116,21 @@ export function createInstallPluginsHandler(
: null
}
}
// Materialize only the explicitly requested agent. Bare shells must not create
// ~/.pi/agent or ~/.omp/agent (#10196).
if (launchKind === 'pi' || launchKind === 'omp') {
const source = launchKind === 'pi' ? pi : omp
if (typeof source === 'string') {
const result = pluginOverlay.materializePi(`wsl-${launchKind}`, undefined, launchKind, {
materializeDefaultHome: true
})
if (launchKind === 'pi') {
piDir = result?.sourceAgentDir
} else {
ompDir = result?.statusExtensionPath
}
}
}
return {
installed: {
opencode: pluginOverlay.hasOpenCodeSource(),
@@ -122,7 +141,9 @@ export function createInstallPluginsHandler(
},
overlayDirs: {
...(opencodeDir ? { opencode: opencodeDir } : {}),
...(opencode2Dir ? { opencode2: opencode2Dir } : {})
...(opencode2Dir ? { opencode2: opencode2Dir } : {}),
...(piDir ? { pi: piDir } : {}),
...(ompDir ? { omp: ompDir } : {})
}
}
}