fix: validate paired launch results and retry incomplete SSH test snapshots

This commit is contained in:
Neil
2026-09-16 16:18:40 -07:00
parent 941ce439da
commit abb10a0eee
5 changed files with 113 additions and 73 deletions
@@ -2,10 +2,6 @@ import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-op
import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types'
import type {
RuntimeCreateAgentSessionResult,
RuntimeEnsureAgentSessionResult
} from '../../../shared/agent-session-host-authority'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { translate } from '../i18n/i18n'
import { useAppStore } from '../store'
@@ -43,7 +39,10 @@ import {
selectWebRuntimeSessionWorktree,
type WebRuntimeSessionWorkspaceSelectionRollback
} from './web-runtime-session-workspace-selection'
import { createdTerminalLeafId } from './web-runtime-terminal-identity'
import {
createdTerminalLeafId,
readCreatedAgentTerminalIdentity
} from './web-runtime-terminal-identity'
import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement'
export async function createWebRuntimeSessionTerminalResult(
@@ -88,9 +87,8 @@ export async function createWebRuntimeSessionTerminalResult(
args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs
if (agent) {
// Paired panes retain the default keyboard advertisement, including on Windows clients.
const keyboardOptions = createAgentSessionKeyboardOptions(
buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard
)
const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard
const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol)
let legacyAlreadyPlacedInGroup = false
// Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can.
// Why: focus belongs to the paired client; a headless execution host has no renderer to focus.
@@ -101,54 +99,60 @@ export async function createWebRuntimeSessionTerminalResult(
: args.agentSessionKind === 'resume'
? args.providerSession
? async () =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'terminal.ensureAgentSession',
params: {
...(await keyboardOptions(environmentId)),
kind: 'explicit',
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
providerSession: args.providerSession!,
...(args.launchConfig?.ompResumeFilePath
? { ompResumeFilePath: args.launchConfig.ompResumeFilePath }
: {}),
...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}),
...(args.launchPreferences
? { launchPreferences: args.launchPreferences }
: {}),
presentation: 'background'
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeEnsureAgentSessionResult>
)
: undefined
: async () =>
await createAgentSessionCreateOperation().run(async (clientOperationId) =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'terminal.createAgentSession',
params: withAgentSessionCreateOperationId(
{
readCreatedAgentTerminalIdentity(
unwrapRuntimeRpcResult(
await callEnvironment({
method: 'terminal.ensureAgentSession',
params: {
...(await keyboardOptions(environmentId)),
kind: 'explicit',
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}),
providerSession: args.providerSession!,
...(args.launchConfig?.ompResumeFilePath
? { ompResumeFilePath: args.launchConfig.ompResumeFilePath }
: {}),
...(agentArgsOverride !== undefined
? { agentArgs: agentArgsOverride }
: {}),
...(args.launchPreferences
? { launchPreferences: args.launchPreferences }
: {}),
...(args.cwd ? { startupCwd: args.cwd } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {}),
presentation: 'background'
},
clientOperationId
),
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeCreateAgentSessionResult>
timeoutMs: 15_000
})
)
)
: undefined
: async () =>
await createAgentSessionCreateOperation().run(async (clientOperationId) =>
readCreatedAgentTerminalIdentity(
unwrapRuntimeRpcResult(
await callEnvironment({
method: 'terminal.createAgentSession',
params: withAgentSessionCreateOperationId(
{
...(await keyboardOptions(environmentId)),
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}),
...(agentArgsOverride !== undefined
? { agentArgs: agentArgsOverride }
: {}),
...(args.launchPreferences
? { launchPreferences: args.launchPreferences }
: {}),
...(args.cwd ? { startupCwd: args.cwd } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {}),
presentation: 'background'
},
clientOperationId
),
timeoutMs: 15_000
})
)
)
)
const resumeHostAuthorityCapability =
@@ -0,0 +1,25 @@
import { expect, it } from 'vitest'
import { readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity'
it('reads host coordinates while accepting additive and legacy fields', () => {
expect(
readCreatedAgentTerminalIdentity({
terminal: { tabId: 'tab', paneKey: 'tab:leaf', future: true },
disposition: 'created'
})
).toEqual({ terminal: { tabId: 'tab', paneKey: 'tab:leaf' } })
expect(readCreatedAgentTerminalIdentity({ terminal: { paneKey: null } })).toEqual({
terminal: { tabId: undefined, paneKey: null }
})
})
it.each([
null,
{},
{ terminal: null },
{ terminal: 'pty' },
{ terminal: { tabId: 12 } },
{ terminal: { paneKey: false } }
])('rejects malformed host coordinates: %j', (value) => {
expect(() => readCreatedAgentTerminalIdentity(value)).toThrow('Host returned')
})
@@ -5,3 +5,25 @@ export function createdTerminalLeafId(terminal: CreatedAgentTerminalIdentity): s
const pane = parsePaneKey(terminal.paneKey ?? '')
return pane && pane.tabId === terminal.tabId ? pane.leafId : undefined
}
/** Decode only the host terminal coordinates consumed by the paired renderer. */
export function readCreatedAgentTerminalIdentity(value: unknown): {
terminal: CreatedAgentTerminalIdentity
} {
if (typeof value !== 'object' || value === null || !('terminal' in value)) {
throw new Error('Host returned an invalid agent terminal result')
}
const terminal = value.terminal
if (typeof terminal !== 'object' || terminal === null) {
throw new Error('Host returned an invalid agent terminal identity')
}
const tabId = 'tabId' in terminal ? terminal.tabId : undefined
const paneKey = 'paneKey' in terminal ? terminal.paneKey : undefined
if (
(tabId !== undefined && typeof tabId !== 'string') ||
(paneKey !== undefined && paneKey !== null && typeof paneKey !== 'string')
) {
throw new Error('Host returned invalid agent terminal coordinates')
}
return { terminal: { tabId, paneKey } }
}
@@ -52,7 +52,7 @@ function parseRelayProcessRows(output: string): RelayProcessRow[] {
return output.split('\n').map((line) => {
const [type, rawPid, rawParentPid, cwd] = line.split('\t')
// Why: Number('') is 0, so empty pid/ppid (e.g. vanished /proc status) must
// throw and let expect.poll retry instead of accepting parentPid: 0.
// throw so callers retry the observation instead of accepting parentPid: 0.
const pid = Number(rawPid)
const parentPid = Number(rawParentPid)
if (
+16 -27
View File
@@ -48,15 +48,11 @@ async function waitForRelayWatcherProcessGroup(
target: DockerSshRelayTarget
): Promise<DockerSshRelayProcessSnapshot> {
let snapshot: DockerSshRelayProcessSnapshot | null = null
await expect
.poll(
() => {
snapshot = readDockerSshRelayProcessSnapshot(target)
return snapshot !== null
},
{ timeout: 30_000, message: 'remote relay/watcher process group did not appear' }
)
.toBe(true)
// A process can exit between /proc reads; retry the rejected snapshot, never accept it.
await expect(() => {
snapshot = readDockerSshRelayProcessSnapshot(target)
expect(snapshot, 'remote relay/watcher process group did not appear').not.toBeNull()
}).toPass({ timeout: 30_000 })
if (!snapshot) {
throw new Error('remote relay/watcher process group disappeared after polling')
}
@@ -130,24 +126,17 @@ test.describe('Docker SSH relay watcher isolation', () => {
signalDockerSshRelayWatchers(target, beforeCrash)
let afterCrash: DockerSshRelayProcessSnapshot | null = null
await expect
.poll(
() => {
afterCrash = readDockerSshRelayProcessSnapshot(target!)
return Boolean(
afterCrash &&
afterCrash.relayPid === beforeCrash.relayPid &&
afterCrash.watcherPids.every(
(watcherPid) => !beforeCrash.watcherPids.includes(watcherPid)
)
)
},
{
timeout: 30_000,
message: 'watcher children were not replaced under the same relay PID'
}
)
.toBe(true)
await expect(() => {
afterCrash = readDockerSshRelayProcessSnapshot(target!)
expect(
afterCrash &&
afterCrash.relayPid === beforeCrash.relayPid &&
afterCrash.watcherPids.every(
(watcherPid) => !beforeCrash.watcherPids.includes(watcherPid)
),
'watcher children were not replaced under the same relay PID'
).toBe(true)
}).toPass({ timeout: 30_000 })
const terminalMarker = `SSH_WATCHER_CRASH_SURVIVED_${Date.now()}`
// Why: the shell echoes input before executing it. Encoding keeps the