mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
fix: validate paired launch results and retry incomplete SSH test snapshots
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user