mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user