From 3f8b15d5a73215557f086f9c17bbcf09ee3b06ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:55:57 -0700 Subject: [PATCH] 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 --- .github/workflows/wsl-orcad-e2e.yml | 4 +- .../claude-agent-sdk-scripted-cli.mjs | 8 +- .../wsl/wsl-orcad-chat-provider-fixture.ts | 150 ++++++-- src/main/wsl/wsl-orcad-midturn-stop-lane.ts | 333 ++++++++++++++++++ .../wsl/wsl-orcad-real-distro-lane.test.ts | 95 ++++- 5 files changed, 546 insertions(+), 44 deletions(-) create mode 100644 src/main/wsl/wsl-orcad-midturn-stop-lane.ts diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml index 3d7b8e8390e..5e434fa181a 100644 --- a/.github/workflows/wsl-orcad-e2e.yml +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -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 diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs index be1a0a63049..ce2d788d481 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -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() diff --git a/src/main/wsl/wsl-orcad-chat-provider-fixture.ts b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts index 1b00216ccef..e0512d74f12 100644 --- a/src/main/wsl/wsl-orcad-chat-provider-fixture.ts +++ b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts @@ -12,7 +12,7 @@ export type WslChatHost = { } export async function writeChatFixtureFile( - host: WslChatHost, + host: Pick, path: string, content: string ): Promise { @@ -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 => { - 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, + { home, root, runtime }: GuestCliPlace, + agent: WslChatGuestAgent +): Promise { + 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 { + 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, + logPath: string +): Promise { + 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, + logPath: string +): Promise { + return (await readProviderReportLog(host, logPath)).length +} + +export async function waitForProviderReportInLog( + host: Pick, + logPath: string, + schema: z.ZodType, + settled: (report: T) => boolean, + awaiting: string, + afterLine = 0 +): Promise { + 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( - host: WslChatHost, + host: Pick, path: string, schema: z.ZodType, settled: (report: T) => boolean, diff --git a/src/main/wsl/wsl-orcad-midturn-stop-lane.ts b/src/main/wsl/wsl-orcad-midturn-stop-lane.ts new file mode 100644 index 00000000000..9e3dcb04746 --- /dev/null +++ b/src/main/wsl/wsl-orcad-midturn-stop-lane.ts @@ -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 => 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 & { + /** The host connection's pairing, read at each call: Start replaces the connection. */ + pairing(): PairedRuntimeConnection + distro: string + environmentId: string + host: LaneWslHost + terminate(): Promise + /** Holder keys of the distros `wsl --list --running` confirms. */ + runningDistros(): Promise +} + +/** 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( + 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 => + ( + await lane.rpc('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 + } +} diff --git a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts index d12c46da62c..d4f3079f483 100644 --- a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts +++ b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts @@ -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 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 { 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: (method: string, params?: unknown) => rpc(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 { 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 { } }) } + receipt.midTurnStop = await proveMidTurnStop(PLAIN) // Teardown: bridges closed, holders released, distros terminated. await manager.dispose() @@ -681,14 +761,7 @@ async function survivePhase(): Promise { 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) {