test(wsl): stop a distro mid-reply on the real-WSL lane, then Start and Continue (#35826)

* test(wsl): stop a distro mid-reply on the real-WSL lane, then Start and Continue

* test(wsl): read the held partial reply from the chat stream, not history

* test(wsl): close the mid-turn chat streams when a wait fails

* test(wsl): reinstall the guest claude the decoy probe removed before the mid-turn stop

* test(wsl): read the continued turn from the scripted Claude's report log

After Start the restarted server's model discovery relaunches the same scripted
CLI, which overwrites report.json with a discovery-only report after the
continued turn finished. The CLI now also appends every report to an opt-in
log, and the mid-turn step finds the resumed launch there.

* test(wsl): match the continued launch by its session id, after Continue only
This commit is contained in:
Neil
2026-10-29 06:55:57 -07:00
committed by GitHub
parent 6eebdbbaf8
commit 3f8b15d5a7
5 changed files with 546 additions and 44 deletions
+2 -2
View File
@@ -333,8 +333,8 @@ jobs:
node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'launch.log')
if ($global:LASTEXITCODE -ne 0) { throw "launch phase failed: $global:LASTEXITCODE" }
# A new process: the holders must have kept both distros Running since phase 1 exited.
- name: 'Phase 2: holders survive, chat, reconnect, teardown'
timeout-minutes: 8
- name: 'Phase 2: holders survive, chat, mid-reply stop and Continue, teardown'
timeout-minutes: 11
env:
ORCA_RUN_WSL_ORCAD_LANE: '1'
ORCA_WSL_ORCAD_LANE_PHASE: survive
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
import { setTimeout as delay } from 'node:timers/promises'
@@ -10,6 +10,9 @@ if (process.argv.includes('--version')) {
const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH
const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH
// Every launch shares the report path, so a later one (e.g. model discovery) overwrites it; the
// log keeps each launch's reports, one JSON line per write.
const reportLogPath = process.env.ORCA_SDK_CONTRACT_REPORT_LOG_PATH
const report = {
argv: process.argv.slice(1),
@@ -30,6 +33,9 @@ const writeReport = () => {
if (reportPath) {
writeFileSync(reportPath, JSON.stringify(report))
}
if (reportLogPath) {
appendFileSync(reportLogPath, `${JSON.stringify(report)}\n`)
}
}
// Preserve launch evidence even if the scenario never finishes.
writeReport()
+120 -30
View File
@@ -12,7 +12,7 @@ export type WslChatHost = {
}
export async function writeChatFixtureFile(
host: WslChatHost,
host: Pick<WslChatHost, 'guest'>,
path: string,
content: string
): Promise<void> {
@@ -44,30 +44,20 @@ export type WslScriptedProviderInstall = WslGuestCli & {
ackPath: string
}
/** Install each wire fixture as its agent's command on the guest's login PATH, run by the guest's packaged runtime. */
export async function installWslChatFixture(lane: WslChatHost) {
const home = (await lane.guest('printf %s "$HOME"')).trim()
const root = `${home}/wsl chat fixture`
const dirs = [
`${root}/codex-home`,
`${root}/grok-home`,
`${root}/opencode-home`,
`${home}/${WSL_CHAT_GUEST_BIN}`
]
await lane.guest(`mkdir -p ${dirs.map(quotePosixShell).join(' ')}`)
const runtime = (await lane.guest(`readlink -f /proc/${lane.runtimePid}/exe`)).trim()
const installCli = async (agent: WslChatGuestAgent): Promise<WslGuestCli> => {
const script = `${root}/${agent}-scripted.mjs`
const cli = `${home}/${WSL_CHAT_GUEST_BIN}/${agent}`
await writeChatFixtureFile(
lane,
script,
readFileSync(resolve(GUEST_CLI_FIXTURES[agent]), 'utf8')
)
await writeChatFixtureFile(
lane,
cli,
`#!/bin/sh
type GuestCliPlace = { home: string; root: string; runtime: string }
async function installGuestCli(
lane: Pick<WslChatHost, 'guest'>,
{ home, root, runtime }: GuestCliPlace,
agent: WslChatGuestAgent
): Promise<WslGuestCli> {
const script = `${root}/${agent}-scripted.mjs`
const cli = `${home}/${WSL_CHAT_GUEST_BIN}/${agent}`
await writeChatFixtureFile(lane, script, readFileSync(resolve(GUEST_CLI_FIXTURES[agent]), 'utf8'))
await writeChatFixtureFile(
lane,
cli,
`#!/bin/sh
export ORCA_LANE_CLI_WRAPPER="$0"
${
agent === 'claude'
@@ -84,10 +74,36 @@ done
`
: ''
}exec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n`
)
await lane.guest(`chmod 700 ${quotePosixShell(cli)}`)
return { script, cli }
}
)
await lane.guest(`chmod 700 ${quotePosixShell(cli)}`)
return { script, cli }
}
/** Puts one agent's command back after a step removed it, e.g. the Windows-decoy probe. */
export async function reinstallWslGuestCli(
lane: WslChatHost,
agent: WslChatGuestAgent
): Promise<GuestCliPlace & WslGuestCli> {
const home = (await lane.guest('printf %s "$HOME"')).trim()
const runtime = (await lane.guest(`readlink -f /proc/${lane.runtimePid}/exe`)).trim()
const place = { home, root: `${home}/wsl chat fixture`, runtime }
return { ...place, ...(await installGuestCli(lane, place, agent)) }
}
/** Install each wire fixture as its agent's command on the guest's login PATH, run by the guest's packaged runtime. */
export async function installWslChatFixture(lane: WslChatHost) {
const home = (await lane.guest('printf %s "$HOME"')).trim()
const root = `${home}/wsl chat fixture`
const dirs = [
`${root}/codex-home`,
`${root}/grok-home`,
`${root}/opencode-home`,
`${home}/${WSL_CHAT_GUEST_BIN}`
]
await lane.guest(`mkdir -p ${dirs.map(quotePosixShell).join(' ')}`)
const runtime = (await lane.guest(`readlink -f /proc/${lane.runtimePid}/exe`)).trim()
const installCli = (agent: WslChatGuestAgent) =>
installGuestCli(lane, { home, root, runtime }, agent)
const claude = await installCli('claude')
const scripted = async (
agent: WslScriptedProviderInstall['agent']
@@ -108,6 +124,7 @@ done
claude: {
ORCA_SDK_CONTRACT_SCENARIO_PATH: `${root}/scenario.json`,
ORCA_SDK_CONTRACT_REPORT_PATH: `${root}/report.json`,
ORCA_SDK_CONTRACT_REPORT_LOG_PATH: `${root}/report-log.jsonl`,
ORCA_SDK_CONTRACT_TIMEOUT_MS: '120000'
},
codex: {
@@ -162,9 +179,82 @@ export const WslChatProviderReport = z.object({
const PROVIDER_REPORT_TIMEOUT_MS = 30_000
/**
* Every report any launch of the scripted Claude wrote, oldest first. A launch after the one under
* test (model discovery after a restart) overwrites the single report file, so read the log.
*/
async function readProviderReportLog(
host: Pick<WslChatHost, 'guest'>,
logPath: string
): Promise<string[]> {
return (await host.guest(`cat ${quotePosixShell(logPath)} 2>/dev/null || true`))
.split('\n')
.filter(Boolean)
}
/** Lines already logged, so a later wait can skip launches from before an action. */
export async function providerReportLogLength(
host: Pick<WslChatHost, 'guest'>,
logPath: string
): Promise<number> {
return (await readProviderReportLog(host, logPath)).length
}
export async function waitForProviderReportInLog<T>(
host: Pick<WslChatHost, 'guest'>,
logPath: string,
schema: z.ZodType<T>,
settled: (report: T) => boolean,
awaiting: string,
afterLine = 0
): Promise<T> {
let seen: string[] = []
try {
return await vi.waitFor(
async () => {
const lines = await readProviderReportLog(host, logPath)
seen = lines.map((line, index) => `${index}: ${summarizeReportLine(line)}`)
// A line being appended can be torn; skip what does not parse.
const found = lines
.slice(afterLine)
.map((line) => schema.safeParse(safeJson(line)))
.find((parsed) => parsed.success && settled(parsed.data))
if (!found?.success) {
throw new Error(`no report in the log shows ${awaiting} yet`)
}
return found.data
},
{ timeout: PROVIDER_REPORT_TIMEOUT_MS, interval: 250 }
)
} catch (error) {
throw new Error(
`Provider report log ${logPath} did not show ${awaiting} within ${PROVIDER_REPORT_TIMEOUT_MS} ms; launches logged:\n${seen.join('\n')}`,
{ cause: error }
)
}
}
// Each launch's argv and user-message count; env is noise when telling launches apart.
function summarizeReportLine(line: string): string {
const parsed = z
.object({ argv: z.array(z.string()), userMessages: z.array(z.unknown()) })
.safeParse(safeJson(line))
return parsed.success
? `${parsed.data.userMessages.length} user message(s): ${parsed.data.argv.join(' ')}`
: `unparsed: ${line.slice(0, 200)}`
}
function safeJson(line: string): unknown {
try {
return JSON.parse(line)
} catch {
return null
}
}
/** Scripted providers rewrite their report in place after each act, so one read can see a torn file or a stale one. */
export async function waitForProviderReport<T>(
host: WslChatHost,
host: Pick<WslChatHost, 'guest'>,
path: string,
schema: z.ZodType<T>,
settled: (report: T) => boolean,
+333
View File
@@ -0,0 +1,333 @@
// verify-7 on real WSL: a distro stopped mid-reply stays down under the chat's polls, the chat's
// Start boots it through the host service, and Continue finishes the cut turn.
import { randomUUID } from 'node:crypto'
import { expect, vi } from 'vitest'
import { z } from 'zod'
import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../shared/agent-session-continue-interrupted-capability'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import type {
AgentSessionHistoryResult,
AgentSessionSubscribeEvent
} from '../../shared/agent-session-wire'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection'
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime/remote-runtime-client'
import { latestNativeChatOrcaStopCut } from '../../shared/native-chat/native-chat-orca-stop-cut'
import { quotePosixShell } from '../../shared/shell-argument-quoting'
import type { WslHostStatus } from '../../shared/wsl-host-types'
import { claudeSessionIdForOrcaSession } from '../claude/structured/claude-structured-session-id'
import { hasOrcadTemplate } from '../orcad/orcad-artifact-materializer'
import { packagedClaudeChatScenario } from '../orcad/orcad-structured-chat-scenario'
import { wslDistroHolderKey } from './wsl-distro-holder-record'
import { wslHostStoppedMessage } from './wsl-host-add-admission'
import { wslHostConnectOptions } from './wsl-host-connect-request'
import { createWslHostConnector } from './wsl-host-connector'
import type { WslHostConnector } from './wsl-host-connector-contract'
import { WslHostService } from './wsl-host-service'
import { assistantText } from './wsl-orcad-chat-assistant-text'
import { envelope } from './wsl-orcad-chat-envelope'
import {
WslChatProviderReport,
providerReportLogLength,
waitForProviderReportInLog,
writeChatFixtureFile,
type WslChatHost
} from './wsl-orcad-chat-provider-fixture'
/** Polls the running list (and the chat's runtime call) this long after the terminate returns. */
const STAYS_STOPPED_MS = 30_000
const POLL_MS = 5_000
export type LaneWslHost = {
service: WslHostService
connector: WslHostConnector
/** Everything the service published, as the window's status listener receives it. */
published: WslHostStatus[]
}
/** The production service and connector, wired as wsl-host-handlers wires them minus Electron. */
export function createLaneWslHost(userDataPath: string): LaneWslHost {
const published: WslHostStatus[] = []
const service = new WslHostService({
userDataPath: () => userDataPath,
isEnabled: () => true,
hasServerFiles: hasOrcadTemplate,
publish: (status) => {
published.push(status)
console.log(`[lane] host ${JSON.stringify(status)}`)
},
pairingChanged: () => {},
ready: () => {},
removed: () => {}
})
const refuse = (): Promise<never> => Promise.reject(new Error('The lane never removes a host'))
const connector = createWslHostConnector({
userDataPath,
onChange: service.handleConnectionChange.bind(service),
hasUserVisibleWork: (distro) => service.hasUserVisibleWork(distro),
isKnownDistro: (distro) => service.isKnownDistro(distro),
runtime: { call: refuse, census: refuse }
})
service.attach(connector)
return { service, connector, published }
}
export type MidTurnStopLane = Pick<WslChatHost, 'rpc' | 'guest'> & {
/** The host connection's pairing, read at each call: Start replaces the connection. */
pairing(): PairedRuntimeConnection
distro: string
environmentId: string
host: LaneWslHost
terminate(): Promise<void>
/** Holder keys of the distros `wsl --list --running` confirms. */
runningDistros(): Promise<string[]>
}
/** The provider's partial reply waits for an acknowledgement long enough to stop the distro under it. */
function heldReplyScenario(providerId: string, ackPath: string) {
const scenario = packagedClaudeChatScenario(providerId, ackPath)
return {
...scenario,
steps: scenario.steps.map((step) =>
step.awaitFile ? { awaitFile: { ...step.awaitFile, timeoutMs: 120_000 } } : step
)
}
}
/** Streamed assistant text, as the open chat sees it; history keeps only settled rows. */
type Watch = { close(): void }
async function watchAssistantText(lane: MidTurnStopLane, sessionId: string, opened: Watch[]) {
const text: string[] = []
const errors: Error[] = []
const subscription = await subscribeRemoteRuntimeRequest<AgentSessionSubscribeEvent>(
lane.pairing(),
'agentSession.subscribe',
{ sessionId },
30_000,
{
onResponse(response) {
if (!response.ok) {
errors.push(new Error(JSON.stringify(response.error)))
return
}
const event = response.result
const rows =
event.type === 'batch'
? event.batch.items
: event.type === 'snapshot' || event.type === 'reset'
? event.page.items
: []
text.push(...rows.map(assistantText))
},
onError(error) {
errors.push(error)
}
},
{ clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES }
)
let closed = false
const watch = {
text,
errors,
close: () => {
if (!closed) {
closed = true
subscription.close()
}
}
}
opened.push(watch)
return watch
}
type MidTurnFixture = { root: string; worktree: string; cwd: string }
export async function proveWslMidTurnStopAndContinue(
lane: MidTurnStopLane,
fixture: MidTurnFixture
) {
// A failed wait must not leave a stream open until the step times out.
const opened: Watch[] = []
try {
return await stopMidTurnThenContinue(lane, fixture, opened)
} finally {
opened.forEach((watch) => watch.close())
}
}
async function stopMidTurnThenContinue(
lane: MidTurnStopLane,
fixture: MidTurnFixture,
opened: Watch[]
) {
const { distro, environmentId, host } = lane
const sessionId = randomUUID()
const providerId = claudeSessionIdForOrcaSession(sessionId)
const ackPath = `${fixture.root}/midturn-partial-observed`
// agentDefaultEnv points the guest Claude at these.
const scenarioPath = `${fixture.root}/scenario.json`
const reportLogPath = `${fixture.root}/report-log.jsonl`
const items = async (): Promise<AgentJournalRenderItem[]> =>
(
await lane.rpc<AgentSessionHistoryResult>('agentSession.history', {
sessionId,
direction: 'tail',
limit: 100
})
).page.items
await lane.guest(`rm -f ${quotePosixShell(ackPath)} ${quotePosixShell(reportLogPath)}`)
await writeChatFixtureFile(
lane,
scenarioPath,
JSON.stringify(heldReplyScenario(providerId, ackPath))
)
const fields = { worktree: fixture.worktree, agent: 'claude' }
const created = z.object({ ok: z.literal(true), value: z.object({ fence: z.number() }) }).parse(
await lane.rpc('agentSession.create', {
...fields,
envelope: envelope(sessionId, 'agentSession.create', fields, null)
})
)
const held = await watchAssistantText(lane, sessionId, opened)
const body = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Reply slowly; the distro stops under you.' }]
}
expect(
await lane.rpc('agentSession.send', {
body,
envelope: envelope(sessionId, 'agentSession.send', { body }, created.value.fence)
})
).toMatchObject({ ok: true })
await vi.waitFor(
async () => {
expect(held.errors).toEqual([])
const rows = await items()
expect(held.text, JSON.stringify(rows.map((row) => row.body))).toContain('Packaged server ')
expect(rows.some((row) => readAgentJournalTurn(row.body)?.state === 'running')).toBe(true)
},
{ timeout: 30_000, interval: 250 }
)
// Mid-reply, the distro stops under the open turn while the chat stays on screen.
host.service.setVisibleWork([environmentId])
const since = host.published.length
let started = performance.now()
// The stop ends the stream; the chat's stopped state is the host status below.
held.close()
await lane.terminate()
const terminatedAt = performance.now()
const stoppedMessage = wslHostStoppedMessage(distro)
await vi.waitFor(
() =>
expect(host.published.slice(since)).toContainEqual(
expect.objectContaining({ environmentId, state: 'distro-stopped' })
),
{ timeout: 30_000, interval: 100 }
)
const stopShownMs = Math.round(performance.now() - started)
// Nothing boots it: not liveness, not the chat's own runtime calls, not a running-list poll.
let polls = 0
for (;;) {
expect(
await lane.runningDistros(),
`${distro} rebooted ${polls * POLL_MS} ms in`
).not.toContain(wslDistroHolderKey(distro))
await expect(host.service.ensureForRuntimeCall(environmentId)).rejects.toThrow(stoppedMessage)
polls += 1
if (performance.now() - terminatedAt >= STAYS_STOPPED_MS) {
break
}
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
}
const shown = (state: WslHostStatus['state']) =>
expect(host.service.getStatuses()).toContainEqual(
expect.objectContaining({ environmentId, state })
)
shown('distro-stopped')
// The chat's Start: the renderer's wslHosts.connect for this environment.
started = performance.now()
await host.service.connect(
environmentId,
wslHostConnectOptions({ withdrawPendingRemoval: false })
)
const startMs = Math.round(performance.now() - started)
expect(await lane.runningDistros()).toContain(wslDistroHolderKey(distro))
await vi.waitFor(() => shown('ready'), { timeout: 30_000, interval: 100 })
const status = z
.object({ capabilities: z.array(z.string()).optional() })
.parse(await lane.rpc('status.get'))
expect(status.capabilities).toContain(AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY)
// What NativeChatInterruptedContinue offers: the latest turn, cut by Orca's stop.
const cut = await vi.waitFor(
async () => {
const rows = await items()
const found = latestNativeChatOrcaStopCut(rows, [])
if (!found) {
throw new Error(`No Continue offered yet: ${JSON.stringify(rows.map((row) => row.body))}`)
}
return { ...found, before: rows.map((row) => row.itemId) }
},
{ timeout: 60_000, interval: 500 }
)
await writeChatFixtureFile(
lane,
scenarioPath,
JSON.stringify(packagedClaudeChatScenario(providerId, ackPath))
)
await lane.guest(`touch ${quotePosixShell(ackPath)}`)
const resumedWatch = await watchAssistantText(lane, sessionId, opened)
// The held turn's own launch logged the same session id; only a later launch is the Continue.
const loggedBeforeContinue = await providerReportLogLength(lane, reportLogPath)
const continued = z.object({ outcome: z.string() }).parse(
await lane.rpc('agentSession.continueInterrupted', {
sessionId,
turnItemId: cut.turnItemId
})
)
expect(['continued', 'pending']).toContain(continued.outcome)
await vi.waitFor(
async () => {
const rows = await items()
expect(resumedWatch.errors).toEqual([])
expect(resumedWatch.text).toContain('Packaged server reply.')
expect(
rows.some(
(row) =>
row.body?.kind === 'turn' &&
row.body.state === 'completed' &&
row.body.outcome === 'success' &&
!cut.before.includes(row.itemId)
)
).toBe(true)
},
{ timeout: 60_000, interval: 500 }
)
// The held turn's stream frames leave no transcript head, so Continue may relaunch the same
// provider session with --session-id rather than --resume; either is the continued turn.
const resumedArgv = new RegExp(`--(?:resume|session-id)(?:=| )${providerId}`)
// The restarted server's model discovery relaunches the same CLI after the turn, so the log.
const resumed = await waitForProviderReportInLog(
lane,
reportLogPath,
WslChatProviderReport,
(written) => resumedArgv.test(written.argv.join(' ')) && written.userMessages.length > 0,
`the continued turn after line ${loggedBeforeContinue}`,
loggedBeforeContinue
)
expect(resumed.cwd).toBe(fixture.cwd)
await lane.rpc('agentSession.close', { sessionId })
return {
sessionId,
stopShownMs,
stayedStoppedPolls: polls,
startMs,
cause: cut.cause,
continued: continued.outcome
}
}
+84 -11
View File
@@ -1,4 +1,4 @@
// C10 real WSL2/systemd-on/off contract: C4 deploy, C5 RPC bridge, C6 loss/recovery, E1 deferred/forced/idle updates, then a separate survive process proves holder adoption, git/folder chat and cleanup. wsl-orcad-e2e.yml imports/runs both phases.
// C10 real WSL2/systemd-on/off contract: C4 deploy, C5 RPC bridge, C6 loss/recovery, E1 deferred/forced/idle updates, then a separate survive process proves holder adoption, git/folder chat, a mid-reply distro stop resumed by Start and Continue, and cleanup. wsl-orcad-e2e.yml imports/runs both phases.
// Run: ORCA_RUN_WSL_ORCAD_LANE=1 ORCA_WSL_ORCAD_LANE_PHASE=launch|survive pnpm test <this file>
import { createHash, randomBytes } from 'node:crypto'
import { appendFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
@@ -29,7 +29,8 @@ import { WslOrcadBridgeActivity } from './wsl-orcad-bridge-activity'
import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge'
import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder'
import { proveWslStructuredChat } from './wsl-orcad-structured-chat-contract'
import { WSL_CHAT_GUEST_BIN } from './wsl-orcad-chat-provider-fixture'
import { createLaneWslHost, proveWslMidTurnStopAndContinue } from './wsl-orcad-midturn-stop-lane'
import { reinstallWslGuestCli, WSL_CHAT_GUEST_BIN } from './wsl-orcad-chat-provider-fixture'
import {
WSL_CHAT_WINDOWS_DECOY,
WSL_CHAT_WINDOWS_DECOY_AGENTS,
@@ -341,7 +342,10 @@ async function proveDetachedSession(
'date +%s%N > lane-file; git add lane-file; pwd'
)
).trim()
await rpc(connection, 'git.commit', { worktree: `path:${repoPath}`, message: 'lane commit' })
await rpc(connection, 'git.commit', {
worktree: `path:${repoPath}`,
message: 'lane commit'
})
const hookSock = (await guest(distro, `cat "$HOME/${LOGIN_ENV_TAG}" 2>/dev/null || true`)).trim()
expect(hookSock, `${distro}: SSH_AUTH_SOCK seen by orcad's git`).toBe(LOGIN_ENV_SOCK)
const created = await rpc<{ terminal: { handle: string } }>(connection, 'terminal.create', {
@@ -627,6 +631,81 @@ async function launchPhase(distro: string, systemd: boolean): Promise<Receipt> {
return result
}
/** Mid-turn holder exits are unverifiable until the process is gone; wait for the proven exit. */
async function releaseHolder(distro: string) {
const released = await holder.release(distro)
await waitFor(
`the ${distro} holder to exit`,
async () => ((await holder.holderState(distro)) === 'exited' ? true : null),
10_000,
250
)
return released
}
/** verify-7's device script, with Start through the production host service and connector. */
async function proveMidTurnStop(distro: string) {
// Hand the distro from the lane's own bridge and holder to the host service's.
await manager.close(distro)
await releaseHolder(distro)
// The service deploys the packaged template; point it at what this distro runs, so Add
// neither updates nor defers.
const packaged = process.env.ORCA_ORCAD_TEMPLATE_PATH
const running = templates.get(wslDistroHolderKey(distro))
if (running) {
process.env.ORCA_ORCAD_TEMPLATE_PATH = running
}
const host = createLaneWslHost(USER_DATA)
try {
const added = await host.service.add({ distro, startIfStopped: true })
if (!added.ok) {
throw new Error(`Adding ${distro} as a WSL host failed: ${JSON.stringify(added)}`)
}
const current = (): WslOrcadConnection => {
const connection = host.connector.get(distro)
if (!connection) {
throw new Error(`${distro} has no host connection`)
}
return connection
}
const chat = {
rpc: <T>(method: string, params?: unknown) => rpc<T>(current(), method, params),
guest: (command: string) => guest(distro, command)
}
const { pid } = current()
if (pid === null) {
throw new Error(`${distro} host connection has no runtime PID`)
}
// The chat contract's Windows-decoy probe deleted the guest claude; install it again.
const install = await reinstallWslGuestCli({ ...chat, runtimePid: pid }, 'claude')
const repo = `${install.home}/wsl-orcad-lane-repo`
return await proveWslMidTurnStopAndContinue(
{
...chat,
distro,
environmentId: added.environmentId,
host,
pairing: () => pairedConnection(current()),
terminate: async () => {
const terminated = await wslExe(['--terminate', distro])
expect(terminated.code, terminated.output).toBe(0)
},
runningDistros
},
{ root: install.root, worktree: `path:${repo}`, cwd: repo }
)
} finally {
await host.connector.disconnect(distro).catch(() => {})
await host.connector.releaseHolder(distro).catch(() => {})
await host.service.dispose()
if (packaged === undefined) {
delete process.env.ORCA_ORCAD_TEMPLATE_PATH
} else {
process.env.ORCA_ORCAD_TEMPLATE_PATH = packaged
}
}
}
async function survivePhase(): Promise<void> {
const state: LaunchState = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
for (const [key, dir] of Object.entries(state.templates ?? {})) {
@@ -672,6 +751,7 @@ async function survivePhase(): Promise<void> {
}
})
}
receipt.midTurnStop = await proveMidTurnStop(PLAIN)
// Teardown: bridges closed, holders released, distros terminated.
await manager.dispose()
@@ -681,14 +761,7 @@ async function survivePhase(): Promise<void> {
const released: Receipt = {}
receipt.released = released
for (const { name } of DISTROS) {
// Mid-exit unreadable command lines are unverifiable; wait for proven holder exit.
released[name] = await holder.release(name)
await waitFor(
`the ${name} holder to exit`,
async () => ((await holder.holderState(name)) === 'exited' ? true : null),
10_000,
250
)
released[name] = await releaseHolder(name)
}
// The systemd distro stops while the plain one still runs, so the interop entry is shared.
if (SYSTEMD) {