Merge branch 'main' into fix/7716-relay-cli-orchestration

This commit is contained in:
Jinwoo Hong
2026-07-08 13:47:06 -07:00
committed by GitHub
138 changed files with 7487 additions and 613 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.9m">
<title>downloads: 3.9m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.0m">
<title>downloads: 4.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.9m</text>
<text x="93.5" y="14">3.9m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.0m</text>
<text x="93.5" y="14">4.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.129-rc.0",
"version": "1.4.129-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
listEnvironments: vi.fn()
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: mocks.listEnvironments
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
await import('./runtime-session-scanner')
beforeEach(() => {
vi.clearAllMocks()
mocks.listEnvironments.mockReturnValue([])
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: result([session('runtime:env-1', 'session-1')])
})
})
describe('runtime AI Vault session scanner', () => {
it('lists saved runtime environments as runtime execution hosts', () => {
mocks.listEnvironments.mockReturnValue([
{ id: 'env-1', name: 'Server 1' },
{ id: 'env-2', name: 'Server 2' }
])
expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([
{ environmentId: 'env-1', executionHostId: 'runtime:env-1' },
{ environmentId: 'env-2', executionHostId: 'runtime:env-2' }
])
})
it('forwards scan options to the runtime transport', async () => {
await scanRuntimeAiVaultSessions(
'/user-data',
'env-1',
{
limit: 25,
force: true,
scopePaths: ['/srv/app']
},
{ timeoutMs: 3000 }
)
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'aiVault.listSessions',
{
limit: 25,
force: true,
scopePaths: ['/srv/app'],
executionHostId: 'runtime:env-1'
},
3000
)
})
it('stamps sessions and issues returned for a different execution host', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: result(
[session('local', 'session-1')],
[
{
executionHostId: 'ssh:dev-box',
agent: 'codex',
path: '/sessions/session-1.jsonl',
message: 'could not parse session'
}
]
)
})
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
expect(scanResult.sessions).toEqual([
expect.objectContaining({
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
executionHostId: 'runtime:env-1'
})
])
expect(scanResult.issues).toEqual([
expect.objectContaining({
executionHostId: 'runtime:env-1',
agent: 'codex',
path: '/sessions/session-1.jsonl'
})
])
})
it('stamps accepted sessions with the requested runtime host', async () => {
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
expect(scanResult.sessions).toEqual([
expect.objectContaining({
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
executionHostId: 'runtime:env-1'
})
])
})
})
function result(
sessions: AiVaultSession[],
issues: AiVaultListResult['issues'] = []
): AiVaultListResult {
return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' }
}
function session(
executionHostId: AiVaultSession['executionHostId'],
sessionId: string
): AiVaultSession {
return {
id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`,
executionHostId,
executionHostPlatform: 'linux',
agent: 'codex',
sessionId,
title: sessionId,
cwd: '/srv/app',
branch: null,
model: null,
filePath: `/sessions/${sessionId}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: '2026-07-04T03:00:00.000Z',
modifiedAt: '2026-07-04T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
resumeCommand: `codex resume ${sessionId}`
}
}
@@ -0,0 +1,176 @@
import { z } from 'zod'
import {
AI_VAULT_AGENTS,
type AiVaultListArgs,
type AiVaultListResult,
type AiVaultSession
} from '../../shared/ai-vault-types'
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
export type RuntimeAiVaultHostInfo = {
environmentId: string
executionHostId: `runtime:${string}`
}
export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
const nodePlatformSchema = z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
] satisfies NodeJS.Platform[])
const aiVaultSessionPreviewMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
text: z.string(),
timestamp: z.string().nullable()
})
const executionHostIdSchema = z.string().transform((value, ctx) => {
const normalized = normalizeExecutionHostId(value)
if (normalized) {
return normalized
}
ctx.addIssue({
code: 'custom',
message: 'Invalid execution host id'
})
return z.NEVER
})
const aiVaultListResultSchema = z.object({
sessions: z.array(
z.object({
id: z.string(),
executionHostId: executionHostIdSchema,
executionHostPlatform: nodePlatformSchema.nullable().optional(),
agent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string(),
title: z.string(),
cwd: z.string().nullable(),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
codexHome: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
modifiedAt: z.string(),
messageCount: z.number(),
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
resumeCommand: z.string()
})
),
issues: z.array(
z.object({
executionHostId: executionHostIdSchema.optional(),
agent: z.enum(AI_VAULT_AGENTS),
path: z.string(),
message: z.string()
})
),
scannedAt: z.string()
})
export function getSavedRuntimeAiVaultHostInfos(
userDataPath: string
): readonly RuntimeAiVaultHostInfo[] {
return listEnvironments(userDataPath).map((environment) => ({
environmentId: environment.id,
executionHostId: toRuntimeExecutionHostId(environment.id)
}))
}
export async function scanRuntimeAiVaultSessions(
userDataPath: string,
environmentId: string,
args: AiVaultListArgs,
options: RuntimeAiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const executionHostId = toRuntimeExecutionHostId(environmentId)
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.listSessions',
{
limit: args.limit,
force: args.force,
scopePaths: args.scopePaths,
executionHostId
},
options.timeoutMs
)
if (response.ok === true) {
const parsed = aiVaultListResultSchema.safeParse(response.result)
if (parsed.success) {
return withRuntimeExecutionHost(parsed.data, executionHostId)
}
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: `Invalid aiVault.listSessions response: ${
parsed.error.issues[0]?.message ?? 'unexpected result shape'
}`
})
}
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: response.error.message
})
}
function withRuntimeExecutionHost(
result: AiVaultListResult,
executionHostId: `runtime:${string}`
): AiVaultListResult {
return {
sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)),
issues: result.issues.map((issue) => ({ ...issue, executionHostId })),
scannedAt: result.scannedAt
}
}
function retagRuntimeSession(
session: AiVaultSession,
executionHostId: `runtime:${string}`
): AiVaultSession {
// The paired server is the transport, but the parent owns which concrete
// runtime host was scanned; never trust returned host ids across the boundary.
return {
...session,
executionHostId,
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
}
}
function runtimeScanIssueResult(args: {
executionHostId: `runtime:${string}`
environmentId: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId: args.executionHostId,
agent: 'codex',
path: args.environmentId,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
+48
View File
@@ -0,0 +1,48 @@
import type {
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { sessionSortTime } from './session-scanner-accumulator'
export function aiVaultScanIssueResult(args: {
executionHostId?: ExecutionHostId
path: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
agent: 'codex',
path: args.path,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
export function mergeAiVaultListResults(
results: readonly AiVaultListResult[],
rawLimit: number | undefined
): AiVaultListResult {
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
const byId = new Map<string, AiVaultSession>()
const issues: AiVaultScanIssue[] = []
for (const result of results) {
for (const session of result.sessions) {
byId.set(session.id, session)
}
issues.push(...result.issues)
}
return {
sessions: [...byId.values()]
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
}
}
+6
View File
@@ -111,6 +111,7 @@ import {
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import {
@@ -986,6 +987,11 @@ function openMainWindow(): BrowserWindow {
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
window.on('minimize', stopSyntheticTitleSpinnerTimer)
// Why: visibility-gated main-process pollers (SSH port scanner) park while
// hidden and rely on this signal to resume; re-wired per window because
// macOS dock re-activation recreates the BrowserWindow.
window.on('show', notifyMainWindowBecameVisible)
window.on('restore', notifyMainWindowBecameVisible)
agentHookServer.setListener(
({
paneKey,
+81 -2
View File
@@ -6,6 +6,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
const mocks = vi.hoisted(() => ({
scanAiVaultSessions: vi.fn(),
scanRemoteAiVaultSessions: vi.fn(),
scanRuntimeAiVaultSessions: vi.fn(),
getAiVaultWslHomeDirs: vi.fn(),
getSshFilesystemProvider: vi.fn(),
getActiveSshAiVaultHostInfo: vi.fn(),
@@ -41,7 +42,7 @@ vi.mock('./ssh', () => ({
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
}))
const { _internals } = await import('./ai-vault')
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
const provider = {} as IFilesystemProvider
@@ -52,6 +53,9 @@ beforeEach(() => {
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
result([session('ssh:dev-box', 'remote-session')])
)
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(
result([session('runtime:remote-server', 'runtime-session')])
)
mocks.getSshFilesystemProvider.mockReturnValue(provider)
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
@@ -96,6 +100,77 @@ describe('listAiVaultSessions host routing', () => {
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
})
it('merges paired runtime servers for all hosts', async () => {
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [
{
environmentId: 'remote-server',
executionHostId: 'runtime:remote-server'
}
],
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith(
'remote-server',
{
executionHostScope: 'runtime:remote-server'
},
expect.objectContaining({ timeoutMs: expect.any(Number) })
)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([
'runtime:remote-server',
'ssh:dev-box',
'local'
])
})
it('keeps local and SSH results when runtime host discovery fails', async () => {
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => {
throw new Error('runtime store is invalid')
},
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled()
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
expect(result.issues).toEqual([
expect.objectContaining({
agent: 'codex',
path: 'runtime environments',
message: 'runtime store is invalid'
})
])
})
it('keeps direct runtime host scans on the normal runtime timeout', async () => {
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [],
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
await _internals.listAiVaultSessions({
executionHostScope: 'runtime:remote-server',
force: true
})
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith(
'remote-server',
{
executionHostScope: 'runtime:remote-server',
force: true
},
{}
)
})
it('returns a scan issue for a disconnected SSH target', async () => {
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
@@ -153,7 +228,11 @@ function session(
codexHome: null,
createdAt: null,
updatedAt:
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
sessionId === 'runtime-session'
? '2026-07-04T03:00:00.000Z'
: sessionId === 'remote-session'
? '2026-07-04T02:00:00.000Z'
: '2026-07-04T01:00:00.000Z',
modifiedAt: '2026-07-04T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
+113 -52
View File
@@ -2,17 +2,14 @@ import { app, ipcMain } from 'electron'
import { join } from 'node:path'
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type {
AiVaultListArgs,
AiVaultListResult,
AiVaultScanIssue
} from '../../shared/ai-vault-types'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostScope,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostScope
} from '../../shared/execution-host'
@@ -23,9 +20,20 @@ import {
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
const AI_VAULT_CACHE_TTL_MS = 15_000
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
type AiVaultHandlerOptions = {
getAdditionalCodexHomePaths?: () => readonly string[]
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: (
environmentId: string,
args: AiVaultListArgs,
options?: RuntimeAiVaultScanOptions
) => Promise<AiVaultListResult>
}
type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
type CachedAiVaultList = {
@@ -34,6 +42,11 @@ type CachedAiVaultList = {
expiresAt: number
}
type RuntimeAiVaultHostInfo = {
environmentId: string
executionHostId: `runtime:${string}`
}
let cachedList: CachedAiVaultList | null = null
let inflightList: Promise<AiVaultListResult> | null = null
let inflightKey: string | null = null
@@ -89,12 +102,20 @@ async function scanAiVaultSessionsByHostScope(
}
if (executionHostScope === 'all') {
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : []
return mergeAiVaultListResults(
await Promise.all([
scanLocalAiVaultSessions(args),
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
scanSshAiVaultSessions(hostInfo.targetId, args)
)
),
...runtimeHosts.hostInfos.map((hostInfo) =>
scanRuntimeAiVaultSessions(hostInfo, args, {
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
})
),
...runtimeResults
]),
args?.limit
)
@@ -104,19 +125,88 @@ async function scanAiVaultSessionsByHostScope(
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, args)
}
return {
sessions: [],
issues: [
if (parsed?.kind === 'runtime') {
return scanRuntimeAiVaultSessions(
{
executionHostId: executionHostScope,
agent: 'codex',
path: executionHostScope,
message: 'Agent Session History is not available for this execution host.'
}
],
scannedAt: new Date().toISOString()
environmentId: parsed.environmentId,
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
},
args
)
}
return aiVaultScanIssueResult({
executionHostId: executionHostScope,
path: executionHostScope,
message: 'Agent Session History is not available for this execution host.'
})
}
function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] {
return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? []
}
function getActiveRuntimeAiVaultHostInfosResult(): {
hostInfos: readonly RuntimeAiVaultHostInfo[]
issue?: AiVaultListResult
} {
try {
return { hostInfos: getActiveRuntimeAiVaultHostInfos() }
} catch (error) {
return {
hostInfos: [],
issue: runtimeHostDiscoveryIssueResult(
error instanceof Error ? error.message : 'Runtime hosts are unavailable.'
)
}
}
}
async function scanRuntimeAiVaultSessions(
hostInfo: RuntimeAiVaultHostInfo,
args?: AiVaultListArgs,
options: RuntimeAiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const scanner = handlerOptions.scanRuntimeAiVaultSessions
if (!scanner) {
return runtimeScanIssueResult(
hostInfo,
'Agent Session History is not available for this execution host.'
)
}
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
if (args?.limit !== undefined) {
scanArgs.limit = args.limit
}
if (args?.force !== undefined) {
scanArgs.force = args.force
}
if (args?.scopePaths !== undefined) {
scanArgs.scopePaths = args.scopePaths
}
try {
return await scanner(hostInfo.environmentId, scanArgs, options)
} catch (error) {
return runtimeScanIssueResult(
hostInfo,
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
)
}
}
function runtimeScanIssueResult(
hostInfo: RuntimeAiVaultHostInfo,
message: string
): AiVaultListResult {
return aiVaultScanIssueResult({
executionHostId: hostInfo.executionHostId,
path: hostInfo.environmentId,
message
})
}
function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult {
return aiVaultScanIssueResult({ path: 'runtime environments', message })
}
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
@@ -161,40 +251,11 @@ function sshScanIssueResult(args: {
targetId: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId: args.executionHostId,
agent: 'codex',
path: args.targetId,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
function mergeAiVaultListResults(
results: readonly AiVaultListResult[],
rawLimit: number | undefined
): AiVaultListResult {
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
const issues: AiVaultScanIssue[] = []
for (const result of results) {
for (const session of result.sessions) {
byId.set(session.id, session)
}
issues.push(...result.issues)
}
return {
sessions: [...byId.values()]
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
}
return aiVaultScanIssueResult({
executionHostId: args.executionHostId,
path: args.targetId,
message: args.message
})
}
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
+117 -36
View File
@@ -1,3 +1,4 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -5,7 +6,7 @@ const {
appExitMock,
appQuitMock,
appRelaunchMock,
execFileMock,
spawnMock,
destroySystemTrayMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock
@@ -14,16 +15,53 @@ const {
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
execFileMock: vi.fn(),
spawnMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
spawn: spawnMock
}))
// Fakes the detached `spawn` child: a stdout EventEmitter plus close/error
// events, so tests drive the async command lifecycle readCommandStdout expects.
function createFakeSpawnChild(options: {
stdout?: string
code?: number
error?: Error
pid?: number
hang?: boolean
}): EventEmitter & { pid: number; kill: ReturnType<typeof vi.fn>; stdout: EventEmitter } {
const { stdout, code = 0, error, pid = 4242, hang = false } = options
const child = new EventEmitter() as EventEmitter & {
pid: number
kill: ReturnType<typeof vi.fn>
stdout: EventEmitter & { setEncoding: ReturnType<typeof vi.fn> }
}
child.pid = pid
child.kill = vi.fn()
const stdoutStream = new EventEmitter() as EventEmitter & {
setEncoding: ReturnType<typeof vi.fn>
}
stdoutStream.setEncoding = vi.fn()
child.stdout = stdoutStream
if (!hang) {
queueMicrotask(() => {
if (error) {
child.emit('error', error)
return
}
if (stdout !== undefined) {
stdoutStream.emit('data', stdout)
}
child.emit('close', code)
})
}
return child
}
vi.mock('electron', () => ({
app: {
exit: appExitMock,
@@ -63,6 +101,9 @@ import { registerAppHandlers } from './app'
describe('registerAppHandlers', () => {
const originalPlatform = process.platform
// Why: readCommandStdout process-group-kills on timeout; stub the real signal
// so a fake child pid can never target a live process group during tests.
let processKillSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
@@ -70,14 +111,16 @@ describe('registerAppHandlers', () => {
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
execFileMock.mockReset()
spawnMock.mockReset()
destroySystemTrayMock.mockReset()
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true)
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
afterEach(() => {
processKillSpy.mockRestore()
vi.useRealTimers()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
@@ -177,10 +220,9 @@ describe('registerAppHandlers', () => {
it('returns the selected macOS input mode before the keyboard layout fallback', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(
null,
JSON.stringify([
spawnMock.mockImplementation(() =>
createFakeSpawnChild({
stdout: JSON.stringify([
{ 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' },
{
'Bundle ID': 'com.apple.inputmethod.SCIM',
@@ -188,60 +230,96 @@ describe('registerAppHandlers', () => {
InputSourceKind: 'Input Mode'
}
])
)
return { kill: vi.fn() }
})
})
)
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.inputmethod.SCIM.ITABC'
)
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
'/usr/bin/plutil',
expect.arrayContaining(['AppleSelectedInputSources']),
expect.any(Object),
expect.any(Function)
expect(spawnMock).toHaveBeenCalledTimes(1)
// Why: macOS 15's `plutil -extract <key> json` aborts on the input-source
// array, so the probe reads live cfprefsd via `defaults export` and dodges
// the bug with an xml1 extract before converting the clean subtree to JSON.
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
// stage silently regressing CJK detection to the fallback fails the test.
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
[
'-c',
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
'/usr/bin/plutil -convert json -o - -'
],
expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
)
})
it('falls back to the keyboard layout when no keyboard input mode is selected', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
execFileMock
.mockImplementationOnce((_command, _args, _options, callback) => {
callback(
null,
JSON.stringify([
spawnMock
.mockImplementationOnce(() =>
createFakeSpawnChild({
stdout: JSON.stringify([
{
'Bundle ID': 'com.apple.PressAndHold',
InputSourceKind: 'Non Keyboard Input Method'
}
])
)
return { kill: vi.fn() }
})
.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'com.apple.keylayout.ABC\n')
return { kill: vi.fn() }
})
})
)
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.keylayout.ABC'
)
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenLastCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenLastCalledWith(
'/usr/bin/defaults',
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
expect.any(Object),
expect.any(Function)
expect.objectContaining({ detached: true })
)
})
it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
// Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so
// the probe rejects on the `close` branch and the handler falls back.
spawnMock
.mockImplementationOnce(() => createFakeSpawnChild({ code: 1 }))
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.keylayout.ABC'
)
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenLastCalledWith(
'/usr/bin/defaults',
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
expect.objectContaining({ detached: true })
)
})
it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
// Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must
// still fall back rather than reject out of the IPC call.
spawnMock
.mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') }))
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.keylayout.ABC'
)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('falls back when macOS keyboard input source probes never report completion', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
const killMock = vi.fn()
execFileMock.mockImplementation(() => ({ kill: killMock }))
spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true }))
registerAppHandlers({} as never)
const handler = handlers.get('app:getKeyboardInputSourceId')
@@ -256,7 +334,10 @@ describe('registerAppHandlers', () => {
expect(settled).toBe(true)
await expect(resultPromise).resolves.toBeNull()
expect(killMock).toHaveBeenCalledTimes(2)
// Why: both wedged probes get a process-group SIGKILL (negative pid) so the
// shell and any orphaned `defaults`/`plutil` stages are reaped on timeout.
expect(processKillSpy).toHaveBeenCalledTimes(2)
expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL')
})
it('picks an existing floating workspace directory without enabling native directory creation', async () => {
+58 -29
View File
@@ -1,6 +1,5 @@
import { execFile } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron'
@@ -24,12 +23,15 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown
const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500
const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox'
const MAC_HITOOLBOX_PREFERENCES_PATH = path.join(
homedir(),
'Library',
'Preferences',
`${MAC_HITOOLBOX_DOMAIN}.plist`
)
// Why: macOS 15's `plutil -extract <key> json` aborts on the (pure-string)
// input-source array and the on-disk plist lags cfprefsd; read live prefs via
// `defaults export`, extract as xml1 (dodges the json bug), then convert to JSON.
// Absolute paths so a GUI-launched app's minimal PATH can't shadow the tools.
const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [
`/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`,
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -',
'/usr/bin/plutil -convert json -o - -'
].join(' | ')
type RegisterAppHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
@@ -118,13 +120,32 @@ function readCommandStdout(
): Promise<string> {
return new Promise((resolve, reject) => {
let settled = false
let child: ReturnType<typeof execFile> | undefined
let child: ReturnType<typeof spawn> | undefined
// Why: the probe runs a `/bin/sh -c` pipeline, so signaling only the shell
// orphans wedged `defaults`/`plutil` stages on a stuck cfprefsd. Spawning
// detached makes the child a process-group leader, so one negative-pid
// SIGKILL reaps the shell and every stage; child.kill() covers the fallback.
const killTree = (): void => {
if (!child?.pid) {
return
}
try {
process.kill(-child.pid, 'SIGKILL')
} catch {
child.kill()
}
}
// Why: short timeout so a wedged macOS preference probe (corporate-managed
// config, sandbox policy, ...) never holds the handle indefinitely. This
// manual timer is the only timeout guard, so it owns the process-group kill.
const timer = setTimeout(() => {
if (settled) {
return
}
settled = true
child?.kill()
killTree()
reject(new Error(timeoutMessage))
}, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS)
@@ -137,24 +158,32 @@ function readCommandStdout(
callback()
}
// Why: execFile's timeout only signals `defaults`; if the callback
// never arrives, window-focus keyboard probes would remain pending.
try {
child = execFile(
command,
args,
// Why: short timeout so a wedged macOS preference probe (corporate-managed
// config, sandbox policy, ...) never holds the handle indefinitely.
// Fall through to the fingerprint on timeout.
{ encoding: 'utf8', timeout: KEYBOARD_INPUT_SOURCE_TIMEOUT_MS },
(error, stdout) => {
if (error) {
settle(() => reject(error))
return
}
settle(() => resolve(String(stdout ?? '')))
}
)
child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
})
const failWith = (error: Error): void => {
killTree()
settle(() => reject(error))
}
// Why: an unhandled Readable 'error' would crash the main process; treat a
// stdout read failure the same as a child spawn error.
child.stdout?.on('error', failWith)
child.on('error', failWith)
child.on('close', (code, signal) => {
settle(() =>
code === 0
? resolve(stdout)
: reject(
new Error(
`${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}`
)
)
)
})
} catch (error) {
settle(() => reject(error))
}
@@ -196,8 +225,8 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null {
async function readSelectedKeyboardInputSourceId(): Promise<string | null> {
try {
const stdout = await readCommandStdout(
'/usr/bin/plutil',
['-extract', 'AppleSelectedInputSources', 'json', '-o', '-', MAC_HITOOLBOX_PREFERENCES_PATH],
'/bin/sh',
['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND],
'Selected keyboard input source probe timed out'
)
return readSelectedInputSourceIdFromJson(stdout)
+35
View File
@@ -2316,4 +2316,39 @@ describe('registerFilesystemHandlers', () => {
excludePaths: ['/home/user/repo/worktrees/feature']
})
})
// Why #7721: without a cancel path, every workspace switch left the previous
// workspace's full-tree SSH scan running, stacking scans on the relay until
// interactive fs.readDir/fs.stat starved past their 30s timeout.
it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => {
let capturedSignal: AbortSignal | undefined
const listFilesMock = vi.fn(
(_rootPath: string, options: { signal?: AbortSignal }) =>
new Promise<string[]>((_resolve, reject) => {
capturedSignal = options.signal
options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), {
once: true
})
})
)
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
registerFilesystemHandlers(store as never)
const pending = handlers.get('fs:listFiles')!(null, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
requestToken: 'token-1'
}) as Promise<string[]>
expect(capturedSignal?.aborted).toBe(false)
await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' })
expect(capturedSignal?.aborted).toBe(true)
await expect(pending).rejects.toThrow('listing cancelled')
// Unknown or already-settled tokens are a no-op, not an error.
expect(() =>
handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' })
).not.toThrow()
})
})
+42 -14
View File
@@ -1026,30 +1026,58 @@ export function registerFilesystemHandlers(
)
// ─── List all files (for quick-open) ─────────────────────
// Why #7721: keyed by renderer-generated token so a workspace switch can
// abort the previous workspace's full-tree scan (SSH relays otherwise stack
// scans that starve interactive fs.readDir/fs.stat past their 30s timeout).
const listFilesCancellations = new Map<string, AbortController>()
ipcMain.handle(
'fs:listFiles',
async (
_event,
args: { rootPath: string; connectionId?: string; excludePaths?: string[] }
args: {
rootPath: string
connectionId?: string
excludePaths?: string[]
requestToken?: string
}
): Promise<string[]> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
// Why: when the SSH connection is not yet established (cold start) or
// temporarily disconnected, return [] so quick-open shows "No matching
// files" instead of an error banner. The file list will repopulate when
// the user re-opens quick-open after the connection is restored.
if (!provider) {
return []
const controller = args.requestToken ? new AbortController() : null
if (controller && args.requestToken) {
listFilesCancellations.set(args.requestToken, controller)
}
try {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
// Why: when the SSH connection is not yet established (cold start) or
// temporarily disconnected, return [] so quick-open shows "No matching
// files" instead of an error banner. The file list will repopulate when
// the user re-opens quick-open after the connection is restored.
if (!provider) {
return []
}
// Why: forward excludePaths through to the remote provider.
// Dropping it here would silently double-scan nested linked worktrees
// over SSH and contribute to timeout-induced partial results.
return await provider.listFiles(args.rootPath, {
excludePaths: args.excludePaths,
signal: controller?.signal
})
}
// Why: forward excludePaths through to the remote provider.
// Dropping it here would silently double-scan nested linked worktrees
// over SSH and contribute to timeout-induced partial results.
return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths })
return await listQuickOpenFiles(args.rootPath, store, args.excludePaths)
} finally {
if (args.requestToken) {
listFilesCancellations.delete(args.requestToken)
}
}
return listQuickOpenFiles(args.rootPath, store, args.excludePaths)
}
)
ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => {
// Why: best-effort — the entry is gone once the listing settles, and
// local scans are fast enough to simply let them finish.
listFilesCancellations.get(args.requestToken)?.abort()
})
// ─── Git operations ─────────────────────────────────────
ipcMain.handle(
'git:status',
+52 -12
View File
@@ -18,11 +18,13 @@ const expectedOmpStatusExtension = posix.join(
'extensions',
'orca-agent-status.ts'
)
const expectedAttributionShimDir = join(
'/tmp/orca-user-data',
'orca-terminal-attribution',
isWindowsHost ? 'win32' : 'posix'
)
function expectedAttributionShimDir(): string {
return join(
'/tmp/orca-user-data',
'orca-terminal-attribution',
process.platform === 'win32' ? 'win32' : 'posix'
)
}
const {
handleMock,
@@ -1152,7 +1154,7 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.PATH).toContain(expectedAttributionShimDir)
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('skips git/gh attribution shims when attribution is disabled', async () => {
@@ -1164,7 +1166,7 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir)
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
@@ -1193,7 +1195,7 @@ describe('registerPtyHandlers', () => {
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir)
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
@@ -1799,7 +1801,7 @@ describe('registerPtyHandlers', () => {
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir)
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
@@ -1829,7 +1831,7 @@ describe('registerPtyHandlers', () => {
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir)
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
@@ -1850,7 +1852,7 @@ describe('registerPtyHandlers', () => {
)
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir)
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
@@ -1967,7 +1969,7 @@ describe('registerPtyHandlers', () => {
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir)
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('does not mutate the caller-provided args.env on the daemon path', async () => {
@@ -5810,9 +5812,11 @@ describe('registerPtyHandlers', () => {
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.WSLENV?.split(':')).toEqual(
expect.arrayContaining([
'ORCA_TERMINAL_HANDLE/u',
'ORCA_USER_DATA_PATH/p',
'ORCA_AGENT_HOOK_PORT/u',
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
@@ -5822,6 +5826,42 @@ describe('registerPtyHandlers', () => {
)
})
it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_wsl'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
env: {
ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
})
describe('Windows UTF-8 code page', () => {
let originalPlatform: string
let originalComspec: string | undefined
+7 -8
View File
@@ -909,15 +909,14 @@ export function buildPtyHostEnv(
baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath
}
// Why: in dev mode the `orca` CLI defaults to the production userData
// path, which routes status updates to the packaged Orca instead of this
// dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from
// agents running inside dev terminals reach the correct runtime. We also
// prepend the dev CLI launcher directory to PATH so `orca` resolves to
// the dev build (which supports ORCA_USER_DATA_PATH) instead of the
// production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
// Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance.
if (opts.isWsl) {
baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath
} else if (!opts.isPackaged) {
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
}
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
const inheritedPath = readInheritedPath(baseEnv)
// Why: avoid a trailing delimiter when PATH is empty — some shells
+76 -4
View File
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
getPathMock,
listEnvironmentsMock,
callRuntimeEnvironmentMock,
registerCliHandlersMock,
registerPreflightHandlersMock,
registerClaudeUsageHandlersMock,
@@ -57,6 +60,9 @@ const {
registerEmulatorFrameStreamHandlersMock,
registerEmulatorVideoStreamHandlersMock
} = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/test/user-data'),
listEnvironmentsMock: vi.fn(() => []),
callRuntimeEnvironmentMock: vi.fn(),
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
registerClaudeUsageHandlersMock: vi.fn(),
@@ -114,6 +120,20 @@ const {
registerEmulatorVideoStreamHandlersMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: listEnvironmentsMock
}))
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: callRuntimeEnvironmentMock
}))
vi.mock('./onboarding', () => ({
registerOnboardingHandlers: registerOnboardingHandlersMock
}))
@@ -326,6 +346,11 @@ import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
beforeEach(() => {
getPathMock.mockReset()
getPathMock.mockReturnValue('/test/user-data')
listEnvironmentsMock.mockReset()
listEnvironmentsMock.mockReturnValue([])
callRuntimeEnvironmentMock.mockReset()
registerCliHandlersMock.mockReset()
registerPreflightHandlersMock.mockReset()
registerClaudeUsageHandlersMock.mockReset()
@@ -382,7 +407,7 @@ describe('registerCoreHandlers', () => {
registerEmulatorVideoStreamHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
it('passes the store through to handler registrars that need it', async () => {
const store = { marker: 'store' }
const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null }
const stats = { marker: 'stats' }
@@ -415,6 +440,14 @@ describe('registerCoreHandlers', () => {
{ getAdditionalAiVaultCodexHomePaths, onBeforeRelaunch }
)
const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0]
expect(aiVaultOptions).toBeDefined()
callRuntimeEnvironmentMock.mockResolvedValueOnce({
ok: true,
result: { sessions: 'bad-shape' }
})
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
@@ -451,9 +484,14 @@ describe('registerCoreHandlers', () => {
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
})
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith(
expect.objectContaining({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths,
getActiveRuntimeAiVaultHostInfos: expect.any(Function),
scanRuntimeAiVaultSessions: expect.any(Function)
})
)
expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([])
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
@@ -466,6 +504,40 @@ describe('registerCoreHandlers', () => {
expect(registerBrowserHandlersMock).toHaveBeenCalled()
expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled()
expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store)
await expect(
aiVaultOptions.scanRuntimeAiVaultSessions(
'env-123',
{
limit: 10,
scopePaths: ['/workspace']
},
{ timeoutMs: 3000 }
)
).resolves.toEqual({
sessions: [],
issues: [
expect.objectContaining({
executionHostId: 'runtime:env-123',
agent: 'codex',
path: 'env-123',
message: expect.stringContaining('Invalid aiVault.listSessions response')
})
],
scannedAt: expect.any(String)
})
expect(callRuntimeEnvironmentMock).toHaveBeenCalledWith(
'/test/user-data',
'env-123',
'aiVault.listSessions',
{
limit: 10,
force: undefined,
scopePaths: ['/workspace'],
executionHostId: 'runtime:env-123'
},
3000
)
})
it('only registers IPC handlers once but always updates web contents id', () => {
+10 -1
View File
@@ -1,3 +1,4 @@
import { app } from 'electron'
import { registerAppHandlers } from './app'
import { registerCliHandlers } from './cli'
import { registerPreflightHandlers } from './preflight'
@@ -69,6 +70,10 @@ import type { AutomationService } from '../automations/service'
import type { AgentAwakeService } from '../agent-awake-service'
import type { CrashReportStore } from '../crash-reporting/crash-report-store'
import type { KeybindingService } from '../keybindings/keybinding-service'
import {
getSavedRuntimeAiVaultHostInfos,
scanRuntimeAiVaultSessions
} from '../ai-vault/runtime-session-scanner'
let registered = false
@@ -171,7 +176,11 @@ export function registerCoreHandlers(
registerRuntimeEnvironmentHandlers(store)
registerEphemeralVmHandlers(store)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
getActiveRuntimeAiVaultHostInfos: () =>
getSavedRuntimeAiVaultHostInfos(app.getPath('userData')),
scanRuntimeAiVaultSessions: async (environmentId, args, options) =>
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options)
})
registerNativeChatHandlers()
registerClipboardHandlers(store)
+109 -3
View File
@@ -50,7 +50,8 @@ const {
onRequest: vi.fn().mockReturnValue(() => {}),
onDispose: vi.fn().mockReturnValue(() => {}),
request: vi.fn().mockResolvedValue({}),
notify: vi.fn()
notify: vi.fn(),
probeLiveness: vi.fn().mockResolvedValue(false)
},
mockPtyProvider: {
onData: vi.fn(),
@@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => {
mockMux.isDisposed.mockReset().mockReturnValue(false)
mockMux.onNotification.mockReset()
mockMux.onDispose.mockReset().mockReturnValue(() => {})
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
mockPtyProvider.onData.mockReset()
mockPtyProvider.onExit.mockReset()
mockPtyProvider.onReplay.mockReset()
@@ -778,6 +780,39 @@ describe('SSH IPC handlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
})
it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => {
const runtime = {
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
notifySshStateChanged: vi.fn()
}
registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never)
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue({})
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
// Why: paired remote clients only learn SSH state through this hook —
// without it their reconnect overlays never clear (STA-1468).
expect(runtime.notifySshStateChanged).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ targetId: 'ssh-1', status: 'connected' })
)
})
it('preserves active port forwards and live connections across handler re-registration', async () => {
const target: SshTarget = {
id: 'ssh-1',
@@ -1368,7 +1403,7 @@ describe('SSH IPC handlers', () => {
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
})
it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => {
it('reconnects on system resume when the relay liveness probe fails', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
@@ -1386,6 +1421,7 @@ describe('SSH IPC handlers', () => {
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(false)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
@@ -1394,7 +1430,77 @@ describe('SSH IPC handlers', () => {
resumeListener()
expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
})
it('skips reconnect on system resume when the relay link is still alive', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(conn)
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(true)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
expect(resumeListener).toBeTypeOf('function')
resumeListener()
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1))
// Let the async resume handler settle before asserting no teardown happened.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
})
it('does not reconnect after resume when the target was disconnected during the probe', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(conn)
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(false)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
expect(resumeListener).toBeTypeOf('function')
resumeListener()
// Why: the probe window is seconds long; a user disconnect during it must
// win — reconnecting afterwards would resurrect the torn-down target.
mockConnectionManager.getConnection.mockReturnValue(undefined)
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
})
it('extends active relay grace while the system is suspending', async () => {
+59 -14
View File
@@ -90,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un
return registeredGetSshState?.(targetId)
}
/** Public targets for runtime RPC clients — same list the desktop renderer gets. */
export function listRegisteredSshTargets(): SshTarget[] {
return sshStore?.listTargets() ?? []
}
/** Removed-target id → last known label, for ghost-host display on paired clients. */
export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
return sshStore?.listRemovedTargetLabels() ?? {}
}
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
if (!connectionManager) {
return
@@ -235,13 +245,14 @@ function broadcastSshState(
if (isRuntimeOwnedSshTargetId(targetId)) {
return
}
const enrichedState = withSshRemotePlatform(targetId, state)
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', {
targetId,
state: withSshRemotePlatform(targetId, state)
})
win.webContents.send('ssh:state-changed', { targetId, state: enrichedState })
}
// Why: paired remote clients have no ssh:state-changed IPC; without this
// their terminals keep a stale reconnect overlay after the host connects.
currentRuntime?.notifySshStateChanged?.(targetId, enrichedState)
}
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
@@ -431,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
})
}
// Why: macOS can resume the process before the network stack is back up, so
// a failed first probe gets one retry before the link is declared dead (#7773).
const RESUME_PROBE_TIMEOUT_MS = 5_000
const RESUME_PROBE_ATTEMPTS = 2
async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise<boolean> {
const mux = session.getMux()
if (!mux || mux.isDisposed()) {
return false
}
for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) {
if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) {
return true
}
}
return false
}
function registerPowerMonitorReconnect(): void {
powerMonitorUnsubscribe?.()
const onSuspend = (): void => {
@@ -439,19 +468,35 @@ function registerPowerMonitorReconnect(): void {
}
}
const onResume = (): void => {
for (const targetId of activeSessions.keys()) {
const conn = connectionManager?.getConnection(targetId)
for (const [targetId, session] of activeSessions) {
const manager = connectionManager
const conn = manager?.getConnection(targetId)
if (!conn) {
continue
}
const reconnect = connectionManager?.reconnect(targetId)
void reconnect?.catch((err) => {
console.warn(
`[ssh] Failed to reconnect ${targetId} after system resume: ${
err instanceof Error ? err.message : String(err)
}`
)
})
void (async () => {
// Why: unconditional reconnect on every wake tore down live sessions
// and flashed the reconnect overlay (#7773). Only reconnect targets
// whose relay link actually died during sleep.
if (await isRelayLinkAliveAfterResume(session)) {
return
}
// Why: the probe can take ~10s. If the user disconnected or the
// session/connection was replaced meanwhile, reconnecting would
// resurrect a connection that was intentionally torn down.
if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) {
return
}
try {
await manager?.reconnect(targetId)
} catch (err) {
console.warn(
`[ssh] Failed to reconnect ${targetId} after system resume: ${
err instanceof Error ? err.message : String(err)
}`
)
}
})()
}
}
powerMonitor.on('suspend', onSuspend)
+144
View File
@@ -2988,6 +2988,16 @@ describe('Store', () => {
expect(updated?.projectGroupOrder).toBe(1)
})
it('updates repo execution host identity', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1' }))
const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' })
expect(updated?.executionHostId).toBe('runtime:env-1')
expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1')
})
it('getRepo returns undefined for nonexistent id', async () => {
const store = await createStore()
expect(store.getRepo('nonexistent')).toBeUndefined()
@@ -3188,6 +3198,140 @@ describe('Store', () => {
expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new')
})
it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }))
store.setWorkspaceSession({
activeRepoId: 'r1',
activeWorktreeId: 'r1::/wt',
activeTabId: 'tab1',
tabsByWorktree: {
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })]
},
terminalLayoutsByTabId: {},
remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' },
activeConnectionIdsAtShutdown: ['ssh-old']
})
store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' })
store.updateUI({
workspaceHostScope: 'ssh:ssh-old',
visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'],
workspaceHostOrder: ['ssh:ssh-old', 'local']
})
store.reassignSshTargetId('ssh-old', 'ssh-new')
store.flush()
const reloaded = await createStore()
const session = reloaded.getWorkspaceSession()
expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2')
expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' })
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new'])
expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1)
expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0)
const ui = reloaded.getUI()
expect(ui.workspaceHostScope).toBe('ssh:ssh-new')
expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new'])
expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local'])
})
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
const store = await createStore()
store.setWorkspaceSession(
{
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })]
},
terminalLayoutsByTabId: {}
},
'ssh:ssh-old'
)
store.reassignSshTargetId('ssh-old', 'ssh-new')
store.flush()
const reloaded = await createStore()
// Old-key partition is gone; the re-keyed one carries migrated pty ids.
expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe(
'ssh:ssh-new@@pty-9'
)
})
it('reassignSshTargetId keeps the live partition when both host keys exist', async () => {
const store = await createStore()
const baseSession = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
terminalLayoutsByTabId: {}
}
store.setWorkspaceSession(
{ ...baseSession, tabsByWorktree: { 'r1::/dead': [] } },
'ssh:ssh-old'
)
store.setWorkspaceSession(
{ ...baseSession, tabsByWorktree: { 'r1::/live': [] } },
'ssh:ssh-new'
)
store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] })
})
it('reassignSshTargetId re-points an independent provisioned host setup', async () => {
const store = await createStore()
store.addRepo({
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
upstream: { owner: 'stablyai', repo: 'cloud-project' }
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-old',
setupId: 'cloud-project::ssh-old',
setupMethod: 'provisioned'
})
// Meta-only re-adoption (no repo pinned to the old id) must still migrate
// the provisioned setup, or new worktrees would be born on a dead host id.
store.reassignSshTargetId('ssh-old', 'ssh-new')
const setups = store.getProjectHostSetups()
const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old')
expect(provisioned?.hostId).toBe('ssh:ssh-new')
})
it('reassignSshTargetId drops a stale setup when the new host already has one', async () => {
const store = await createStore()
store.addRepo({
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
upstream: { owner: 'stablyai', repo: 'cloud-project' }
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-old',
setupId: 'setup-old',
setupMethod: 'provisioned'
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-new',
setupId: 'setup-new',
setupMethod: 'provisioned'
})
store.reassignSshTargetId('ssh-old', 'ssh-new')
const setups = store.getProjectHostSetups()
expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined()
expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new')
})
// ── 7. updateRepo ──────────────────────────────────────────────────
it('updateRepo modifies the repo in place', async () => {
+68 -5
View File
@@ -113,6 +113,10 @@ import {
type ExecutionHostId
} from '../shared/execution-host'
import { toRelaySshPtyId } from './providers/ssh-pty-id'
import {
migrateUiHostScopeSshTargetId,
migrateWorkspaceSessionSshTargetId
} from './ssh/ssh-target-id-migration'
import { isWslUncPath } from '../shared/wsl-paths'
import {
isTerminalLeafId,
@@ -4272,6 +4276,7 @@ export class Store {
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
| 'executionHostId'
| 'symlinkPaths'
| 'issueSourcePreference'
| 'forkSyncMode'
@@ -6130,13 +6135,71 @@ export class Store {
metaChanged = true
}
}
// Why: repo-row rewrites can affect host-setup compatibility, but meta-only
// rewrites cannot — keep that sync under the repo gate. Persist whenever
// either repos OR metas changed, so meta-only re-points aren't lost on quit.
if (repoCount > 0) {
// Why: the old id also survives in session pty ids, the startup reconnect
// list, sleeping-agent records, host setups, host-scope UI, and pty leases;
// any un-migrated carrier later throws `SSH target not found` (STA-1468).
let carrierChanged = migrateWorkspaceSessionSshTargetId(
this.state.workspaceSession,
oldTargetId,
newTargetId
)
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) {
carrierChanged = true
}
}
// Why: partitions are read by host id, so one stored under the removed id
// would be orphaned. No writer keys partitions by ssh host today, but the
// schema tolerates it — re-key rather than strand it. If the new key
// already has a partition, that one is live; drop the dead old one.
const partitions = this.state.workspaceSessionsByHostId
const oldPartition = partitions?.[oldHostId]
if (partitions && oldPartition) {
delete partitions[oldHostId]
partitions[newHostId] ??= oldPartition
carrierChanged = true
}
if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) {
carrierChanged = true
}
for (const lease of this.state.sshRemotePtyLeases ?? []) {
if (lease.targetId === oldTargetId) {
lease.targetId = newTargetId
carrierChanged = true
}
}
let setupsChanged = false
const keptSetups: ProjectHostSetup[] = []
for (const setup of this.state.projectHostSetups) {
if (setup.hostId !== oldHostId) {
keptSetups.push(setup)
continue
}
const duplicate = this.state.projectHostSetups.some(
(entry) =>
entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId
)
// Why: a setup already exists for the re-added host — the old row is a
// stale ghost that would violate the (projectId, hostId) uniqueness.
if (duplicate) {
setupsChanged = true
continue
}
setup.hostId = newHostId
setup.updatedAt = Date.now()
keptSetups.push(setup)
setupsChanged = true
}
if (setupsChanged) {
this.state.projectHostSetups = keptSetups
}
// Why: repo-row and host-setup rewrites can affect host-setup compatibility,
// but meta-only rewrites cannot — keep that sync under this gate. Persist
// whenever anything changed, so partial re-points aren't lost on quit.
if (repoCount > 0 || setupsChanged) {
this.syncProjectHostSetupCompatibilityState()
}
if (repoCount > 0 || metaChanged) {
if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) {
this.scheduleSave()
}
return repoCount
+15 -8
View File
@@ -50,6 +50,10 @@ export type ShellReadySignal = {
// ── Shell wrapper files ─────────────────────────────────────────────
function getShellReadyWrapperRoot(): string {
// Why: this instance's userData must win over an inherited
// ORCA_USER_DATA_PATH (Orca launched from another Orca's terminal), so the
// wrapper writer root always matches the root buildPtyHostEnv hands to
// WSL children.
const userDataPath = app?.getPath?.('userData') ?? process.env.ORCA_USER_DATA_PATH ?? tmpdir()
return `${userDataPath}/shell-ready`
}
@@ -64,8 +68,8 @@ function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): s
]
}
function shellReadyWrappersExist(): boolean {
return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path))
function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean {
return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path))
}
// Why: if our own process inherited ZDOTDIR from a parent shell that was
@@ -288,16 +292,12 @@ fi
`
}
function ensureShellReadyWrappers(): void {
if (process.platform === 'win32') {
return
}
if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) {
export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void {
if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) {
return
}
didEnsureShellReadyWrappers = true
const root = getShellReadyWrapperRoot()
const zshDir = `${root}/zsh`
const bashDir = `${root}/bash`
@@ -365,6 +365,13 @@ ${getZshFinalZdotdirRestoreBlock()}
}
}
function ensureShellReadyWrappers(): void {
if (process.platform === 'win32') {
return
}
ensureShellReadyWrappersAt()
}
// ── Shell launch config ─────────────────────────────────────────────
export type ShellReadyLaunchConfig = {
@@ -417,7 +417,11 @@ describe('SshFilesystemProvider', () => {
it('listFiles sends fs.listFiles request', async () => {
mux.request.mockResolvedValue(['src/index.ts', 'package.json'])
const result = await provider.listFiles('/home/user/project')
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: undefined }
)
expect(result).toEqual(['src/index.ts', 'package.json'])
})
@@ -426,16 +430,35 @@ describe('SshFilesystemProvider', () => {
await provider.listFiles('/home/user/project', {
excludePaths: ['/home/user/project/worktrees/b']
})
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
rootPath: '/home/user/project',
excludePaths: ['/home/user/project/worktrees/b']
})
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{
rootPath: '/home/user/project',
excludePaths: ['/home/user/project/worktrees/b']
},
{ signal: undefined }
)
})
it('listFiles omits excludePaths when empty', async () => {
mux.request.mockResolvedValue([])
await provider.listFiles('/home/user/project', { excludePaths: [] })
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: undefined }
)
})
it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => {
mux.request.mockResolvedValue([])
const controller = new AbortController()
await provider.listFiles('/home/user/project', { signal: controller.signal })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: controller.signal }
)
})
describe('watch', () => {
+10 -2
View File
@@ -283,12 +283,20 @@ export class SshFilesystemProvider implements IFilesystemProvider {
return (await this.mux.request('fs.search', opts)) as SearchResult
}
async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise<string[]> {
async listFiles(
rootPath: string,
options?: { excludePaths?: string[]; signal?: AbortSignal }
): Promise<string[]> {
const params: Record<string, unknown> = { rootPath }
if (options?.excludePaths && options.excludePaths.length > 0) {
params.excludePaths = options.excludePaths
}
return (await this.mux.request('fs.listFiles', params)) as string[]
// Why #7721: the signal lets a workspace switch send rpc.cancel so the
// relay aborts the full-tree scan instead of stacking abandoned scans
// that starve interactive fs.readDir/fs.stat on the shared SSH channel.
return (await this.mux.request('fs.listFiles', params, {
signal: options?.signal
})) as string[]
}
async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> {
+4 -1
View File
@@ -197,7 +197,10 @@ export type IFilesystemProvider = {
copy(source: string, destination: string): Promise<void>
realpath(filePath: string): Promise<string>
search(opts: SearchOptions): Promise<SearchResult>
listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise<string[]>
listFiles(
rootPath: string,
options?: { excludePaths?: string[]; signal?: AbortSignal }
): Promise<string[]>
scanWorkspaceSpace?(
rootPath: string,
options?: { signal?: AbortSignal }
+43 -1
View File
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
@@ -17,6 +20,24 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
}
describe('resolveWindowsShellLaunchArgs', () => {
let previousUserDataPath: string | undefined
let userDataPath: string
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(userDataPath, { recursive: true, force: true })
})
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
@@ -201,6 +222,27 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
})
it('materializes shell-ready wrappers before building WSL shell args', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'C:\\Users\\alice\\code',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true)
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
// Why: the point of materializing wrappers for WSL is that a typed `omp`
// picks up Orca's status extension; pin that shim end to end.
const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
for (const wrapperFile of [bashRcfile, zshLogin]) {
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }')
}
})
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
+2
View File
@@ -6,6 +6,7 @@ import {
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
import { ensureShellReadyWrappersAt } from './local-pty-shell-ready'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
@@ -94,6 +95,7 @@ function getPowerShellEncodedCommand(startupCommand?: string): {
* Builds wsl.exe arguments that enter the target directory through the distro shell.
*/
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
ensureShellReadyWrappersAt()
const setupCommand = [
`cd ${quotePosixShell(linuxCwd)}`,
'export PATH="$HOME/.local/bin:$PATH"',
+2
View File
@@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => {
it('marks OMP status and hook env for Windows to WSL import', () => {
const env: Record<string, string> = {
ORCA_TERMINAL_HANDLE: 'term_wsl',
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
ORCA_PANE_KEY: 'tab-1:leaf-1',
ORCA_TAB_ID: 'tab-1',
@@ -36,6 +37,7 @@ describe('addOrcaWslInteropEnv', () => {
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
+2 -2
View File
@@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
const entries = parseWslenvEntries(env.WSLENV)
// Why: wsl.exe only imports selected Windows env vars. Agent status in WSL
// needs both the pane identity and the hook/OMP coordinates at process start.
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
const passthroughEntries = [
'ORCA_TERMINAL_HANDLE/u',
'ORCA_USER_DATA_PATH/p',
'ORCA_PANE_KEY/u',
'ORCA_TAB_ID/u',
'ORCA_WORKTREE_ID/u',
+624
View File
@@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks'
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -66,6 +67,7 @@ import {
buildAgentPromptPasteBytes
} from '../../shared/agent-prompt-injection'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@@ -5255,6 +5257,406 @@ describe('OrcaRuntimeService', () => {
}
})
it('keeps existing-folder imports split by runtime host on the same normalized path', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-'))
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
const first = await runtime.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
path: tempRoot,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
const second = await runtime.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-2',
path: tempRoot,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
expect(repos).toHaveLength(2)
expect(repos).toEqual([
expect.objectContaining({
path: tempRoot,
executionHostId: 'runtime:env-1'
}),
expect.objectContaining({
path: tempRoot,
executionHostId: 'runtime:env-2'
})
])
expect(first.repo).toMatchObject({
path: tempRoot,
executionHostId: 'runtime:env-1'
})
expect(first.setup).toMatchObject({
repoId: first.repo.id,
hostId: 'runtime:env-1'
})
expect(second.repo).toMatchObject({
path: tempRoot,
executionHostId: 'runtime:env-2'
})
expect(second.setup).toMatchObject({
repoId: second.repo.id,
hostId: 'runtime:env-2'
})
expect(first.repo.id).not.toBe(second.repo.id)
expect(first.setup.repoId).not.toBe(second.setup.repoId)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('keeps path-only runtime addRepo reuse working when a host-qualified repo already exists', async () => {
const repos: Record<string, unknown>[] = [
{
id: 'repo-runtime-1',
path: '/tmp/runtime-shared',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder',
executionHostId: 'runtime:env-1'
}
]
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const repo = await runtime.addRepo('/tmp/runtime-shared', 'folder')
expect(repo).toMatchObject({
id: 'repo-runtime-1',
path: '/tmp/runtime-shared',
executionHostId: 'runtime:env-1'
})
expect(repos).toHaveLength(1)
})
it('does not hijack a legacy SSH repo at the same path into a runtime host', async () => {
// A legacy SSH repo resolves to `ssh:<connectionId>` even with a null executionHostId,
// so a same-path runtime import must create a new repo instead of adopting it.
const repos: Record<string, unknown>[] = [
{
id: 'repo-ssh-1',
path: '/workspace',
displayName: 'workspace',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder',
connectionId: 'ssh-target-1'
}
]
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
}
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const repo = await runtime.addRepo('/workspace', 'folder', 'runtime:env-1')
expect(repos).toHaveLength(2)
expect(repo.id).not.toBe('repo-ssh-1')
expect(repo).toMatchObject({ path: '/workspace', executionHostId: 'runtime:env-1' })
// The legacy SSH repo must be untouched (no executionHostId stamped onto it).
expect(repos[0]).toMatchObject({ id: 'repo-ssh-1', connectionId: 'ssh-target-1' })
expect(repos[0]).not.toHaveProperty('executionHostId')
})
it('only a runtime host adopts an unstamped repo; local/ssh imports never stamp it', async () => {
// A genuine local repo is indistinguishable from a legacy runtime repo (both have null
// executionHostId and connectionId). Only a runtime host may backfill it — a local or ssh
// import at the same path must leave it untouched rather than re-attribute it to that host.
for (const importHostId of ['local', 'ssh:ssh-target-9'] as const) {
const repos: Record<string, unknown>[] = [
{
id: 'repo-local-1',
path: '/workspace',
displayName: 'workspace',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder'
}
]
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
}
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const repo = await runtime.addRepo('/workspace', 'folder', importHostId)
// The matched repo is returned unchanged — no new repo, no executionHostId stamped.
expect(repos).toHaveLength(1)
expect(repo.id).toBe('repo-local-1')
expect(repos[0]).not.toHaveProperty('executionHostId')
}
})
it('keeps project clone setup on the cloned host-qualified repo', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
proc.emit('close', 0, null)
})
return proc as never
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const result = await runtime.setupProjectClone({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
url: 'https://example.com/orca.git',
destination
})
expect(repos).toHaveLength(1)
expect(result.repo).toMatchObject({
path: clonePath,
executionHostId: 'runtime:env-1',
projectHostSetupMethod: 'cloned'
})
expect(result.setup).toMatchObject({
repoId: result.repo.id,
hostId: 'runtime:env-1',
setupMethod: 'cloned'
})
} finally {
spawnSpy.mockRestore()
await rm(destination, { recursive: true, force: true })
}
})
it('adopts public clone repos into host-qualified project setup', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
proc.emit('close', 0, null)
})
return proc as never
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const cloned = await runtime.cloneRepo('https://example.com/orca.git', destination)
expect(cloned).toMatchObject({
path: clonePath
})
expect(cloned).not.toHaveProperty('executionHostId')
const result = await runtime.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
path: clonePath,
kind: 'git',
setupMethod: 'cloned'
})
expect(repos).toHaveLength(1)
expect(result.repo).toMatchObject({
id: cloned.id,
path: clonePath,
executionHostId: 'runtime:env-1',
projectHostSetupMethod: 'cloned'
})
expect(result.setup).toMatchObject({
repoId: cloned.id,
hostId: 'runtime:env-1',
setupMethod: 'cloned'
})
} finally {
spawnSpy.mockRestore()
await rm(destination, { recursive: true, force: true })
}
})
it('keeps project clone repos split by runtime host on the same clone path', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
path: clonePath,
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
proc.emit('close', 0, null)
})
return proc as never
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const result = await runtime.setupProjectClone({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-2',
url: 'https://example.com/orca.git',
destination
})
expect(repos).toHaveLength(2)
expect(repos[0]).toMatchObject({
id: 'repo-host-a',
executionHostId: 'runtime:env-1'
})
expect(result.repo).toMatchObject({
path: clonePath,
executionHostId: 'runtime:env-2'
})
expect(result.repo.id).not.toBe('repo-host-a')
expect(result.setup).toMatchObject({
repoId: result.repo.id,
hostId: 'runtime:env-2',
setupMethod: 'cloned'
})
} finally {
spawnSpy.mockRestore()
await rm(destination, { recursive: true, force: true })
}
})
it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
const added: Record<string, unknown>[] = []
const colorStore = {
@@ -26495,4 +26897,226 @@ describe('OrcaRuntimeService', () => {
expect(preDaemonProvider.shutdown).not.toHaveBeenCalled()
})
})
describe('stale terminal handle resolution (#7718)', () => {
function syncSingleTerminalGraph(runtime: OrcaRuntimeService, ptyId: string): void {
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Terminal 1',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId,
paneTitle: 'Terminal 1'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Terminal 1',
isActive: true
}
]
}
]
})
}
// Why: session-tab mirrors travel as PTY handles, but client-created
// terminals (terminal.create) hold LEAF handles — the kind that can
// diverge from the pane's current PTY across reconnects. Issue one
// directly from the leaf record to pin the resolver semantics.
function issueLeafHandle(runtime: OrcaRuntimeService, ptyId: string): string {
const internals = runtime as unknown as {
leaves: Map<string, { ptyId: string | null }>
issueHandle: (leaf: unknown) => string
}
const leaf = Array.from(internals.leaves.values()).find(
(candidate) => candidate.ptyId === ptyId
)
if (!leaf) {
throw new Error('expected leaf record')
}
return internals.issueHandle(leaf)
}
it('errors with terminal_handle_stale instead of adopting a replacement PTY', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
syncSingleTerminalGraph(runtime, 'pty-a')
const handle = issueLeafHandle(runtime, 'pty-a')
// Simulate the pane's PTY being replaced while the remote client still
// holds the old handle (the record missed the swap, e.g. across a
// renderer graph reload that preserved live PTY records).
const internals = runtime as unknown as {
leaves: Map<string, { ptyId: string | null }>
}
for (const leaf of internals.leaves.values()) {
if (leaf.ptyId === 'pty-a') {
leaf.ptyId = 'pty-b'
}
}
// The unguarded resolver silently adopts the new PTY — the misroute.
expect(runtime.resolveLeafForHandle(handle)).toEqual({ ptyId: 'pty-b' })
// The guarded resolver surfaces the staleness so clients can re-derive.
expect(() => runtime.resolveLiveLeafForHandle(handle)).toThrow('terminal_handle_stale')
})
it('lets a handle issued before its first PTY adopt that PTY without erroring', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
syncSingleTerminalGraph(runtime, 'pty-a')
const handle = issueLeafHandle(runtime, 'pty-a')
// Simulate the mobile pre-spawn flow: the handle record predates the
// PTY (ptyId null); the pane's first PTY must still resolve.
const internals = runtime as unknown as {
handles: Map<string, { ptyId: string | null }>
}
const record = internals.handles.get(handle)
if (!record) {
throw new Error('expected handle record')
}
record.ptyId = null
expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' })
})
})
describe('mobile terminal create resilience (#7718)', () => {
it('cancels the surface wait without rolling back when the client connection dies', async () => {
vi.useFakeTimers()
try {
const closeTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal))
const abort = new AbortController()
const webContents = { send: vi.fn() }
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-abort', title: 'Terminal' }
)
})
webContents.send = send
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false,
signal: abort.signal
})
const settled = create.then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
// The client's socket dies mid-wait. The create must settle right away
// (not run down the 10s surface timeout) and must NOT close the tab —
// the renderer spawned a real terminal the host user can see.
abort.abort()
await vi.advanceTimersByTimeAsync(0)
const outcome = await settled
expect(outcome.ok).toBe(false)
if (outcome.ok === false) {
expect(outcome.error.message).toBe('client_disconnected')
}
expect(closeTerminal).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('keeps a mobile-created terminal alive when its live shell has no registered pane key', async () => {
vi.useFakeTimers()
try {
const closeTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal))
const webContents = { send: vi.fn() }
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-stall', title: 'Terminal' }
)
})
webContents.send = send
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false
})
const settled = create.then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
// A live shell backs the tab, but its leaf id is not a terminal UUID
// so no pane key registers: the surface rescue can't publish it, and
// the surface itself never publishes (stalled renderer publication).
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [
{
tabId: 'tab-stall',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-stall',
paneTitle: null
}
]
})
await vi.advanceTimersByTimeAsync(11_000)
const outcome = await settled
// The create still fails (nothing to return), but the timeout must not
// kill the live terminal — that is the "tab dies after ~10s" symptom.
expect(outcome.ok).toBe(false)
if (outcome.ok === false) {
expect(outcome.error.message).toContain('Timed out')
}
expect(closeTerminal).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
})
+165 -18
View File
@@ -126,9 +126,11 @@ import type {
WorkspaceSessionState,
DirEntry
} from '../../shared/types'
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type { SshConnectionState } from '../../shared/ssh-types'
import type {
LinearCurrentIssueContextHints,
LinearAttachResult,
@@ -1182,6 +1184,10 @@ const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024
const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024
const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024
function isClientDisconnectedError(error: unknown): boolean {
return error instanceof Error && error.message === 'client_disconnected'
}
function createTerminalRevealWarning(handle: string, error?: unknown): string {
const reason =
error instanceof Error && error.message.trim().length > 0
@@ -1414,6 +1420,22 @@ function getRuntimeFolderWorkspaceRootId(repo: Repo): string {
return `${repo.id}::${repo.path}`
}
// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime
// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo
// (resolves to ssh:<id>), so it must not be adopted/matched by a runtime host at the same path.
function runtimeRepoMatchesExecutionHost(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
executionHostId?: ExecutionHostId | null
): boolean {
if (executionHostId == null) {
return true
}
if (repo.executionHostId != null) {
return repo.executionHostId === executionHostId
}
return repo.connectionId == null
}
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
}
@@ -2789,6 +2811,12 @@ export class OrcaRuntimeService {
this.emitClientEvent({ type: 'reposChanged' })
}
// Why: SSH state changes originate in main's ssh handlers, not in runtime
// methods, so they need a public entry point onto the client-event stream.
notifySshStateChanged(targetId: string, state: SshConnectionState): void {
this.emitClientEvent({ type: 'sshStateChanged', targetId, state })
}
private notifyActivateWorktree(
repoId: string,
worktreeId: string,
@@ -6215,6 +6243,32 @@ export class OrcaRuntimeService {
return { ptyId: leaf.ptyId }
}
// Why: remote clients hold handles across transport reconnects. A handle
// minted for a concrete PTY must never silently adopt a different PTY that
// later occupies the same pane — that misroutes keystrokes (#7718). Handles
// still awaiting their first PTY (ptyId null) may adopt it, which preserves
// the mobile pre-spawn subscribe flow.
resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null {
const record = this.handles.get(handle)
if (!record) {
return null
}
if (record.tabId.startsWith('pty:')) {
return { ptyId: record.ptyId }
}
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
if (!leaf) {
return null
}
if (
record.ptyId !== null &&
(leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration)
) {
throw new Error('terminal_handle_stale')
}
return { ptyId: leaf.ptyId }
}
async resolveTerminalCwd(handle: string): Promise<string | null> {
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
if (!ptyId) {
@@ -9873,7 +9927,7 @@ export class OrcaRuntimeService {
if (!this.store) {
throw new Error('runtime_unavailable')
}
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git')
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId)
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
if (setup.projectId !== args.projectId) {
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
@@ -9912,7 +9966,7 @@ export class OrcaRuntimeService {
}
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
const repo = await this.cloneRepo(args.url, args.destination)
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
return await this.setupProjectExistingFolder({
projectId: args.projectId,
hostId: args.hostId,
@@ -10313,7 +10367,11 @@ export class OrcaRuntimeService {
})
}
async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise<Repo> {
async addRepo(
path: string,
kind: 'git' | 'folder' = 'git',
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
@@ -10326,8 +10384,29 @@ export class OrcaRuntimeService {
throw new Error(`Not a valid git repository: ${path}`)
}
const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path))
const existing = this.store.getRepos().find((repo) => {
if (!runtimePathsEqual(repo.path, path)) {
return false
}
return runtimeRepoMatchesExecutionHost(repo, executionHostId)
})
if (existing) {
// Only a runtime host backfills a legacy unstamped repo. An unstamped repo is
// indistinguishable from a genuine local repo (both have null executionHostId and
// connectionId), so we never stamp local/ssh onto it — that would re-attribute a
// real local project to the wrong host. Runtime is the only host that lost its
// identity to the pre-#7018 path-only import and needs the backfill.
if (
existing.executionHostId == null &&
parseExecutionHostId(executionHostId)?.kind === 'runtime'
) {
const adopted =
this.store.updateRepo(existing.id, { executionHostId }) ??
({ ...existing, executionHostId } as Repo)
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
return adopted
}
return existing
}
@@ -10337,6 +10416,7 @@ export class OrcaRuntimeService {
path,
displayName: getRepoName(path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...(executionHostId != null ? { executionHostId } : {}),
...detected,
addedAt: Date.now(),
kind,
@@ -10475,7 +10555,11 @@ export class OrcaRuntimeService {
return { repo: this.store.getRepo(repo.id) ?? repo }
}
async cloneRepo(url: string, destination: string): Promise<Repo> {
async cloneRepo(
url: string,
destination: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
@@ -10503,7 +10587,8 @@ export class OrcaRuntimeService {
trimmedUrl,
trimmedDestination,
clonePath,
clonePathKey
clonePathKey,
executionHostId
)
} finally {
release()
@@ -10517,14 +10602,19 @@ export class OrcaRuntimeService {
trimmedUrl: string,
trimmedDestination: string,
clonePath: string,
clonePathKey: string
clonePathKey: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const existingBeforeClone = this.store
.getRepos()
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
.find(
(repo) =>
getClonePathComparisonKey(repo.path) === clonePathKey &&
runtimeRepoMatchesExecutionHost(repo, executionHostId)
)
if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) {
return existingBeforeClone
}
@@ -10584,7 +10674,11 @@ export class OrcaRuntimeService {
const existing = this.store
.getRepos()
.find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey)
.find(
(repo) =>
getClonePathComparisonKey(repo.path) === clonePathKey &&
runtimeRepoMatchesExecutionHost(repo, executionHostId)
)
if (existing) {
if (isFolderRepo(existing)) {
const updated = this.store.updateRepo(existing.id, { kind: 'git' })
@@ -10605,6 +10699,7 @@ export class OrcaRuntimeService {
path: clonePath,
displayName: getRepoName(clonePath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...(executionHostId != null ? { executionHostId } : {}),
...detected,
addedAt: Date.now(),
kind: 'git',
@@ -16198,6 +16293,7 @@ export class OrcaRuntimeService {
launchAgent?: TuiAgent
activate?: boolean
clientMutationId?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const mutationId = opts.clientMutationId
@@ -16239,6 +16335,7 @@ export class OrcaRuntimeService {
launchAgent?: TuiAgent
activate?: boolean
clientMutationId?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
this.assertGraphReady()
@@ -16278,8 +16375,16 @@ export class OrcaRuntimeService {
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
// Why: a dead client connection cancels the wait; the renderer tab (and
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
const handler = (
event: Electron.IpcMainEvent,
@@ -16290,12 +16395,14 @@ export class OrcaRuntimeService {
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? '' })
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
@@ -16333,18 +16440,25 @@ export class OrcaRuntimeService {
// wait resolves without depending on a graph sync.
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
signal: opts.signal
})
if (this.isReadyMobileTerminalSurface(surface)) {
return surface
}
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
requireReady: true
requireReady: true,
signal: opts.signal
}).catch(() => null)
if (readySurface) {
return readySurface
}
if (opts.signal?.aborted) {
// Why: nobody is waiting for this create anymore; do not materialize
// or roll back — the renderer's own publication settles the tab.
throw new Error('client_disconnected')
}
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
if (!pendingSurface) {
throw new Error('Timed out waiting for terminal surface after creation')
@@ -16378,6 +16492,16 @@ export class OrcaRuntimeService {
return rescued
}
}
// Why: don't roll back when (a) the client connection died — the wait
// was cancelled, not the spawn — or (b) a live shell already backs the
// tab (its pane key may simply not be registered yet). Killing a real
// terminal the host user can see is the "tab dies after ~10s" bug (#7718).
if (
isClientDisconnectedError(error) ||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
) {
throw error
}
// Why: the renderer created the tab but no live PTY backs it (true PTY
// spawn/handle failure). Roll the half-created tab back via the renderer
// close path so it can't linger as a ghost in mobile snapshots, then
@@ -16597,33 +16721,44 @@ export class OrcaRuntimeService {
private waitForMobileTerminalSurface(
worktreeId: string,
parentTabId: string,
options: { timeoutMs?: number; requireReady?: boolean } = {}
options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS
const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options)
if (existing) {
return Promise.resolve(existing)
}
if (options.signal?.aborted) {
return Promise.reject(new Error('client_disconnected'))
}
return new Promise<RuntimeMobileSessionCreateTerminalResult>((resolve, reject) => {
const timer = setTimeout(() => {
const cleanup = (): void => {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onAbort)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
}
const timer = setTimeout(() => {
cleanup()
reject(new Error('Timed out waiting for terminal surface after creation'))
}, timeoutMs)
// Why: a dead client connection cancels the wait immediately instead of
// running down the timeout and triggering rollback (#7718).
const onAbort = (): void => {
cleanup()
reject(new Error('client_disconnected'))
}
options.signal?.addEventListener('abort', onAbort, { once: true })
const check = (): void => {
const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options)
if (!next) {
return
}
clearTimeout(timer)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
cleanup()
resolve(next)
}
this.graphSyncCallbacks.push(check)
@@ -16713,6 +16848,18 @@ export class OrcaRuntimeService {
return null
}
// Why: rollback guard, looser than findLiveRegisteredPtyForRendererTab — a
// shell whose pane key hasn't registered yet can't be surface-rescued, but
// it is still a real terminal the create timeout must not kill (#7718).
private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean {
for (const pty of this.ptysById.values()) {
if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) {
return true
}
}
return false
}
private isReadyMobileTerminalSurface(
surface: RuntimeMobileSessionCreateTerminalResult | null
): boolean {
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_METHODS } from './ai-vault'
const mocks = vi.hoisted(() => ({
scanAiVaultSessions: vi.fn()
}))
vi.mock('../../../ai-vault/session-scanner', () => ({
scanAiVaultSessions: mocks.scanAiVaultSessions
}))
beforeEach(() => {
vi.clearAllMocks()
mocks.scanAiVaultSessions.mockResolvedValue({
sessions: [],
issues: [],
scannedAt: '2026-07-04T00:00:00.000Z'
})
})
describe('aiVault.listSessions runtime RPC', () => {
it('scans server-local sessions under the caller supplied runtime host id', async () => {
const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions')
expect(method).toBeDefined()
const params = method!.params!.parse({
limit: 25,
scopePaths: ['/srv/app'],
executionHostId: 'runtime:remote-server'
})
await method!.handler(params, {} as never)
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({
limit: 25,
scopePaths: ['/srv/app'],
executionHostId: 'runtime:remote-server'
})
})
it('rejects non-runtime execution host ids before dispatch', () => {
const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions')
expect(method).toBeDefined()
expect(() =>
method!.params!.parse({
executionHostId: 'not-a-runtime-host'
})
).toThrow('Invalid runtime execution host id')
expect(() =>
method!.params!.parse({
executionHostId: 'local'
})
).toThrow('Invalid runtime execution host id')
expect(() =>
method!.params!.parse({
executionHostId: 'ssh:dev-box'
})
).toThrow('Invalid runtime execution host id')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { scanAiVaultSessions } from '../../../ai-vault/session-scanner'
import { defineMethod, type RpcMethod } from '../core'
import { parseExecutionHostId } from '../../../../shared/execution-host'
const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => {
const parsed = parseExecutionHostId(value)
if (parsed?.kind === 'runtime') {
return parsed.id
}
ctx.addIssue({
code: 'custom',
message: 'Invalid runtime execution host id'
})
return z.NEVER
})
const listSessionsParamsSchema = z.object({
limit: z.number().int().positive().optional(),
force: z.boolean().optional(),
scopePaths: z.array(z.string()).optional(),
executionHostId: executionHostIdSchema
})
export const AI_VAULT_METHODS: RpcMethod[] = [
defineMethod({
name: 'aiVault.listSessions',
params: listSessionsParamsSchema,
handler: async (params) =>
scanAiVaultSessions({
limit: params.limit,
scopePaths: params.scopePaths,
executionHostId: params.executionHostId
})
})
]
+2
View File
@@ -1,5 +1,6 @@
import type { RpcAnyMethod } from '../core'
import { STATUS_METHODS } from './status'
import { AI_VAULT_METHODS } from './ai-vault'
import { AUTOMATION_METHODS } from './automations'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
@@ -39,6 +40,7 @@ import { EMULATOR_METHODS } from './emulator'
// auditing the security boundary or wiring new CLI commands.
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATUS_METHODS,
...AI_VAULT_METHODS,
...AUTOMATION_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,
+5 -2
View File
@@ -41,7 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.createTerminal',
params: CreateTerminalTab,
handler: async (params, { runtime }) =>
handler: async (params, { runtime, signal }) =>
runtime.createMobileSessionTerminal(params.worktree, {
afterTabId: params.afterTabId,
targetGroupId: params.targetGroupId,
@@ -54,7 +54,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
activate: params.activate,
clientMutationId: params.clientMutationId
clientMutationId: params.clientMutationId,
// Why: a dead client connection must cancel the surface wait instead
// of running down the timeout and rolling back a live tab (#7718).
signal
})
}),
defineMethod({
+34 -3
View File
@@ -4,14 +4,23 @@ import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SSH_METHODS } from './ssh'
const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({
const {
connectRegisteredSshTargetMock,
getRegisteredSshStateMock,
listRegisteredSshTargetsMock,
listRegisteredRemovedSshTargetLabelsMock
} = vi.hoisted(() => ({
connectRegisteredSshTargetMock: vi.fn(),
getRegisteredSshStateMock: vi.fn()
getRegisteredSshStateMock: vi.fn(),
listRegisteredSshTargetsMock: vi.fn(),
listRegisteredRemovedSshTargetLabelsMock: vi.fn()
}))
vi.mock('../../../ipc/ssh', () => ({
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
getRegisteredSshState: getRegisteredSshStateMock
getRegisteredSshState: getRegisteredSshStateMock,
listRegisteredSshTargets: listRegisteredSshTargetsMock,
listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock
}))
function makeRequest(method: string, params?: unknown): RpcRequest {
@@ -62,4 +71,26 @@ describe('ssh RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { state: null } })
})
it('lists the registered SSH targets for paired clients', async () => {
const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }]
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listTargets'))
expect(response).toMatchObject({ ok: true, result: { targets } })
})
it('lists removed-target labels for ghost-host display on paired clients', async () => {
const labels = { 'ssh-old': 'Dev box' }
listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels'))
expect(response).toMatchObject({ ok: true, result: { labels } })
})
})
+16 -1
View File
@@ -1,5 +1,10 @@
import { z } from 'zod'
import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh'
import {
connectRegisteredSshTarget,
getRegisteredSshState,
listRegisteredRemovedSshTargetLabels,
listRegisteredSshTargets
} from '../../../ipc/ssh'
import { defineMethod, type RpcMethod } from '../core'
const SshTarget = z.object({
@@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [
name: 'ssh.connect',
params: SshTarget,
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
}),
defineMethod({
name: 'ssh.listTargets',
params: null,
handler: () => ({ targets: listRegisteredSshTargets() })
}),
defineMethod({
name: 'ssh.listRemovedTargetLabels',
params: null,
handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() })
})
]
+16 -2
View File
@@ -904,7 +904,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalSend,
handler: async (params, { runtime }) => {
await assertTerminalSendTextWithinLimit(params.text)
const leaf = runtime.resolveLeafForHandle(params.terminal)
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale (clients recover by re-deriving the handle)
// instead of evaluating driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
@@ -1436,8 +1439,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const request = parsed.data
detachStream(request.streamId, false)
let leaf = runtime.resolveLeafForHandle(request.terminal)
const isMobile = request.client?.type === 'mobile'
let leaf: { ptyId: string | null } | null
try {
// Why: guarded resolution — binding the output stream to whatever
// PTY now occupies a stale handle's pane silently mirrors the wrong
// terminal after a reconnect (#7718). terminal_handle_stale lets the
// client re-derive the handle from the current session snapshot.
leaf = runtime.resolveLiveLeafForHandle(request.terminal)
} catch {
sendStreamError(request.streamId, 'terminal_handle_stale')
emit({ type: 'end', streamId: request.streamId })
return
}
if (!leaf?.ptyId && isMobile) {
try {
const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal)
@@ -41,7 +41,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'user@host:~$ ',
+83 -11
View File
@@ -41,7 +41,7 @@ describe('terminal multiplex RPC', () => {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'snapshot',
@@ -258,7 +258,7 @@ describe('terminal multiplex RPC', () => {
| undefined
const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
@@ -371,7 +371,7 @@ describe('terminal multiplex RPC', () => {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'snapshot',
@@ -477,7 +477,7 @@ describe('terminal multiplex RPC', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi
.fn()
.mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }),
@@ -573,7 +573,7 @@ describe('terminal multiplex RPC', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
@@ -681,7 +681,7 @@ describe('terminal multiplex RPC', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
@@ -781,7 +781,7 @@ describe('terminal multiplex RPC', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
@@ -1305,7 +1305,7 @@ describe('terminal multiplex RPC', () => {
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
waitForLeafPtyId: vi.fn(
(_handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
new Promise<string>((_resolve, reject) => {
@@ -1372,6 +1372,78 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => {
// Why: after a reconnect a client can resubscribe with a handle whose
// pane now hosts a different PTY. Binding the stream anyway would mirror
// (and type into) the wrong terminal (#7718); the client recovers from
// terminal_handle_stale by re-deriving the handle from the next snapshot.
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(() => {
throw new Error('terminal_handle_stale')
}),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-stale-handle',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 9,
terminal: 'stale-terminal',
client: { id: 'desktop-1', type: 'desktop' }
})
})
)!
)
await vi.waitFor(() =>
expect(
messages
.map((msg) => JSON.parse(msg).result)
.some((result) => result?.type === 'end' && result.streamId === 9)
).toBe(true)
)
const errorFrame = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.Error)
expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('terminal_handle_stale')
// The stream must never have bound to any PTY.
expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled()
cleanups.get('terminal-multiplex:conn-stale-handle')?.()
await dispatchPromise
})
it('bounds live output queued while a multiplex snapshot is loading', async () => {
vi.useFakeTimers()
try {
@@ -1385,7 +1457,7 @@ describe('terminal multiplex RPC', () => {
const dataListenerRef: { current?: (data: string) => void } = {}
const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
@@ -1494,7 +1566,7 @@ describe('terminal multiplex RPC', () => {
const dataListenerRef: { current?: (data: string) => void } = {}
const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
@@ -1607,7 +1679,7 @@ describe('terminal multiplex RPC', () => {
rows: number
}) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
+32 -7
View File
@@ -75,9 +75,34 @@ describe('terminal send RPC', () => {
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1')
})
it('fails terminal.send with terminal_handle_stale for a stale handle instead of probing the wrong PTY', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(() => {
throw new Error('terminal_handle_stale')
}),
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'stale-terminal',
text: 'x',
client: { id: 'desktop-1', type: 'desktop' }
})
)
expect(response.ok).toBe(false)
if (response.ok) {
throw new Error('expected stale handle error')
}
expect(response.error.message).toContain('terminal_handle_stale')
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('drops desktop input while a mobile client owns the terminal floor', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }),
sendTerminal: vi.fn(),
mobileTookFloor: vi.fn()
@@ -109,7 +134,7 @@ describe('terminal send RPC', () => {
it('accepts legacy clientless mobile input when the current driver is mobile', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
@@ -198,7 +223,7 @@ describe('terminal send RPC', () => {
vi.useFakeTimers()
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
@@ -236,7 +261,7 @@ describe('terminal send RPC', () => {
it('refuses guarded terminal sends when the agent needs permission', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus: vi.fn().mockResolvedValue({
handle: 'terminal-1',
@@ -274,7 +299,7 @@ describe('terminal send RPC', () => {
it('allows guarded terminal sends when the agent is sendable', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus: vi.fn().mockResolvedValue({
handle: 'terminal-1',
@@ -316,7 +341,7 @@ describe('terminal send RPC', () => {
it('refuses guarded combined text and submit sends before any PTY write', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus: vi.fn(),
sendTerminal: vi.fn()
@@ -350,7 +375,7 @@ describe('terminal send RPC', () => {
it('rechecks the mobile floor lock immediately before guarded PTY writes', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi
.fn()
.mockReturnValueOnce({ kind: 'desktop' })
+2
View File
@@ -308,6 +308,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'settings.update',
'ssh.connect',
'ssh.getState',
'ssh.listRemovedTargetLabels',
'ssh.listTargets',
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',
@@ -307,6 +307,81 @@ describe('SshChannelMultiplexer', () => {
})
})
describe('wake guard (timer pause across system sleep, #7773)', () => {
it('does not kill a healthy link on the first tick after a long timer pause', () => {
// Reach steady state with pending unacked keepalives (<5s old at pause).
vi.advanceTimersByTime(5_000)
expect(mux.isDisposed()).toBe(false)
// Simulate sleep/App Nap: wall clock jumps far ahead with no ticks.
// Without the guard, the first post-wake tick sees lastReceivedAt and
// the pre-pause keepalive both >20s stale and disposes the mux.
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
const writesBefore = transport.written.length
vi.advanceTimersByTime(5_000)
expect(mux.isDisposed()).toBe(false)
// The guard probes immediately with a fresh keepalive.
expect(transport.written.length).toBeGreaterThan(writesBefore)
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
})
it('keeps the link alive after wake when frames resume', () => {
vi.advanceTimersByTime(5_000)
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
vi.advanceTimersByTime(5_000) // guard tick
// The relay answers the post-wake probe; the link must stay up.
let seq = 1
for (let i = 0; i < 8; i++) {
vi.advanceTimersByTime(5_000)
transport.dataCallbacks[0](encodeKeepAliveFrame(seq++, 0))
}
expect(mux.isDisposed()).toBe(false)
})
it('still detects a genuinely dead link within the next window after wake', () => {
vi.advanceTimersByTime(5_000)
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
vi.advanceTimersByTime(5_000) // guard tick: reset + probe, no kill
expect(mux.isDisposed()).toBe(false)
// No frames arrive after the guard reset; the honest window expires.
vi.advanceTimersByTime(25_000)
expect(mux.isDisposed()).toBe(true)
})
})
describe('probeLiveness', () => {
it('sends a keepalive and resolves true when any frame arrives', async () => {
const writesBefore = transport.written.length
const probe = mux.probeLiveness(5_000)
expect(transport.written.length).toBe(writesBefore + 1)
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
transport.dataCallbacks[0](encodeKeepAliveFrame(1, 0))
await expect(probe).resolves.toBe(true)
})
it('resolves false when no frame arrives before the timeout', async () => {
const probe = mux.probeLiveness(5_000)
vi.advanceTimersByTime(5_000)
await expect(probe).resolves.toBe(false)
})
it('resolves false when the mux is disposed while probing', async () => {
const probe = mux.probeLiveness(5_000)
mux.dispose()
await expect(probe).resolves.toBe(false)
})
it('resolves false immediately on a disposed mux', async () => {
mux.dispose()
await expect(mux.probeLiveness(5_000)).resolves.toBe(false)
})
})
describe('dispose', () => {
it('rejects all pending requests on dispose', async () => {
const promise = mux.request('pty.spawn')
+58
View File
@@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record<string, unknown>) => voi
export type RequestHandler = (params: Record<string, unknown>) => Promise<unknown> | unknown
const REQUEST_TIMEOUT_MS = 30_000
// Why: a tick gap far beyond the interval means the process was paused
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
export class SshChannelMultiplexer {
private decoder: FrameDecoder
@@ -58,6 +61,10 @@ export class SshChannelMultiplexer {
// Track the oldest unacked outgoing message timestamp
private unackedTimestamps = new Map<number, number>()
// Why: liveness probes (#7773) resolve on the first frame of any kind —
// a keepalive ack proves the relay round-trip without a full RPC.
private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = []
constructor(transport: MultiplexerTransport) {
this.transport = transport
@@ -232,6 +239,31 @@ export class SshChannelMultiplexer {
this.sendMessage(msg)
}
/**
* Send a fresh keepalive and resolve true when any frame arrives before the
* timeout. Used on system resume to distinguish a link that survived sleep
* from a dead one before tearing the session down (#7773).
*/
probeLiveness(timeoutMs: number): Promise<boolean> {
if (this.disposed) {
return Promise.resolve(false)
}
return new Promise<boolean>((resolve) => {
const settle = (alive: boolean): void => {
clearTimeout(timer)
const idx = this.livenessProbeWaiters.indexOf(waiter)
if (idx !== -1) {
this.livenessProbeWaiters.splice(idx, 1)
}
resolve(alive)
}
const waiter = { succeed: () => settle(true), fail: () => settle(false) }
const timer = setTimeout(() => settle(false), timeoutMs)
this.livenessProbeWaiters.push(waiter)
this.sendKeepAlive()
})
}
dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void {
if (this.disposed) {
return
@@ -259,6 +291,10 @@ export class SshChannelMultiplexer {
reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed'
const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.fail()
}
for (const [id, pending] of this.pendingRequests) {
pending.cleanup()
const err = new Error(errorMessage) as Error & { code: string }
@@ -322,6 +358,12 @@ export class SshChannelMultiplexer {
}
private handleFrame(frame: DecodedFrame): void {
// Why: any decoded frame proves the relay round-trip is alive; resolve
// pending resume probes before ordinary dispatch (#7773).
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.succeed()
}
// Update ack tracking
if (frame.id > this.highestReceivedSeq) {
this.highestReceivedSeq = frame.id
@@ -453,12 +495,28 @@ export class SshChannelMultiplexer {
}
private startTimeoutCheck(): void {
let lastTickAt = Date.now()
this.timeoutTimer = setInterval(() => {
if (this.disposed) {
return
}
const now = Date.now()
const sinceLastTick = now - lastTickAt
lastTickAt = now
// Why: after sleep/App Nap the pre-pause keepalive looks stale on the
// first post-wake tick, killing a healthy link (#7773). Reset staleness
// tracking, probe with a fresh keepalive, and let the NEXT full window
// make an honest liveness determination.
if (sinceLastTick > WAKE_GAP_MS) {
this.lastReceivedAt = now
for (const seq of this.unackedTimestamps.keys()) {
this.unackedTimestamps.set(seq, now)
}
this.sendKeepAlive()
return
}
const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS
// Check oldest unacked message
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PortScanner,
SSH_PORT_SCAN_BASE_INTERVAL_MS,
SSH_PORT_SCAN_MAX_INTERVAL_MS,
type PortScannerWindowVisibility
} from './ssh-port-scanner'
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import type { DetectedPort } from '../../shared/ssh-types'
type VisibilityHarness = {
visibility: PortScannerWindowVisibility
setVisible: (visible: boolean) => void
listenerCount: () => number
}
function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness {
let visible = initiallyVisible
const listeners = new Set<() => void>()
return {
visibility: {
isWindowVisible: () => visible,
onWindowBecameVisible: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
},
setVisible: (next) => {
const wasVisible = visible
visible = next
if (!wasVisible && next) {
for (const listener of Array.from(listeners)) {
listener()
}
}
},
listenerCount: () => listeners.size
}
}
function port(portNumber: number): DetectedPort {
return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' }
}
function createMux(ports: () => DetectedPort[]): {
mux: SshChannelMultiplexer
request: ReturnType<typeof vi.fn>
} {
const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' }))
return { mux: { request } as unknown as SshChannelMultiplexer, request }
}
const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS
const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS
describe('PortScanner', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => {
const harness = createVisibilityHarness(false)
const { mux, request } = createMux(() => [port(3000)])
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, vi.fn())
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(request).not.toHaveBeenCalled()
harness.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(1)
scanner.dispose()
})
it('polls at the base cadence while visible when results keep changing', async () => {
const harness = createVisibilityHarness(true)
let next = 3000
const { mux, request } = createMux(() => [port(next++)])
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(1)
// 5 simulated base intervals -> 5 more scans (no backoff while changing).
await vi.advanceTimersByTimeAsync(5 * BASE)
expect(request).toHaveBeenCalledTimes(6)
scanner.dispose()
})
it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => {
const harness = createVisibilityHarness(true)
let ports = [port(3000)]
const { mux, request } = createMux(() => ports)
const onChanged = vi.fn()
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, onChanged)
// t=0: first scan is a change (empty -> {3000}) and stays at base.
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(1)
expect(onChanged).toHaveBeenCalledTimes(1)
// t=12s: unchanged -> next wait doubles to 24s.
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(2)
// t=24s: nothing (waiting until t=36s).
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(2)
// t=36s: unchanged -> next wait caps at 30s (not 48s).
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(3)
// t=65.999s: still waiting.
await vi.advanceTimersByTimeAsync(MAX - 1)
expect(request).toHaveBeenCalledTimes(3)
// t=66s: unchanged -> next wait remains capped at 30s.
await vi.advanceTimersByTimeAsync(1)
expect(request).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(MAX - 1)
expect(request).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(1)
expect(request).toHaveBeenCalledTimes(5)
expect(onChanged).toHaveBeenCalledTimes(1)
// A changed result resets the cadence back to base.
ports = [port(3000), port(4000)]
await vi.advanceTimersByTimeAsync(MAX)
expect(request).toHaveBeenCalledTimes(6)
expect(onChanged).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(7)
expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000])
scanner.dispose()
})
it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => {
const harness = createVisibilityHarness(true)
const { mux, request } = createMux(() => [port(3000)])
const scanner = new PortScanner(harness.visibility)
const onChanged = vi.fn()
scanner.startScanning('t1', mux, onChanged)
// Back off: scans at t=0, t=12s, t=36s -> interval now 48s.
await vi.advanceTimersByTimeAsync(3 * BASE)
expect(request).toHaveBeenCalledTimes(3)
// Reconnect paths call startScanning again: immediate scan, base cadence.
scanner.startScanning('t1', mux, onChanged)
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(5)
scanner.dispose()
})
it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => {
const harness = createVisibilityHarness(true)
let next = 3000
const { mux, request } = createMux(() => [port(next++)])
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, vi.fn())
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(2)
harness.setVisible(false)
await vi.advanceTimersByTimeAsync(30 * 60_000)
expect(request).toHaveBeenCalledTimes(2)
harness.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(4)
scanner.dispose()
})
it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => {
const harness = createVisibilityHarness(true)
let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null
const request = vi.fn(
() =>
new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => {
resolveFirst = resolve
})
)
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn())
expect(request).toHaveBeenCalledTimes(1)
// The chain waits for the in-flight request instead of stacking more.
await vi.advanceTimersByTimeAsync(10 * BASE)
expect(request).toHaveBeenCalledTimes(1)
resolveFirst!({ ports: [port(3000)], platform: 'linux' })
await vi.advanceTimersByTimeAsync(BASE)
expect(request).toHaveBeenCalledTimes(2)
scanner.dispose()
})
it('stopScanning halts polling and detaches the visibility listener', async () => {
const harness = createVisibilityHarness(true)
const { mux, request } = createMux(() => [port(3000)])
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(request).toHaveBeenCalledTimes(1)
expect(harness.listenerCount()).toBe(1)
scanner.stopScanning('t1')
expect(harness.listenerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(10 * 60_000)
harness.setVisible(false)
harness.setVisible(true)
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(request).toHaveBeenCalledTimes(1)
expect(scanner.getDetectedPorts('t1')).toEqual([])
})
it('keeps targets independent: stopping one host leaves the other scanning', async () => {
const harness = createVisibilityHarness(true)
let nextA = 3000
let nextB = 4000
const a = createMux(() => [port(nextA++)])
const b = createMux(() => [port(nextB++)])
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('host-a', a.mux, vi.fn())
scanner.startScanning('host-b', b.mux, vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(a.request).toHaveBeenCalledTimes(1)
expect(b.request).toHaveBeenCalledTimes(1)
scanner.stopScanning('host-a')
await vi.advanceTimersByTimeAsync(BASE)
expect(a.request).toHaveBeenCalledTimes(1)
expect(b.request).toHaveBeenCalledTimes(2)
scanner.dispose()
await vi.advanceTimersByTimeAsync(10 * BASE)
expect(b.request).toHaveBeenCalledTimes(2)
expect(harness.listenerCount()).toBe(0)
})
})
+65 -11
View File
@@ -1,10 +1,30 @@
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import type { DetectedPort } from '../../shared/ssh-types'
const POLL_INTERVAL_MS = 3_000
// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote
// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the
// old 3s while keeping new-port detection well under the 30s workspace-scanner
// cadence users already accept.
export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000
// Why: idle backoff cap — an unchanged port set doubles the interval up to
// this bound, so a quiet remote costs two scans per minute instead of twenty
// without exceeding the existing workspace-port scanner's visible cadence.
export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000
export type PortScannerWindowVisibility = {
isWindowVisible: () => boolean
// Fires when a hidden/minimized window is shown again; returns unsubscribe.
onWindowBecameVisible: (listener: () => void) => () => void
}
type ScanHandle = {
timer: ReturnType<typeof setInterval>
timer: ReturnType<typeof setTimeout> | null
intervalMs: number
// Why: while the window is hidden the scan chain is parked outright — no
// timer wakeups, no remote requests — and the visibility listener resumes
// it with an immediate scan so ports opened while hidden surface at once.
parkedWhileHidden: boolean
unsubscribeVisibility: () => void
// Why: keyed by "host:port" (not just port) so that host-distinct listeners
// on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately.
previousPorts: Map<string, DetectedPort>
@@ -17,6 +37,8 @@ type ScanHandle = {
export class PortScanner {
private handles = new Map<string, ScanHandle>()
constructor(private visibility: PortScannerWindowVisibility) {}
startScanning(
targetId: string,
mux: SshChannelMultiplexer,
@@ -25,14 +47,18 @@ export class PortScanner {
this.stopScanning(targetId)
const handle: ScanHandle = {
timer: null!,
timer: null,
intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS,
parkedWhileHidden: false,
unsubscribeVisibility: () => {},
previousPorts: new Map(),
initialPorts: null
}
const isCurrent = (): boolean => this.handles.get(targetId) === handle
// Why: guard against overlapping scans. On slow remotes, /proc/*/fd walks
// can take longer than POLL_INTERVAL_MS. Without this guard, setInterval
// would stack up concurrent requests on the shared SSH multiplexer.
// Why: guard against overlapping scans. The timer chain only reschedules
// after a poll completes, but the visibility-resume path can race a poll
// still in flight on a slow remote.
let polling = false
const poll = async (): Promise<void> => {
if (polling) {
@@ -45,7 +71,7 @@ export class PortScanner {
platform: string
}
if (!this.handles.has(targetId)) {
if (!isCurrent()) {
return
}
@@ -60,7 +86,10 @@ export class PortScanner {
if (!portsEqual(handle.previousPorts, currentPorts)) {
handle.previousPorts = currentPorts
handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS
onChanged(targetId, result.ports, result.platform)
} else {
handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS)
}
} catch {
// Relay disconnected or request timed out — retry on next interval
@@ -69,10 +98,32 @@ export class PortScanner {
}
}
handle.timer = setInterval(() => void poll(), POLL_INTERVAL_MS)
this.handles.set(targetId, handle)
const tick = async (): Promise<void> => {
handle.timer = null
if (!isCurrent()) {
return
}
if (!this.visibility.isWindowVisible()) {
handle.parkedWhileHidden = true
return
}
await poll()
if (!isCurrent()) {
return
}
handle.timer = setTimeout(() => void tick(), handle.intervalMs)
}
void poll()
handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => {
if (!isCurrent() || !handle.parkedWhileHidden) {
return
}
handle.parkedWhileHidden = false
void tick()
})
this.handles.set(targetId, handle)
void tick()
}
getDetectedPorts(targetId: string): DetectedPort[] {
@@ -88,7 +139,10 @@ export class PortScanner {
if (!handle) {
return
}
clearInterval(handle.timer)
if (handle.timer) {
clearTimeout(handle.timer)
}
handle.unsubscribeVisibility()
this.handles.delete(targetId)
}
+4
View File
@@ -118,6 +118,10 @@ function createMockDeps() {
} as unknown as SshPortForwardManager
const mockWindow = {
isDestroyed: () => false,
// Why: the port scanner visibility-gates its ticks; a visible mock window
// keeps establish-path tests exercising the scan-on-ready behavior.
isVisible: () => true,
isMinimized: () => false,
webContents: { send: vi.fn() }
}
const getMainWindow = vi.fn().mockReturnValue(mockWindow)
+8 -1
View File
@@ -52,6 +52,7 @@ import {
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
import { PortScanner } from './ssh-port-scanner'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type { SshPortForwardManager } from './ssh-port-forward'
import type { SshConnection } from './ssh-connection'
import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
@@ -982,7 +983,13 @@ export class SshRelaySession {
if (!this.mux || this.isDisposed()) {
return
}
const scanner = new PortScanner()
// Why: each scan walks /proc/*/fd on the remote host, so the scanner skips
// ticks entirely while no window can show the results (hidden to tray or
// minimized overnight) and rescans immediately when the window returns.
const scanner = new PortScanner({
isWindowVisible: () => isMainWindowVisible(this.getMainWindow()),
onWindowBecameVisible: onMainWindowBecameVisible
})
this.portScanner = scanner
// Why: capture the scanner instance so that a late ports.detect callback
// from a previous relay session (before reconnect replaced it) is silently
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types'
import {
migrateUiHostScopeSshTargetId,
migrateWorkspaceSessionSshTargetId
} from './ssh-target-id-migration'
const OLD_ID = 'ssh-1783337351840-ohabf0'
const NEW_ID = 'ssh-1783400000000-fresh1'
const makeTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({
id: 'tab1',
ptyId: null,
worktreeId: 'r1::/wt',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
...overrides
})
const makeSession = (overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState => ({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
...overrides
})
describe('migrateWorkspaceSessionSshTargetId', () => {
it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => {
const session = makeSession({
tabsByWorktree: {
'r1::/wt': [
makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }),
makeTab({ id: 'tab2', ptyId: 'local-pty-7' }),
makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` })
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` }
}
},
remoteSessionIdsByTabId: {
tab1: `ssh:${OLD_ID}@@pty-3`,
tab3: `ssh:other-target@@pty-1`
}
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
const tabs = session.tabsByWorktree['r1::/wt']
expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`)
// Local and other-target pty ids must be untouched.
expect(tabs[1].ptyId).toBe('local-pty-7')
expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`)
expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
'leaf-1': `ssh:${NEW_ID}@@pty-3`
})
expect(session.remoteSessionIdsByTabId).toEqual({
tab1: `ssh:${NEW_ID}@@pty-3`,
tab3: `ssh:other-target@@pty-1`
})
})
it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => {
const session = makeSession({
activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated']
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated'])
})
it('re-points sleeping agent records pinned to the old connection', () => {
const session = makeSession({
sleepingAgentSessionsByPaneKey: {
'tab1:leaf-1': {
paneKey: 'tab1:leaf-1',
worktreeId: 'r1::/wt',
agent: 'claude',
providerSession: { key: 'session_id', id: 's-1' },
prompt: 'p',
state: 'done',
capturedAt: 1,
updatedAt: 1,
connectionId: OLD_ID
}
}
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID)
})
it('returns false when nothing references the old id', () => {
const session = makeSession({
tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] },
activeConnectionIdsAtShutdown: ['ssh-unrelated']
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false)
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated'])
})
})
describe('migrateUiHostScopeSshTargetId', () => {
const makeUi = (overrides: Partial<PersistedUIState>): PersistedUIState =>
({ ...overrides }) as PersistedUIState
it('re-points scope, visible hosts, and host order, deduping collisions', () => {
const ui = makeUi({
workspaceHostScope: `ssh:${OLD_ID}`,
visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`],
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local']
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`)
expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`])
expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local'])
})
it('returns false when the old host id appears nowhere', () => {
const ui = makeUi({
workspaceHostScope: 'all',
visibleWorkspaceHostIds: ['local'],
workspaceHostOrder: ['local']
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)
})
})
+116
View File
@@ -0,0 +1,116 @@
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
import { toSshExecutionHostId } from '../../shared/execution-host'
/**
* Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts).
*
* reassignSshTargetId re-points repos/worktree metas, but the removed target's
* id is also embedded in other persisted state: app-scoped SSH pty ids
* ("ssh:<targetId>@@pty-N") inside the workspace session, the startup
* reconnect list, sleeping-agent resume records, and the sidebar host-scope
* arrays. Any survivor resurfaces later as `SSH target "<old>" not found` at
* connect/reattach time (STA-1468), so every carrier must migrate together.
*
* All helpers mutate in place (matching how the Store edits this.state) and
* return whether anything changed so callers can gate scheduleSave.
*/
function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null {
const parsed = parseAppSshPtyId(ptyId)
if (!parsed || parsed.connectionId !== oldTargetId) {
return null
}
return toAppSshPtyId(newTargetId, parsed.relayPtyId)
}
function rewriteSshPtyIdRecordValues(
record: Record<string, string> | undefined,
oldTargetId: string,
newTargetId: string
): boolean {
if (!record) {
return false
}
let changed = false
for (const [key, ptyId] of Object.entries(record)) {
const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId)
if (next) {
record[key] = next
changed = true
}
}
return changed
}
/** Re-point every old-target-id carrier inside one workspace session partition. */
export function migrateWorkspaceSessionSshTargetId(
session: WorkspaceSessionState,
oldTargetId: string,
newTargetId: string
): boolean {
let changed = false
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (!tab.ptyId) {
continue
}
const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId)
if (next) {
tab.ptyId = next
changed = true
}
}
}
for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) {
if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) {
changed = true
}
}
if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) {
changed = true
}
if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) {
session.activeConnectionIdsAtShutdown = [
...new Set(
session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id))
)
]
changed = true
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
if (record.connectionId === oldTargetId) {
record.connectionId = newTargetId
changed = true
}
}
return changed
}
/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */
export function migrateUiHostScopeSshTargetId(
ui: PersistedUIState,
oldTargetId: string,
newTargetId: string
): boolean {
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
let changed = false
if (ui.workspaceHostScope === oldHostId) {
ui.workspaceHostScope = newHostId
changed = true
}
if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) {
ui.visibleWorkspaceHostIds = [
...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id)))
]
changed = true
}
if (ui.workspaceHostOrder?.includes(oldHostId)) {
ui.workspaceHostOrder = [
...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id)))
]
changed = true
}
return changed
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { isMainWindowVisible } from './main-window-visibility'
describe('main window visibility helpers', () => {
it('treats a minimal alive window double as visible', () => {
expect(isMainWindowVisible({ isDestroyed: () => false })).toBe(true)
})
it('parks work when the real window is hidden, minimized, destroyed, or missing', () => {
expect(
isMainWindowVisible({
isDestroyed: () => false,
isVisible: () => false,
isMinimized: () => false
})
).toBe(false)
expect(
isMainWindowVisible({
isDestroyed: () => false,
isVisible: () => true,
isMinimized: () => true
})
).toBe(false)
expect(isMainWindowVisible({ isDestroyed: () => true })).toBe(false)
expect(isMainWindowVisible(null)).toBe(false)
})
})
+38
View File
@@ -0,0 +1,38 @@
// Why: BrowserWindow instances are recreated on macOS dock re-activation, so
// long-lived main-process services (e.g. the SSH port scanner) subscribe to
// this process-global signal instead of a specific window instance; index.ts
// re-wires each new window's show/restore events into notifyMainWindowBecameVisible.
type MainWindowBecameVisibleListener = () => void
type MainWindowVisibilityState = {
isDestroyed: () => boolean
isVisible?: () => boolean
isMinimized?: () => boolean
}
const listeners = new Set<MainWindowBecameVisibleListener>()
export function notifyMainWindowBecameVisible(): void {
for (const listener of Array.from(listeners)) {
listener()
}
}
export function onMainWindowBecameVisible(listener: MainWindowBecameVisibleListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function isMainWindowVisible(window: MainWindowVisibilityState | null): boolean {
if (window === null || window.isDestroyed()) {
return false
}
// Why: production BrowserWindow exposes both APIs, but older main-process
// tests use minimal window doubles that should stay visible by default.
const isVisible = window.isVisible?.() ?? true
const isMinimized = window.isMinimized?.() ?? false
return isVisible && !isMinimized
}
+2
View File
@@ -2247,7 +2247,9 @@ export type PreloadApi = {
rootPath: string
connectionId?: string
excludePaths?: string[]
requestToken?: string
}) => Promise<string[]>
cancelListFiles: (args: { requestToken: string }) => Promise<void>
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
importExternalPaths: (args: {
sourcePaths: string[]
+3
View File
@@ -2648,7 +2648,10 @@ const api = {
rootPath: string
connectionId?: string
excludePaths?: string[]
requestToken?: string
}): Promise<string[]> => ipcRenderer.invoke('fs:listFiles', args),
cancelListFiles: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('fs:cancelListFiles', args),
search: (args: {
query: string
rootPath: string
+24 -5
View File
@@ -7,6 +7,7 @@
* a git-focused app.
*/
import { spawn } from 'node:child_process'
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
import type { SearchOptions, SearchResult } from './fs-handler-utils'
import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter'
import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk'
@@ -31,8 +32,13 @@ import { buildRelayCommandEnv } from './relay-command-env'
*/
export function listFilesWithGit(
rootPath: string,
excludePathPrefixes: readonly string[] = []
excludePathPrefixes: readonly string[] = [],
options: { signal?: AbortSignal } = {}
): Promise<string[]> {
const { signal } = options
if (signal?.aborted) {
return Promise.reject(fileListingCancellationError(signal))
}
const gitPaths = new Set<string>()
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
const children: {
@@ -145,7 +151,7 @@ export function listFilesWithGit(
})
}
const killSurvivors = (): void => {
const killSurvivors = (reason: string): void => {
// Why: Promise.all returns after the first failed pass, but the sibling
// git process can keep streaming on SSH unless we cancel it explicitly.
for (const entry of children) {
@@ -155,22 +161,35 @@ export function listFilesWithGit(
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
entry.reject(new Error('git ls-files canceled after sibling failure'))
entry.reject(new Error(reason))
}
}
// Why: a cancelled scan (workspace switch, superseded request) must stop
// its git children right away instead of streaming a huge tree the caller
// has already abandoned over the shared SSH channel.
const onAbort = (): void => killSurvivors('git ls-files cancelled')
signal?.addEventListener('abort', onAbort, { once: true })
return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)])
.then(() =>
expandQuickOpenGitFilesWithNestedRepos({
rootPath,
gitPaths,
excludePathPrefixes
excludePathPrefixes,
signal
})
)
.catch((err) => {
killSurvivors()
killSurvivors('git ls-files canceled after sibling failure')
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
throw err
})
.finally(() => {
signal?.removeEventListener('abort', onAbort)
})
}
/**
@@ -0,0 +1,127 @@
/**
* Cancellation behavior of the relay file-list scanners (#7721): an aborted
* scan must kill its child processes immediately and reject with a
* cancellation error instead of streaming an abandoned tree to completion.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { listFilesWithRg } from './fs-handler-list-files'
import { listFilesWithGit } from './fs-handler-git-fallback'
import { isFileListingCancellation } from '../shared/file-listing-cancellation'
function createMockProcess(): ChildProcess {
const p = new EventEmitter() as unknown as ChildProcess
;(p as unknown as Record<string, unknown>).stdout = new EventEmitter()
;(
(p as unknown as Record<string, unknown>).stdout as EventEmitter & {
setEncoding: () => void
}
).setEncoding = vi.fn()
;(p as unknown as Record<string, unknown>).stderr = new EventEmitter()
;(p as unknown as Record<string, unknown>).kill = vi.fn()
;(p as unknown as Record<string, unknown>).exitCode = null
;(p as unknown as Record<string, unknown>).signalCode = null
return p
}
describe('relay list-files cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => {
const primaryProc = createMockProcess()
const ignoredProc = createMockProcess()
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
)
const controller = new AbortController()
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
// Partial output before the abort — must be discarded, not resolved.
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
controller.abort()
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
expect(primaryProc.kill).toHaveBeenCalled()
expect(ignoredProc.kill).toHaveBeenCalled()
// Late close events after cancellation must not fire anything.
primaryProc.emit('close', null, 'SIGTERM')
ignoredProc.emit('close', null, 'SIGTERM')
})
it('listFilesWithRg rejects without spawning when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
listFilesWithRg('/remote/root', [], { signal: controller.signal })
).rejects.toSatisfy(isFileListingCancellation)
expect(spawnMock).not.toHaveBeenCalled()
})
it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => {
const primaryProc = createMockProcess()
const ignoredProc = createMockProcess()
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc
)
const controller = new AbortController()
const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal })
setTimeout(() => {
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
primaryProc.emit('close', 0, null)
;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n')
ignoredProc.emit('close', 0, null)
}, 5)
await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js'])
})
it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => {
const procs: ChildProcess[] = []
spawnMock.mockImplementation(() => {
const proc = createMockProcess()
procs.push(proc)
return proc
})
const controller = new AbortController()
const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal })
expect(procs).toHaveLength(2)
controller.abort()
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
expect(procs[0].kill).toHaveBeenCalled()
expect(procs[1].kill).toHaveBeenCalled()
})
it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
listFilesWithGit('/remote/root', [], { signal: controller.signal })
).rejects.toSatisfy(isFileListingCancellation)
expect(spawnMock).not.toHaveBeenCalled()
})
})
+25 -4
View File
@@ -16,6 +16,7 @@
* denied on a single subdir is expected on home-dir roots)
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
import {
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
@@ -27,8 +28,13 @@ export const LIST_FILES_TIMEOUT_MS = 25_000
export function listFilesWithRg(
rootPath: string,
excludePathPrefixes: readonly string[] = []
excludePathPrefixes: readonly string[] = [],
options: { signal?: AbortSignal } = {}
): Promise<string[]> {
const { signal } = options
if (signal?.aborted) {
return Promise.reject(fileListingCancellationError(signal))
}
return new Promise((resolve, reject) => {
const files = new Set<string>()
let done = false
@@ -177,7 +183,7 @@ export function listFilesWithRg(
child.once('close', handleClose)
})
const killSurvivors = (): void => {
const killSurvivors = (reason: string): void => {
// Why: when one pass rejects, Promise.all surfaces the error immediately
// but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it
// so repeated Quick Open opens don't pile up orphan rg processes on the
@@ -189,16 +195,30 @@ export function listFilesWithRg(
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
entry.reject(new Error('rg list canceled after sibling failure'))
entry.reject(new Error(reason))
}
}
// Why: a cancelled scan (workspace switch, superseded request) must stop
// its rg children immediately instead of letting them walk the tree to
// completion and flood the relay with stdout it will only discard.
const onAbort = (): void => {
if (done) {
return
}
done = true
killSurvivors('rg list cancelled')
reject(fileListingCancellationError(signal))
}
signal?.addEventListener('abort', onAbort, { once: true })
Promise.all([runPass(primary), runPass(ignoredPass)])
.then(() => {
if (done) {
return
}
done = true
signal?.removeEventListener('abort', onAbort)
resolve(Array.from(files))
})
.catch((err) => {
@@ -206,7 +226,8 @@ export function listFilesWithRg(
return
}
done = true
killSurvivors()
signal?.removeEventListener('abort', onAbort)
killSurvivors('rg list canceled after sibling failure')
reject(err instanceof Error ? err : new Error(String(err)))
})
})
+4 -2
View File
@@ -23,10 +23,12 @@ import {
*/
export async function listFilesWithReaddir(
rootPath: string,
excludePathPrefixes: readonly string[] = []
excludePathPrefixes: readonly string[] = [],
options: { signal?: AbortSignal } = {}
): Promise<string[]> {
return listQuickOpenFilesWithReaddir(rootPath, {
excludePathPrefixes,
budget: createQuickOpenReaddirBudget()
budget: createQuickOpenReaddirBudget(),
signal: options.signal
})
}
+33 -5
View File
@@ -17,6 +17,11 @@ import {
} from './fs-handler-utils'
import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback'
import { listFilesWithReaddir } from './fs-handler-readdir-fallback'
import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator'
import {
isFileListingCancellation,
throwIfFileListingCancelled
} from '../shared/file-listing-cancellation'
import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk'
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
import { buildInstallRgMessage } from './fs-handler-install-rg'
@@ -82,6 +87,7 @@ export class FsHandler {
private dispatcher: RelayDispatcher
private watches = new Map<string, WatchState>()
private streamRegistry = new RelayStreamRegistry()
private listFilesScans = new ListFilesScanCoordinator()
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
this.dispatcher = dispatcher
@@ -114,7 +120,7 @@ export class FsHandler {
this.dispatcher.onRequest('fs.copy', (p) => this.copy(p))
this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p))
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p))
this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
@@ -331,16 +337,33 @@ export class FsHandler {
})
}
private async listFiles(params: Record<string, unknown>): Promise<string[]> {
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
const rootPath = expandTilde(params.rootPath as string)
// Why: the main-to-relay RPC adds excludePaths so nested linked worktrees
// don't get double-scanned. The shared helper validates the shape and
// normalizes into root-relative prefixes; malformed input yields [] so
// the request still succeeds (older apps omit the field entirely).
const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths)
// Why #7721: full-tree scans are the relay's most expensive request; the
// coordinator caps them at one per client, coalescing duplicates and
// aborting a stale scan when the workspace changes or the host cancels.
return this.listFilesScans.run({
clientId: context?.clientId ?? 0,
key: JSON.stringify([rootPath, excludePathPrefixes]),
signal: context?.signal,
start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal)
})
}
private async runListFilesScan(
rootPath: string,
excludePathPrefixes: string[],
signal: AbortSignal
): Promise<string[]> {
const rgAvailable = await checkRgAvailable()
throwIfFileListingCancelled(signal)
if (rgAvailable) {
return listFilesWithRg(rootPath, excludePathPrefixes)
return listFilesWithRg(rootPath, excludePathPrefixes, { signal })
}
// Why: git ls-files only works inside git repos. Use rev-parse to detect
// git ancestry — unlike checking for a local .git entry, this works from
@@ -361,7 +384,7 @@ export class FsHandler {
// budget errors into install-rg guidance; genuine git failures keep
// their own messages.
try {
return await listFilesWithGit(rootPath, excludePathPrefixes)
return await listFilesWithGit(rootPath, excludePathPrefixes, { signal })
} catch (err) {
if (isQuickOpenReaddirBudgetError(err)) {
throw new Error(await buildInstallRgMessage(err))
@@ -375,8 +398,13 @@ export class FsHandler {
// problem, so translate the opaque cap error into actionable guidance
// the user can act on directly from the error toast.
try {
return await listFilesWithReaddir(rootPath, excludePathPrefixes)
return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal })
} catch (err) {
// Why: a cancelled scan is not an rg-availability problem; wrapping it
// in install-rg guidance would surface bogus advice on the client.
if (isFileListingCancellation(err)) {
throw err
}
throw new Error(await buildInstallRgMessage(err))
}
}
@@ -0,0 +1,209 @@
/**
* End-to-end in-process regression tests for #7721: cancellable fs.listFiles.
*
* Wires the client-side SshChannelMultiplexer to the relay-side
* RelayDispatcher + FsHandler through an in-memory pipe (no SSH). The scan
* body is a controllable fake so the tests are deterministic:
* - aborting the client request sends rpc.cancel and stops the relay scan,
* - interactive fs.readDir is served while a scan is in flight,
* - a scan for a different workspace supersedes the previous one,
* - identical concurrent requests coalesce into one scan.
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
const { fakeListFiles } = vi.hoisted(() => {
type ScanRecord = {
rootPath: string
signal: AbortSignal | undefined
resolve: (files: string[]) => void
}
const scans: ScanRecord[] = []
const fakeListFiles = Object.assign(
vi.fn(
(
rootPath: string,
_excludes: readonly string[] = [],
options: { signal?: AbortSignal } = {}
) =>
new Promise<string[]>((resolve, reject) => {
scans.push({ rootPath, signal: options.signal, resolve })
options.signal?.addEventListener(
'abort',
() =>
// Mirror the real scanners: surface the abort reason (e.g. the
// "superseded" error) so the dispatcher reports it to the host.
reject(
options.signal?.reason instanceof Error
? options.signal.reason
: new Error('File listing cancelled')
),
{ once: true }
)
})
),
{ scans }
)
return { fakeListFiles }
})
vi.mock('./fs-handler-utils', async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>
return {
...original,
checkRgAvailable: () => Promise.resolve(true),
listFilesWithRg: fakeListFiles
}
})
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { RelayDispatcher } from './dispatcher'
import { RelayContext } from './context'
import { FsHandler } from './fs-handler'
import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator'
async function flushPipe(): Promise<void> {
// The in-memory pipe defers each hop with setImmediate; a few macrotask
// turns guarantee request/notification frames have crossed both directions.
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setImmediate(resolve))
}
}
describe('Integration: cancellable fs.listFiles (#7721)', () => {
let tmpDir: string
let mux: SshChannelMultiplexer
let dispatcher: RelayDispatcher
let fsHandler: FsHandler
beforeEach(() => {
fakeListFiles.mockClear()
fakeListFiles.scans.length = 0
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-listfiles-cancel-'))
let relayFeedFn: (data: Buffer) => void
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data: Buffer) => {
setImmediate(() => relayFeedFn?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: () => {}
}
dispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
relayFeedFn = (data: Buffer) => dispatcher.feed(data)
fsHandler = new FsHandler(dispatcher, new RelayContext())
mux = new SshChannelMultiplexer(clientTransport)
})
afterEach(async () => {
mux.dispose()
dispatcher.dispose()
fsHandler.dispose()
await rm(tmpDir, { recursive: true, force: true })
})
it('serves fs.readDir while a full-tree scan is in flight', async () => {
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' })
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(1)
// The interactive request must complete while the scan is still pending.
const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[]
expect(entries.map((e) => e.name)).toEqual(['a.txt'])
fakeListFiles.scans[0].resolve(['src/index.ts'])
await expect(scanPromise).resolves.toEqual(['src/index.ts'])
})
it('client abort sends rpc.cancel and stops the relay-side scan', async () => {
const controller = new AbortController()
const scanPromise = mux.request(
'fs.listFiles',
{ rootPath: '/big/workspace' },
{ signal: controller.signal }
)
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(1)
expect(fakeListFiles.scans[0].signal?.aborted).toBe(false)
controller.abort()
await expect(scanPromise).rejects.toThrow('was cancelled')
await flushPipe()
// The rpc.cancel notification must have aborted the scan on the relay.
expect(fakeListFiles.scans[0].signal?.aborted).toBe(true)
})
it('a scan for a different workspace supersedes the previous scan', async () => {
const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
// Attach the rejection handler up front: the error response arrives on a
// macrotask inside flushPipe, before any later `await expect` could.
const firstOutcome = first.then(
() => null,
(err: Error) => err
)
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(1)
const second = mux.request('fs.listFiles', { rootPath: '/workspace/b' })
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(2)
// The stale scan is aborted and its request fails fast — it does not run
// to completion behind the new one and does not hit the 30s timeout.
expect(fakeListFiles.scans[0].signal?.aborted).toBe(true)
const firstError = await firstOutcome
expect(firstError?.message).toContain(LIST_FILES_SUPERSEDED_MESSAGE)
fakeListFiles.scans[1].resolve(['b.ts'])
await expect(second).resolves.toEqual(['b.ts'])
})
it('identical concurrent requests coalesce into a single scan', async () => {
const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(1)
fakeListFiles.scans[0].resolve(['shared.ts'])
await expect(first).resolves.toEqual(['shared.ts'])
await expect(second).resolves.toEqual(['shared.ts'])
})
it('cancelling one coalesced requester keeps the scan alive for the other', async () => {
const controller = new AbortController()
const first = mux.request(
'fs.listFiles',
{ rootPath: '/workspace/a' },
{ signal: controller.signal }
)
const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' })
await flushPipe()
expect(fakeListFiles.scans).toHaveLength(1)
controller.abort()
await expect(first).rejects.toThrow('was cancelled')
await flushPipe()
expect(fakeListFiles.scans[0].signal?.aborted).toBe(false)
fakeListFiles.scans[0].resolve(['still.ts'])
await expect(second).resolves.toEqual(['still.ts'])
})
})
@@ -0,0 +1,222 @@
import { describe, expect, it, vi } from 'vitest'
import {
LIST_FILES_SUPERSEDED_MESSAGE,
ListFilesScanCoordinator
} from './fs-list-files-scan-coordinator'
import {
FileListingCancelledError,
isFileListingCancellation
} from '../shared/file-listing-cancellation'
type Deferred = {
promise: Promise<string[]>
resolve: (files: string[]) => void
reject: (error: Error) => void
}
function deferred(): Deferred {
let resolve!: (files: string[]) => void
let reject!: (error: Error) => void
const promise = new Promise<string[]>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** Scan runner that resolves/rejects on demand and rejects when aborted. */
function controllableScan(): {
start: (signal: AbortSignal) => Promise<string[]>
starts: AbortSignal[]
finish: (files: string[]) => void
} {
const starts: AbortSignal[] = []
let current: Deferred | null = null
return {
starts,
start: (signal: AbortSignal) => {
starts.push(signal)
const scan = deferred()
current = scan
signal.addEventListener(
'abort',
() => {
scan.reject(
signal.reason instanceof Error ? signal.reason : new FileListingCancelledError()
)
},
{ once: true }
)
return scan.promise
},
finish: (files: string[]) => current?.resolve(files)
}
}
describe('ListFilesScanCoordinator', () => {
it('coalesces same-key concurrent requests into one scan', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
expect(scan.starts).toHaveLength(1)
scan.finish(['x.ts'])
await expect(first).resolves.toEqual(['x.ts'])
await expect(second).resolves.toEqual(['x.ts'])
})
it('supersedes a different-key scan: aborts the old one and rejects it fast', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scan.start })
const second = coordinator.run({ clientId: 1, key: 'workspace-b', start: scan.start })
expect(scan.starts).toHaveLength(2)
expect(scan.starts[0].aborted).toBe(true)
await expect(first).rejects.toThrow(LIST_FILES_SUPERSEDED_MESSAGE)
await first.catch((err) => expect(isFileListingCancellation(err)).toBe(true))
expect(scan.starts[1].aborted).toBe(false)
scan.finish(['b.ts'])
await expect(second).resolves.toEqual(['b.ts'])
})
it('keeps scans from different clients independent', async () => {
const coordinator = new ListFilesScanCoordinator()
const scanA = controllableScan()
const scanB = controllableScan()
const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scanA.start })
const second = coordinator.run({ clientId: 2, key: 'workspace-b', start: scanB.start })
expect(scanA.starts[0].aborted).toBe(false)
scanA.finish(['a.ts'])
scanB.finish(['b.ts'])
await expect(first).resolves.toEqual(['a.ts'])
await expect(second).resolves.toEqual(['b.ts'])
})
it('aborts the scan when its only requester cancels', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const requester = new AbortController()
const result = coordinator.run({
clientId: 1,
key: 'a',
signal: requester.signal,
start: scan.start
})
requester.abort()
expect(scan.starts[0].aborted).toBe(true)
await expect(result).rejects.toSatisfy(isFileListingCancellation)
})
it('keeps a coalesced scan alive while another requester still waits', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const first = new AbortController()
const second = new AbortController()
const firstResult = coordinator.run({
clientId: 1,
key: 'a',
signal: first.signal,
start: scan.start
})
const secondResult = coordinator.run({
clientId: 1,
key: 'a',
signal: second.signal,
start: scan.start
})
first.abort()
expect(scan.starts[0].aborted).toBe(false)
// The aborting requester observes its own cancellation immediately,
// while the shared scan keeps running for the sibling.
await expect(firstResult).rejects.toSatisfy(isFileListingCancellation)
second.abort()
expect(scan.starts[0].aborted).toBe(true)
await expect(secondResult).rejects.toSatisfy(isFileListingCancellation)
})
it('rejects an aborted coalesced requester while the sibling still resolves', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const first = new AbortController()
const firstResult = coordinator.run({
clientId: 1,
key: 'a',
signal: first.signal,
start: scan.start
})
const secondResult = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
first.abort()
scan.finish(['kept.ts'])
await expect(firstResult).rejects.toSatisfy(isFileListingCancellation)
await expect(secondResult).resolves.toEqual(['kept.ts'])
})
it('rejects immediately when the requester is already cancelled', async () => {
const coordinator = new ListFilesScanCoordinator()
const start = vi.fn()
const requester = new AbortController()
requester.abort()
await expect(
coordinator.run({
clientId: 1,
key: 'a',
signal: requester.signal,
start: start as unknown as (signal: AbortSignal) => Promise<string[]>
})
).rejects.toSatisfy(isFileListingCancellation)
expect(start).not.toHaveBeenCalled()
})
it('starts a fresh scan for the same key after the previous one settled', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
scan.finish(['old.ts'])
await expect(first).resolves.toEqual(['old.ts'])
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
expect(scan.starts).toHaveLength(2)
scan.finish(['new.ts'])
await expect(second).resolves.toEqual(['new.ts'])
})
it('does not join a scan that is already aborted; starts a replacement', async () => {
const coordinator = new ListFilesScanCoordinator()
const scan = controllableScan()
const requester = new AbortController()
const first = coordinator.run({
clientId: 1,
key: 'a',
signal: requester.signal,
start: scan.start
})
requester.abort()
await expect(first).rejects.toSatisfy(isFileListingCancellation)
// The aborted entry may still be in the map until its promise settles;
// a new same-key request must get a live scan, not the dead one.
const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start })
expect(scan.starts).toHaveLength(2)
expect(scan.starts[1].aborted).toBe(false)
scan.finish(['fresh.ts'])
await expect(second).resolves.toEqual(['fresh.ts'])
})
})
+104
View File
@@ -0,0 +1,104 @@
/**
* Single-flight coordinator for fs.listFiles full-tree scans (#7721).
*
* Why: rapid workspace switching used to stack N concurrent full-tree scans
* on the single-threaded relay and its one SSH channel, starving small
* interactive fs.readDir/fs.stat requests past their 30s timeout. This
* coordinator guarantees at most one scan in flight per client:
* - a request for the same root/excludes joins the in-flight scan
* (Quick Open + file-explorer filter share one scan),
* - a request for a different root supersedes it — the old scan is aborted
* and its request fails fast instead of running to completion (this also
* protects against older Orca clients that never send rpc.cancel),
* - when every joined requester cancels (rpc.cancel / client detach), the
* scan is aborted so abandoned work stops immediately.
*/
import {
FileListingCancelledError,
fileListingCancellationError
} from '../shared/file-listing-cancellation'
export const LIST_FILES_SUPERSEDED_MESSAGE = 'File listing superseded by a newer request'
type ScanEntry = {
key: string
controller: AbortController
promise: Promise<string[]>
attachedCount: number
}
export class ListFilesScanCoordinator {
private readonly scansByClient = new Map<number, ScanEntry>()
run(opts: {
clientId: number
key: string
signal?: AbortSignal
start: (signal: AbortSignal) => Promise<string[]>
}): Promise<string[]> {
const { clientId, key, signal, start } = opts
if (signal?.aborted) {
return Promise.reject(fileListingCancellationError(signal))
}
const existing = this.scansByClient.get(clientId)
if (existing && existing.key === key && !existing.controller.signal.aborted) {
return this.attach(existing, signal)
}
if (existing) {
existing.controller.abort(new FileListingCancelledError(LIST_FILES_SUPERSEDED_MESSAGE))
}
const controller = new AbortController()
const entry: ScanEntry = {
key,
controller,
promise: Promise.resolve([]),
attachedCount: 0
}
this.scansByClient.set(clientId, entry)
entry.promise = start(controller.signal).finally(() => {
if (this.scansByClient.get(clientId) === entry) {
this.scansByClient.delete(clientId)
}
})
return this.attach(entry, signal)
}
private attach(entry: ScanEntry, signal?: AbortSignal): Promise<string[]> {
entry.attachedCount++
if (!signal) {
return entry.promise
}
// Why: an aborting requester must see its own cancellation even though
// the shared scan keeps running (and may later resolve) for coalesced
// siblings — so each attachment gets its own promise.
return new Promise<string[]>((resolve, reject) => {
let settled = false
const settle = (complete: () => void): void => {
if (settled) {
return
}
settled = true
signal.removeEventListener('abort', onAbort)
complete()
}
const onAbort = (): void =>
settle(() => {
entry.attachedCount--
const cancellation = fileListingCancellationError(signal)
// Why: only stop the shared scan when nobody is left waiting on it —
// one requester cancelling must not break a coalesced sibling.
if (entry.attachedCount <= 0) {
entry.controller.abort(cancellation)
}
reject(cancellation)
})
signal.addEventListener('abort', onAbort, { once: true })
entry.promise.then(
(files) => settle(() => resolve(files)),
(error) => settle(() => reject(error))
)
})
}
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { relayLogLine } from './relay-diagnostic-log'
describe('relayLogLine', () => {
it('prefixes each log line with an ISO timestamp', () => {
const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
relayLogLine('[relay] Grace started (stdin ended)')
expect(writeSpy).toHaveBeenCalledTimes(1)
const line = writeSpy.mock.calls[0][0] as string
// Why: relay.log correlation depends on a grep-stable
// "<ISO timestamp> <original line>" shape (#7773).
expect(line).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/
)
} finally {
writeSpy.mockRestore()
}
})
})
+7
View File
@@ -0,0 +1,7 @@
// Why: the relay daemon's stderr is redirected to relay.log on the remote
// host. Without timestamps, reconnect flaps in that log cannot be correlated
// with user activity or sleep/wake windows during diagnosis (#7773). Keep the
// format grep-stable: ISO timestamp, single space, then the original line.
export function relayLogLine(message: string): void {
process.stderr.write(`${new Date().toISOString()} ${message}\n`)
}
+5 -8
View File
@@ -17,6 +17,7 @@ import {
parseHandshakeMessage,
type DecodedFrame
} from './protocol'
import { relayLogLine } from './relay-diagnostic-log'
// Why: a unique exit code reserved for the wire-level version-mismatch terminal
// condition. The client (waitForSentinel + ssh.ts) maps this exit code to a
@@ -132,22 +133,18 @@ function handleDaemonHandshakeFrame(
try {
msg = parseHandshakeMessage(frame.payload)
} catch (err) {
process.stderr.write(
`[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n`
)
relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`)
sock.destroy()
return false
}
if (msg.type !== 'orca-relay-handshake') {
process.stderr.write(
`[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n`
)
relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`)
sock.destroy()
return false
}
if (msg.version !== launchVersion) {
process.stderr.write(
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n`
relayLogLine(
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket`
)
try {
sock.write(
+24 -25
View File
@@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import { pickRemoteCliEnv } from './remote-cli-env'
import { relayLogLine } from './relay-diagnostic-log'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
@@ -358,13 +359,13 @@ async function main(): Promise<void> {
// would risk silent data corruption or zombie PTYs. We log for diagnostics
// and then exit so the client can detect the disconnect and reconnect cleanly.
process.on('uncaughtException', (err) => {
process.stderr.write(`[relay] Uncaught exception: ${err.message}\n${err.stack}\n`)
relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`)
cleanupOwnedSocket()
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[relay] Unhandled rejection: ${reason}\n`)
relayLogLine(`[relay] Unhandled rejection: ${reason}`)
})
// Why: stdoutAlive tracks whether process.stdout is still writable.
@@ -529,8 +530,8 @@ async function main(): Promise<void> {
try {
await hookServer.start({ publishEndpoint: false })
} catch (err) {
process.stderr.write(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
relayLogLine(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}`
)
}
@@ -688,7 +689,7 @@ async function main(): Promise<void> {
function cancelGrace(reason: string): void {
if (ptyHandler.graceTimerActive) {
process.stderr.write(`[relay] Grace canceled: ${reason}\n`)
relayLogLine(`[relay] Grace canceled: ${reason}`)
}
graceDeadlineAt = null
graceReason = null
@@ -704,8 +705,8 @@ async function main(): Promise<void> {
hasAcceptedSocketClient = true
acceptedSocketConnections++
process.stderr.write(
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})\n`
relayLogLine(
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})`
)
cancelGrace('socket client accepted')
@@ -779,7 +780,7 @@ async function main(): Promise<void> {
if (clientId !== undefined) {
dispatcher.detachClient(clientId)
}
process.stderr.write(`[relay] Socket client closed (clients=${socketClients.size})\n`)
relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`)
if (!stdoutAlive && socketClients.size === 0) {
startGrace('socket client closed')
}
@@ -821,9 +822,9 @@ async function main(): Promise<void> {
ownsSocketPath = true
ownedSocketIdentity = readSocketIdentity(sockPath)
server.on('error', (err) => {
process.stderr.write(`[relay] Socket server error: ${err.message}\n`)
relayLogLine(`[relay] Socket server error: ${err.message}`)
})
process.stderr.write(`[relay] Socket server listening: ${sockPath}\n`)
relayLogLine(`[relay] Socket server listening: ${sockPath}`)
resolve()
}
@@ -831,11 +832,11 @@ async function main(): Promise<void> {
removeStartupListeners()
restoreUmask()
if (err.code === 'EADDRINUSE') {
process.stderr.write(
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.\n`
relayLogLine(
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.`
)
} else {
process.stderr.write(`[relay] Socket server error before listen: ${err.message}\n`)
relayLogLine(`[relay] Socket server error before listen: ${err.message}`)
}
reject(err)
}
@@ -903,9 +904,7 @@ async function main(): Promise<void> {
failInitial(err)
return
}
process.stderr.write(
`[relay] Removed stale socket at ${sockPath} and retrying listen\n`
)
relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`)
removeStartupListeners()
listenForStartupError(failInitial)
})
@@ -958,11 +957,11 @@ async function main(): Promise<void> {
: graceTimeMs
graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs
graceReason = reason
process.stderr.write(
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}\n`
relayLogLine(
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}`
)
ptyHandler.startGraceTimer(() => {
process.stderr.write(`[relay] Grace expired (${reason}); shutting down\n`)
relayLogLine(`[relay] Grace expired (${reason}); shutting down`)
shutdown()
}, timeoutMs)
}
@@ -1006,8 +1005,8 @@ async function main(): Promise<void> {
}
function shutdown(): void {
process.stderr.write(
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}\n`
relayLogLine(
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}`
)
graceDeadlineAt = null
graceReason = null
@@ -1034,10 +1033,10 @@ async function main(): Promise<void> {
// window — a reconnecting client can then bridge to the live relay via
// --connect and reattach to the still-running PTY sessions.
process.on('SIGHUP', () => {
process.stderr.write('[relay] Received SIGHUP (SSH session dropped), ignoring\n')
relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring')
})
process.on('exit', (code) => {
process.stderr.write(`[relay] Process exiting with code ${code}\n`)
relayLogLine(`[relay] Process exiting with code ${code}`)
})
// Signal readiness to the client — the client watches for this exact
@@ -1059,8 +1058,8 @@ function cleanupSocket(sockPath: string): void {
}
void main().catch((err) => {
process.stderr.write(
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
relayLogLine(
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
)
process.exit(1)
})
@@ -0,0 +1,105 @@
import React from 'react'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
vi.mock('@/components/editor/ReviewNotesSendMenuContent', () => ({
ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record<string, unknown>) {
return { type: 'ReviewNotesSendMenuContent', props }
}
}))
function expand(node: unknown): unknown {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return node
}
if (Array.isArray(node)) {
return node.map((entry) => expand(entry))
}
if (!React.isValidElement(node)) {
if (typeof node === 'object' && 'props' in node) {
const element = node as ReactElementLike
return {
...element,
props: {
...element.props,
children: expand(element.props.children)
}
}
}
return node
}
const element = node as React.ReactElement<Record<string, unknown>>
if (typeof element.type === 'function') {
const Component = element.type as (props: Record<string, unknown>) => unknown
return expand(Component(element.props))
}
return {
type: element.type,
props: {
...element.props,
children: expand(element.props.children)
}
}
}
function findByType(node: unknown, type: string): ReactElementLike {
if (node && typeof node === 'object' && 'type' in node) {
const element = node as ReactElementLike
if (element.type === type) {
return element
}
const children = element.props.children
if (Array.isArray(children)) {
for (const child of children) {
try {
return findByType(child, type)
} catch {
// Continue searching siblings.
}
}
} else if (children) {
return findByType(children, type)
}
}
throw new Error(`Unable to find ${type}`)
}
describe('BrowserAnnotationSendMenuContent', () => {
it('uses the review notes send content so existing agent sessions are selectable', () => {
const onPromptDelivered = vi.fn()
const tree = expand(
<BrowserAnnotationSendMenuContent
worktreeId="wt-1"
groupId="group-1"
prompt="browser annotation prompt"
onPromptDelivered={onPromptDelivered}
/>
)
expect(findByType(tree, 'ReviewNotesSendMenuContent').props).toMatchObject({
worktreeId: 'wt-1',
groupId: 'group-1',
prompt: 'browser annotation prompt',
promptDelivery: 'submit-after-ready',
launchSource: 'notes_send',
onPromptDelivered
})
})
it('is wired into both browser annotation send surfaces', () => {
const browserPaneSource = readFileSync(
fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)),
'utf8'
)
expect(browserPaneSource.match(/<BrowserAnnotationSendMenuContent\b/g)).toHaveLength(2)
expect(browserPaneSource).not.toContain('<QuickLaunchAgentMenuItems')
})
})
@@ -0,0 +1,29 @@
import React from 'react'
import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent'
export type BrowserAnnotationSendMenuContentProps = {
worktreeId: string
groupId: string
prompt: string
onPromptDelivered?: () => void
}
export function BrowserAnnotationSendMenuContent({
worktreeId,
groupId,
prompt,
onPromptDelivered
}: BrowserAnnotationSendMenuContentProps): React.JSX.Element {
return (
<ReviewNotesSendMenuContent
worktreeId={worktreeId}
groupId={groupId}
prompt={prompt}
// Why: keep browser-annotation delivery and telemetry stable even if
// the shared review-notes menu defaults change later.
promptDelivery="submit-after-ready"
launchSource="notes_send"
onPromptDelivered={onPromptDelivered}
/>
)
}
@@ -45,8 +45,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent'
import {
DropdownMenu,
DropdownMenuContent,
@@ -5297,7 +5296,7 @@ function BrowserPagePane({
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.browser.pane.BrowserPane.95af781091',
'Send feedback to a new agent'
'Send feedback to an agent'
)}
</TooltipContent>
</Tooltip>
@@ -5307,13 +5306,10 @@ function BrowserPagePane({
onInteractOutside={preventAgentSendTargetOutsideDismiss}
onPointerDownOutside={preventAgentSendTargetOutsideDismiss}
>
<QuickLaunchAgentMenuItems
<BrowserAnnotationSendMenuContent
worktreeId={worktreeId}
groupId={activeGroupId ?? worktreeId}
onFocusTerminal={focusTerminalTabSurface}
prompt={browserAnnotationsPrompt}
promptDelivery="submit-after-ready"
launchSource="notes_send"
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
/>
</DropdownMenuContent>
@@ -5571,7 +5567,7 @@ function BrowserPagePane({
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.browser.pane.BrowserPane.95af781091',
'Send feedback to a new agent'
'Send feedback to an agent'
)}
</TooltipContent>
</Tooltip>
@@ -5581,13 +5577,10 @@ function BrowserPagePane({
onInteractOutside={preventAgentSendTargetOutsideDismiss}
onPointerDownOutside={preventAgentSendTargetOutsideDismiss}
>
<QuickLaunchAgentMenuItems
<BrowserAnnotationSendMenuContent
worktreeId={worktreeId}
groupId={activeGroupId ?? worktreeId}
onFocusTerminal={focusTerminalTabSurface}
prompt={browserAnnotationsPrompt}
promptDelivery="submit-after-ready"
launchSource="notes_send"
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
/>
</DropdownMenuContent>
@@ -10,9 +10,11 @@ import type { AppState } from '@/store/types'
import { useRuntimeFileListForWorktree, type RuntimeFileListState } from './quick-open-file-list'
const listRuntimeFilesMock = vi.hoisted(() => vi.fn())
const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-file-client', () => ({
listRuntimeFiles: listRuntimeFilesMock
listRuntimeFiles: listRuntimeFilesMock,
cancelRuntimeFileList: cancelRuntimeFileListMock
}))
const initialAppState = useAppStore.getInitialState()
@@ -104,6 +106,7 @@ async function renderProbe(args: {
beforeEach(() => {
useAppStore.setState(initialAppState, true)
listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json'])
cancelRuntimeFileListMock.mockReset()
})
afterEach(async () => {
@@ -155,9 +158,37 @@ describe('useRuntimeFileListForWorktree', () => {
}),
{
rootPath: '/srv/platform',
excludePaths: undefined
excludePaths: undefined,
requestToken: expect.any(String)
}
)
expect(states.at(-1)?.files).toEqual(['packages/app/package.json'])
})
it('cancels the in-flight scan with the same request token on unmount (#7721)', async () => {
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })],
projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })],
repos: [],
worktreesByRepo: {}
} as Partial<AppState>)
const root = await renderProbe({
enabled: true,
onState: () => {},
worktreeId: workspaceKey
})
await waitForListRuntimeFilesCall()
const [listContext, listRequest] = listRuntimeFilesMock.mock.calls[0]
expect(cancelRuntimeFileListMock).not.toHaveBeenCalled()
await act(async () => {
root.unmount()
})
expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken)
})
})
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { Worktree } from '../../../shared/types'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { listRuntimeFiles } from '@/runtime/runtime-file-client'
import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { useWorktreesForRepo } from '@/store/selectors'
@@ -152,21 +153,21 @@ export function useRuntimeFileListForWorktree({
setLoading(true)
const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined
const requestToken = createBrowserUuid()
const requestContext = {
// Why: Quick Open lists files for the selected workspace. It must
// follow that workspace's owner host, not the globally focused host.
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
worktreeId,
worktreePath,
connectionId
}
void listRuntimeFiles(
{
// Why: Quick Open lists files for the selected workspace. It must
// follow that workspace's owner host, not the globally focused host.
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
worktreeId,
worktreePath,
connectionId
},
{
rootPath: worktreePath,
excludePaths
}
)
void listRuntimeFiles(requestContext, {
rootPath: worktreePath,
excludePaths,
requestToken
})
.then((result) => {
if (!cancelled) {
setFiles(result)
@@ -186,6 +187,11 @@ export function useRuntimeFileListForWorktree({
return () => {
cancelled = true
// Why #7721: switching workspaces (or closing the palette) must abort
// the previous full-tree scan host- and relay-side. Over SSH, abandoned
// scans otherwise stack up and starve fs.readDir/fs.stat past their
// 30s timeout ("Could not load files for this workspace").
cancelRuntimeFileList(requestContext, requestToken)
}
}, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath])
@@ -28,7 +28,6 @@ import {
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
@@ -41,7 +40,11 @@ import { translate } from '@/i18n/i18n'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
import {
buildAiVaultHostScopeOptions,
buildRuntimeAiVaultHostScopeOptions,
useAiVaultExecutionHostScope
} from './ai-vault-host-scope'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktreeId = useActiveWorktreeId()
@@ -59,6 +62,7 @@ export default function AiVaultPanel(): React.JSX.Element {
}))
)
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const agentCmdOverrides = settings?.agentCmdOverrides
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
useAiVaultOriginalPaneActions()
@@ -72,17 +76,28 @@ export default function AiVaultPanel(): React.JSX.Element {
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const isRuntimeWorktree = useMemo(
() =>
getAiVaultResumeWorkspaceTargetStatus(resumeTargetState, activeWorktreeId ?? null) ===
'runtime',
[activeWorktreeId, resumeTargetState]
const runtimeHostOptions = useMemo(
() => buildRuntimeAiVaultHostScopeOptions(runtimeEnvironments),
[runtimeEnvironments]
)
const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } =
const availableExecutionHostScopes = useMemo(
() => runtimeHostOptions.map((option) => option.id),
[runtimeHostOptions]
)
const { executionHostScope, activeExecutionHostScope, onExecutionHostScopeChange } =
useAiVaultExecutionHostScope({
activeWorktreeId: activeWorktreeId ?? null,
resumeTargetState
resumeTargetState,
availableExecutionHostScopes
})
const hostScopeOptions = useMemo(
() =>
buildAiVaultHostScopeOptions({
activeExecutionHostScope,
runtimeHostOptions
}),
[activeExecutionHostScope, runtimeHostOptions]
)
const activeWorktreePath = activeWorktree?.path ?? null
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
const activeWorktreePaths = useMemo(
@@ -306,7 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element {
activeProjectKey={activeProjectKey}
scope={scope}
executionHostScope={executionHostScope}
activeSshExecutionHostScope={activeSshExecutionHostScope}
hostScopeOptions={hostScopeOptions}
agents={agents}
sort={sort}
group={group}
@@ -323,15 +338,6 @@ export default function AiVaultPanel(): React.JSX.Element {
onRefresh={() => void refresh({ force: true })}
/>
{isRuntimeWorktree ? (
<div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.runtimeBrowseLocalHistory',
'Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.'
)}
</div>
) : null}
{error ? (
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
{error}
@@ -32,14 +32,10 @@ import {
type AiVaultScope,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
import {
ALL_EXECUTION_HOSTS_SCOPE,
getExecutionHostLabel,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostScope
} from '../../../../shared/execution-host'
import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shared/execution-host'
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
@@ -184,19 +180,15 @@ export function VaultScopeSwitch({
export function VaultHostScopeMenu({
executionHostScope,
activeSshExecutionHostScope,
hostOptions,
onExecutionHostScopeChange
}: {
executionHostScope: ExecutionHostScope
activeSshExecutionHostScope: ExecutionHostScope | null
hostOptions: readonly AiVaultHostScopeOption[]
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
}): React.JSX.Element {
const hostOptions = [
LOCAL_EXECUTION_HOST_ID,
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []),
ALL_EXECUTION_HOSTS_SCOPE
] as const
const label = getExecutionHostLabel(executionHostScope)
const selectedOption = hostOptions.find((option) => option.id === executionHostScope)
const label = selectedOption?.label ?? getExecutionHostLabel(executionHostScope)
return (
<DropdownMenu>
@@ -224,9 +216,9 @@ export function VaultHostScopeMenu({
value={executionHostScope}
onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)}
>
{hostOptions.map((scope) => (
<DropdownMenuRadioItem key={scope} value={scope}>
{getExecutionHostLabel(scope)}
{hostOptions.map((option) => (
<DropdownMenuRadioItem key={option.id} value={option.id}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -9,6 +9,7 @@ import type {
} from '../../../../shared/ai-vault-types'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
type AiVaultPanelHeaderProps = {
query: string
@@ -20,7 +21,7 @@ type AiVaultPanelHeaderProps = {
activeProjectKey: string | null
scope: AiVaultScope
executionHostScope: ExecutionHostScope
activeSshExecutionHostScope: ExecutionHostScope | null
hostScopeOptions: readonly AiVaultHostScopeOption[]
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
@@ -47,7 +48,7 @@ export function AiVaultPanelHeader({
activeProjectKey,
scope,
executionHostScope,
activeSshExecutionHostScope,
hostScopeOptions,
agents,
sort,
group,
@@ -108,7 +109,7 @@ export function AiVaultPanelHeader({
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
<VaultHostScopeMenu
executionHostScope={executionHostScope}
activeSshExecutionHostScope={activeSshExecutionHostScope}
hostOptions={hostScopeOptions}
onExecutionHostScopeChange={onExecutionHostScopeChange}
/>
<VaultViewMenu
@@ -247,6 +247,25 @@ describe('SourceControlAgentActionDialog', () => {
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('keeps prompt editing available when async auto-start delivery fails', async () => {
mocks.onStart.mockImplementation(async () => {
await Promise.resolve()
return false
})
renderControlledDialog({
baseCommandInput: 'Resolve queued comments.',
savedCommandInputTemplate: '{basePrompt}'
})
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
expect(container.querySelector('textarea')?.value).toContain('{basePrompt}')
expect(container.textContent).toContain('Start agent')
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false)
})
it('does not auto-start when a saved receipt appears after the dialog is already open', async () => {
let setSavedAgentId: (agent: TuiAgent | null) => void = () => {}
@@ -5,7 +5,12 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
import {
buildAiVaultHostScopeOptions,
buildRuntimeAiVaultHostScopeOptions,
useAiVaultExecutionHostScope
} from './ai-vault-host-scope'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
type HostScopeResult = ReturnType<typeof useAiVaultExecutionHostScope>
@@ -15,6 +20,7 @@ let latest: HostScopeResult | null = null
function HookProbe(props: {
activeWorktreeId: string | null
resumeTargetState: AiVaultSessionResumeTargetState
availableExecutionHostScopes?: readonly ExecutionHostScope[]
}): null {
latest = useAiVaultExecutionHostScope(props)
return null
@@ -23,6 +29,7 @@ function HookProbe(props: {
async function renderHook(props: {
activeWorktreeId: string | null
resumeTargetState: AiVaultSessionResumeTargetState
availableExecutionHostScopes?: readonly ExecutionHostScope[]
}): Promise<void> {
if (!root) {
const container = document.createElement('div')
@@ -83,7 +90,7 @@ describe('useAiVaultExecutionHostScope', () => {
})
expect(latest?.executionHostScope).toBe('ssh:dev-box')
expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box')
expect(latest?.activeExecutionHostScope).toBe('ssh:dev-box')
})
it('defaults local worktrees to local history', async () => {
@@ -96,7 +103,22 @@ describe('useAiVaultExecutionHostScope', () => {
})
expect(latest?.executionHostScope).toBe('local')
expect(latest?.activeSshExecutionHostScope).toBeNull()
expect(latest?.activeExecutionHostScope).toBeNull()
})
it('defaults runtime worktrees to their runtime execution host', async () => {
await renderHook({
activeWorktreeId: 'repo-1::/runtime/repo',
resumeTargetState: stateForWorktree({
worktreeId: 'repo-1::/runtime/repo',
repoId: 'repo-1',
executionHostId: 'runtime:remote-server'
}),
availableExecutionHostScopes: ['runtime:remote-server'] as const
})
expect(latest?.executionHostScope).toBe('runtime:remote-server')
expect(latest?.activeExecutionHostScope).toBe('runtime:remote-server')
})
it('preserves manual host scope changes across unrelated rerenders', async () => {
@@ -117,4 +139,56 @@ describe('useAiVaultExecutionHostScope', () => {
expect(latest?.executionHostScope).toBe('all')
})
it('preserves manual runtime host scope choices while they are still available', async () => {
const props = {
activeWorktreeId: 'repo-1::/local/repo',
resumeTargetState: stateForWorktree({
worktreeId: 'repo-1::/local/repo',
repoId: 'repo-1'
}),
availableExecutionHostScopes: ['runtime:remote-server'] as const
}
await renderHook(props)
await act(async () => {
latest?.onExecutionHostScopeChange('runtime:remote-server')
})
await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } })
expect(latest?.executionHostScope).toBe('runtime:remote-server')
})
})
describe('buildAiVaultHostScopeOptions', () => {
it('adds saved runtime hosts between active SSH and all hosts', () => {
const runtimeHostOptions = buildRuntimeAiVaultHostScopeOptions([
{ id: 'remote-server', name: 'VPS Orca Server' }
])
expect(
buildAiVaultHostScopeOptions({
activeExecutionHostScope: 'ssh:dev-box',
runtimeHostOptions
})
).toEqual([
{ id: 'local', label: expect.any(String) },
{ id: 'ssh:dev-box', label: 'dev-box' },
{ id: 'runtime:remote-server', label: 'VPS Orca Server' },
{ id: 'all', label: 'All hosts' }
])
})
it('keeps a runtime active host visible even before the saved environment list hydrates', () => {
expect(
buildAiVaultHostScopeOptions({
activeExecutionHostScope: 'runtime:remote-server',
runtimeHostOptions: []
})
).toEqual([
{ id: 'local', label: expect.any(String) },
{ id: 'runtime:remote-server', label: 'remote-server' },
{ id: 'all', label: 'All hosts' }
])
})
})
@@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target'
import {
ALL_EXECUTION_HOSTS_SCOPE,
getExecutionHostLabel,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId,
type ExecutionHostScope
} from '../../../../shared/execution-host'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
export type AiVaultHostScopeOption = {
id: ExecutionHostScope
label: string
}
export function useAiVaultExecutionHostScope(args: {
activeWorktreeId: string | null
resumeTargetState: AiVaultSessionResumeTargetState
availableExecutionHostScopes?: readonly ExecutionHostScope[]
}): {
executionHostScope: ExecutionHostScope
activeSshExecutionHostScope: ExecutionHostScope | null
activeExecutionHostScope: ExecutionHostId | null
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
} {
const userChangedHostScopeRef = useRef(false)
@@ -22,10 +32,12 @@ export function useAiVaultExecutionHostScope(args: {
[args.activeWorktreeId, args.resumeTargetState]
)
const activeExecutionHost = parseExecutionHostId(activeExecutionHostId)
const activeExecutionHostScope: ExecutionHostId | null =
activeExecutionHost?.kind === 'ssh' || activeExecutionHost?.kind === 'runtime'
? activeExecutionHost.id
: null
const defaultExecutionHostScope: ExecutionHostScope =
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID
const activeSshExecutionHostScope: ExecutionHostScope | null =
activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null
activeExecutionHostScope ?? LOCAL_EXECUTION_HOST_ID
const [executionHostScope, setExecutionHostScope] =
useState<ExecutionHostScope>(defaultExecutionHostScope)
@@ -36,7 +48,8 @@ export function useAiVaultExecutionHostScope(args: {
const allowedScopes = new Set<ExecutionHostScope>([
LOCAL_EXECUTION_HOST_ID,
ALL_EXECUTION_HOSTS_SCOPE,
...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : [])
...(activeExecutionHostScope ? [activeExecutionHostScope] : []),
...(args.availableExecutionHostScopes ?? [])
])
if (!allowedScopes.has(executionHostScope)) {
setExecutionHostScope(defaultExecutionHostScope)
@@ -46,7 +59,12 @@ export function useAiVaultExecutionHostScope(args: {
if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) {
setExecutionHostScope(defaultExecutionHostScope)
}
}, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope])
}, [
activeExecutionHostScope,
args.availableExecutionHostScopes,
defaultExecutionHostScope,
executionHostScope
])
const handleExecutionHostScopeChange = useCallback(
(nextScope: ExecutionHostScope) => {
@@ -58,7 +76,49 @@ export function useAiVaultExecutionHostScope(args: {
return {
executionHostScope,
activeSshExecutionHostScope,
activeExecutionHostScope,
onExecutionHostScopeChange: handleExecutionHostScopeChange
}
}
export function buildRuntimeAiVaultHostScopeOptions(
runtimeEnvironments: readonly Pick<PublicKnownRuntimeEnvironment, 'id' | 'name'>[]
): AiVaultHostScopeOption[] {
return runtimeEnvironments.map((environment) => {
const id = toRuntimeExecutionHostId(environment.id)
const label = environment.name.trim() || getExecutionHostLabel(id)
return { id, label }
})
}
export function buildAiVaultHostScopeOptions(args: {
activeExecutionHostScope: ExecutionHostId | null
runtimeHostOptions: readonly AiVaultHostScopeOption[]
}): AiVaultHostScopeOption[] {
const options: AiVaultHostScopeOption[] = []
const seen = new Set<ExecutionHostScope>()
const add = (option: AiVaultHostScopeOption): void => {
if (seen.has(option.id)) {
return
}
seen.add(option.id)
options.push(option)
}
const activeHost = args.activeExecutionHostScope
? parseExecutionHostId(args.activeExecutionHostScope)
: null
add({ id: LOCAL_EXECUTION_HOST_ID, label: getExecutionHostLabel(LOCAL_EXECUTION_HOST_ID) })
if (activeHost?.kind === 'ssh') {
add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) })
}
for (const option of args.runtimeHostOptions) {
add(option)
}
if (activeHost?.kind === 'runtime') {
add({ id: activeHost.id, label: getExecutionHostLabel(activeHost.id) })
}
add({ id: ALL_EXECUTION_HOSTS_SCOPE, label: getExecutionHostLabel(ALL_EXECUTION_HOSTS_SCOPE) })
return options
}
@@ -100,21 +100,40 @@ export function useAiVaultSessionLaunchActions({
return
}
launchAiVaultSessionInNewTab({
const launchResult = launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: targetId.worktreeId,
...buildResumeStartup(session, targetId.worktreeId)
})
const showQueuedToast = (): void => {
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
}
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((created) => {
if (!created) {
toast.error(
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: agentLabel(session.agent) }
)
)
return
}
showQueuedToast()
})
return
}
if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) {
activateAiVaultResumeWorkspace(targetId.worktreeId)
}
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
showQueuedToast()
},
[activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState]
)
@@ -167,15 +186,9 @@ export function resolveAiVaultSessionLaunchTarget(args: {
function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
if (targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
'Resume from history is not available in runtime-hosted workspaces.'
)
}
// Why: local and SSH targets can both be valid generally; this branch means
// the session's recorded host does not match the selected workspace.
if (targetStatus === 'ssh' || targetStatus === 'local') {
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
'This session belongs to a different host. Open a workspace on the same host to resume it.'
@@ -183,7 +196,7 @@ function aiVaultResumeUnsupportedMessage(
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a local or SSH workspace before resuming a session.'
'Open a workspace before resuming a session.'
)
}
@@ -194,10 +194,33 @@ describe('resolveAiVaultSessionResumeState', () => {
})
})
it('blocks runtime-owned targets even when they have no SSH connection', () => {
it('allows runtime-owned targets for matching runtime sessions', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-1',
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree()],
repos: [
makeRepo({
connectionId: null,
executionHostId: 'runtime:env-1'
})
]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
it('blocks runtime-owned targets for sessions from another host', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
sessionExecutionHostId: 'local',
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree()],
@@ -215,19 +238,20 @@ describe('resolveAiVaultSessionResumeState', () => {
})
})
it('blocks runtime-stamped worktrees even when the repo owner is local', () => {
it('allows runtime-stamped worktrees even when the repo owner is local', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-1',
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree({ hostId: 'runtime:env-1' })],
repos: [makeRepo({ connectionId: null, executionHostId: 'local' })]
})
).toEqual({
blocked: true,
worktreeId: null,
usesSessionWorktree: false
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
@@ -319,10 +343,11 @@ describe('resolveAiVaultSessionResumeState', () => {
})
})
it('blocks an active runtime folder workspace', () => {
it('allows an active runtime folder workspace for matching runtime sessions', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-1',
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -330,8 +355,8 @@ describe('resolveAiVaultSessionResumeState', () => {
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
})
).toEqual({
blocked: true,
worktreeId: null,
blocked: false,
worktreeId: folderWorkspaceKey('folder-1'),
usesSessionWorktree: false
})
})
@@ -461,10 +486,11 @@ describe('resolveAiVaultSessionResumeActions', () => {
})
})
it('disables the active folder workspace action when it is runtime-owned', () => {
it('enables the active folder workspace action when it is a matching runtime host', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-1',
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -473,7 +499,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
})
).toEqual({
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: true }
newTab: { worktreeId: folderWorkspaceKey('folder-1'), disabled: false }
})
})
})
@@ -505,10 +531,25 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
})
})
it('blocks direct resume into a runtime folder workspace', () => {
it('allows direct resume into a matching runtime folder workspace', () => {
expect(
resolveAiVaultSessionLaunchTarget({
sessionFilePath: WSL_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-1',
activeWorktreeId: folderWorkspaceKey('folder-1'),
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
})
).toEqual({
status: 'ready',
worktreeId: folderWorkspaceKey('folder-1')
})
})
it('blocks direct resume into a mismatched runtime folder workspace', () => {
expect(
resolveAiVaultSessionLaunchTarget({
sessionFilePath: WSL_SESSION_FILE,
sessionExecutionHostId: 'runtime:env-2',
activeWorktreeId: folderWorkspaceKey('folder-1'),
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
})
@@ -326,6 +326,18 @@ describe('PRCommentsList comment resolution selection', () => {
expect(container.querySelector('button[role="checkbox"]')).toBeNull()
})
it('keeps queued comments selected when clearRequest is null', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Queue for agent')
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
renderList({ comments, clearRequest: null })
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
})
it('keeps a clear request pending until its review context is mounted', () => {
const queuedComments = [
comment({
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
focusTerminalTabSurface: vi.fn(),
launchAgentInNewTab: vi.fn(),
onClose: vi.fn(),
onLaunched: vi.fn(),
onSaveAgentDefault: vi.fn(),
onStart: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: mocks.focusTerminalTabSurface
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
launchAgentInNewTab: mocks.launchAgentInNewTab
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
function buildArgs(
overrides: Partial<Parameters<typeof runSourceControlAgentActionStart>[0]> = {}
): Parameters<typeof runSourceControlAgentActionStart>[0] {
return {
selectedAgent: 'codex',
trimmedCommandInput: 'Fix the bug',
agentArgs: '--model gpt-5',
commandTemplate: '{basePrompt}',
saveTargetValue: 'none',
actionId: 'resolveComments',
repoId: null,
settings: null,
repo: null,
worktreeId: 'wt-1',
groupId: 'group-1',
promptDelivery: 'submit-after-ready',
launchPlatform: 'linux',
launchSource: 'source_control_recovery',
onStart: undefined,
onSaveAgentDefault: mocks.onSaveAgentDefault,
onLaunched: mocks.onLaunched,
onClose: mocks.onClose,
...overrides
}
}
describe('runSourceControlAgentActionStart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('waits for deferred prompt delivery before confirming a source-control launch', async () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(true)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onClose).toHaveBeenCalledTimes(1)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('keeps the source-control dialog open when deferred prompt delivery fails', async () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false })
})
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onClose).not.toHaveBeenCalled()
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.')
})
it('does not show a generic start failure when deferred delivery already notified the user', async () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true })
})
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onClose).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('logs and treats a rejected promptDeliveryResult as a launch failure', async () => {
const error = new Error('boom')
const originalConsole = console
const consoleError = vi.fn()
vi.stubGlobal('console', { ...originalConsole, error: consoleError })
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.reject(error)
})
try {
await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
expect(consoleError).toHaveBeenCalledWith('promptDeliveryResult rejected', error)
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onClose).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('Could not start the selected agent.')
} finally {
vi.stubGlobal('console', originalConsole)
}
})
it('keeps non-deferred tab launches immediate', async () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true
})
await expect(
runSourceControlAgentActionStart(buildArgs({ promptDelivery: 'draft' }))
).resolves.toBe(true)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onClose).toHaveBeenCalledTimes(1)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('keeps injected onStart successes immediate', async () => {
const onStart = vi.fn().mockResolvedValue(true)
await expect(
runSourceControlAgentActionStart(
buildArgs({
onStart,
worktreeId: undefined,
groupId: undefined
})
)
).resolves.toBe(true)
expect(onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Fix the bug',
agentArgs: '--model gpt-5'
})
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onClose).toHaveBeenCalledTimes(1)
})
})
@@ -62,6 +62,7 @@ export async function runSourceControlAgentActionStart({
onClose
}: RunSourceControlAgentActionStartArgs): Promise<boolean> {
let launched = false
let launchFailureNotified = false
if (onStart) {
launched = await onStart({
agent: selectedAgent,
@@ -83,14 +84,26 @@ export async function runSourceControlAgentActionStart({
if (result?.tabId) {
focusTerminalTabSurface(result.tabId)
}
if (result?.promptDeliveryResult) {
try {
const deliveryResult = await result.promptDeliveryResult
launched = deliveryResult.delivered
launchFailureNotified = deliveryResult.failureNotified
} catch (error) {
console.error('promptDeliveryResult rejected', error)
launched = false
}
}
}
if (!launched) {
toast.error(
translate(
'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1',
'Could not start the selected agent.'
if (!launchFailureNotified) {
toast.error(
translate(
'auto.components.right.sidebar.SourceControlAgentActionDialog.8e856842d1',
'Could not start the selected agent.'
)
)
)
}
return false
}
@@ -173,20 +173,11 @@ export default function AiVaultSessionDropLayer({
const state = useAppStore.getState()
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId)
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId)
if (targetStatus === 'runtime') {
toast.error(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.runtimeWorkspacesUnsupported',
'Resume from history is not available in runtime-hosted workspaces.'
)
)
return true
}
if (targetStatus === 'unknown') {
toast.error(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.openSupportedWorkspace',
'Open a local or SSH workspace before resuming a session.'
'Open a workspace before resuming a session.'
)
)
return true
@@ -208,7 +199,7 @@ export default function AiVaultSessionDropLayer({
return true
}
launchAiVaultSessionInNewTab({
const launchResult = launchAiVaultSessionInNewTab({
agent: payload.agent,
worktreeId,
command: payload.command,
@@ -217,12 +208,31 @@ export default function AiVaultSessionDropLayer({
targetGroupId: dropTarget.groupId,
splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone
})
toast.success(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued',
'Session queued'
const showQueuedToast = (): void => {
toast.success(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued',
'Session queued'
)
)
)
}
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((created) => {
if (!created) {
toast.error(
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: payload.agent }
)
)
return
}
showQueuedToast()
})
return true
}
showQueuedToast()
return true
},
[clearDragState, target, updateTarget, worktreeId]
@@ -24,7 +24,17 @@ import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import { fitPanes, isWindowsUserAgent } from './pane-helpers'
import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
getExplicitRuntimeEnvironmentIdForWorktree,
getRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
import {
selectRuntimeAwareSshStatus,
selectRuntimeAwareSshTargetLabel,
selectRuntimeAwareSshTargetRemoved
} from '@/store/slices/runtime-environment-ssh'
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import {
@@ -306,26 +316,44 @@ export default function TerminalPane({
}
return connectionId
})
// Which machine's SSH store this target belongs to: a remote Orca server's
// per-environment bucket, or null for this machine's local SSH maps. The
// explicit-owner resolver never lets a merely focused runtime make a
// local-owned workspace look remote. The paired web client mirrors its one
// host through the local maps instead.
const sshReconnectEnvironmentId = useAppStore((store) =>
sshReconnectTargetId && !isPairedWebClientWindow()
? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId)
: null
)
const sshReconnectStatus = useAppStore((store) =>
sshReconnectTargetId
? (store.sshConnectionStates.get(sshReconnectTargetId)?.status ?? 'disconnected')
? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId)
: null
)
const sshReconnectTargetLabel = useAppStore((store) =>
sshReconnectTargetId
? (store.sshTargetLabels.get(sshReconnectTargetId) ??
// Fall back to the removed target's last known label (ghost host) before
// the raw id, so a removed host shows its name instead of ssh-<ts>-<rand>.
store.removedSshTargetLabels.get(sshReconnectTargetId) ??
sshReconnectTargetId)
? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId)
: ''
)
// Why: the target was removed entirely (a ghost) when it's no longer a known
// SSH target. Reconnecting to it can only fail ("SSH target not found"), so
// the overlay must offer to remove the workspace instead of Connect.
// SSH target on its owning host. Reconnecting to it can only fail ("SSH
// target not found"), so the overlay must offer to remove the workspace
// instead of Connect. The selector requires positive removal evidence.
const sshReconnectTargetRemoved = useAppStore((store) =>
sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false
sshReconnectTargetId
? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId)
: false
)
useEffect(() => {
if (!sshReconnectEnvironmentId) {
return
}
// Why: an SSH-backed workspace can be mirrored before its owning
// environment's bucket ever hydrated (no-op once hydrated), and overlay
// state must come from fetched evidence, never from an empty default.
void hydrateRuntimeEnvironmentSshState(sshReconnectEnvironmentId).catch(() => {})
}, [sshReconnectEnvironmentId])
useVisibleTerminalTabClaim({ isVisible, tabId })
@@ -2984,6 +3012,7 @@ export default function TerminalPane({
status={sshReconnectStatus}
targetRemoved={sshReconnectTargetRemoved}
worktreeId={worktreeId}
sshOwnerEnvironmentId={sshReconnectEnvironmentId}
/>
) : null}
<DaemonActionDialog api={daemonActions} />
@@ -17,6 +17,11 @@ const deleteFlowMocks = vi.hoisted(() => ({
runWorktreeDelete: vi.fn()
}))
const environmentSshMocks = vi.hoisted(() => ({
connectRuntimeEnvironmentSshTarget: vi.fn(),
resyncRuntimeEnvironmentSshTargets: vi.fn()
}))
vi.mock('sonner', () => ({
toast: {
error: toastMocks.error
@@ -27,17 +32,25 @@ vi.mock('../sidebar/delete-worktree-flow', () => ({
runWorktreeDelete: deleteFlowMocks.runWorktreeDelete
}))
vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks)
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace('{{value0}}', values?.value0 ?? '')
}))
function installSshConnect(connect: ReturnType<typeof vi.fn>): void {
function installSshConnect(
connect: ReturnType<typeof vi.fn>,
overrides: Record<string, ReturnType<typeof vi.fn>> = {}
): void {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
connect
connect,
listTargets: vi.fn().mockResolvedValue([]),
listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
...overrides
}
}
})
@@ -48,6 +61,8 @@ describe('TerminalSshReconnectOverlay', () => {
useAppStore.setState(useAppStore.getInitialState(), true)
toastMocks.error.mockReset()
deleteFlowMocks.runWorktreeDelete.mockReset()
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset()
})
afterEach(() => {
@@ -110,6 +125,56 @@ describe('TerminalSshReconnectOverlay', () => {
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled()
})
it('resyncs target metadata after a failed connect so a stale overlay converges', async () => {
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
const listTargets = vi
.fn()
.mockResolvedValue([
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
])
const listRemovedTargetLabels = vi.fn().mockResolvedValue({ 'ssh-dead': 'devbox (removed)' })
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
// Why: the metadata refresh is what flips TerminalPane's targetRemoved
// derivation, replacing the failing Connect loop with the ghost-host UI.
await waitFor(() => {
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
expect(useAppStore.getState().removedSshTargetLabels.get('ssh-dead')).toBe('devbox (removed)')
})
})
it('still applies the target list when the removed-labels refresh fails', async () => {
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
const listTargets = vi
.fn()
.mockResolvedValue([
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
])
const listRemovedTargetLabels = vi.fn().mockRejectedValue(new Error('unavailable'))
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
// Why: a removed-labels failure must not discard the refreshed target
// list — it alone is enough evidence for targetRemoved to converge.
await waitFor(() => {
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
expect(useAppStore.getState().sshTargetsHydrated).toBe(true)
})
})
it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => {
const connect = vi.fn().mockResolvedValue(undefined)
installSshConnect(connect)
@@ -134,6 +199,65 @@ describe('TerminalSshReconnectOverlay', () => {
expect(connect).not.toHaveBeenCalled()
})
it('routes Connect to the owning environment runtime RPC for a remote-owned workspace', async () => {
const connect = vi.fn().mockResolvedValue(undefined)
installSshConnect(connect)
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null)
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay
targetId="ssh-remote-1"
targetLabel="devbox"
status="disconnected"
sshOwnerEnvironmentId="env-1"
/>
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() =>
expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith(
'env-1',
'ssh-remote-1'
)
)
// The local ssh API must never see a remote host's target.
expect(connect).not.toHaveBeenCalled()
})
it('resyncs the owning environment (not the local store) after a failed remote connect', async () => {
const connect = vi.fn().mockResolvedValue(undefined)
const listTargets = vi.fn().mockResolvedValue([])
installSshConnect(connect, { listTargets })
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockRejectedValue(
new Error('SSH target "ssh-remote-dead" not found')
)
environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined)
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay
targetId="ssh-remote-dead"
targetLabel="devbox"
status="disconnected"
sshOwnerEnvironmentId="env-1"
/>
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-remote-dead" not found')
)
await waitFor(() =>
expect(environmentSshMocks.resyncRuntimeEnvironmentSshTargets).toHaveBeenCalledWith('env-1')
)
// The failed-connect resync must not rewrite local target metadata.
expect(listTargets).not.toHaveBeenCalled()
expect(useAppStore.getState().sshTargetsHydrated).toBe(false)
})
it('publishes the returned SSH state so deferred terminal reattach can resume', async () => {
const connectedState: SshConnectionState = {
targetId: 'ssh-target-1',
@@ -7,6 +7,10 @@ import { useAppStore } from '@/store'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import {
connectRuntimeEnvironmentSshTarget,
resyncRuntimeEnvironmentSshTargets
} from '@/runtime/runtime-environment-ssh-state'
type TerminalSshReconnectOverlayProps = {
targetId: string
@@ -16,6 +20,10 @@ type TerminalSshReconnectOverlayProps = {
// remove the workspace instead of a Connect button that can only fail.
targetRemoved?: boolean
worktreeId?: string
// Set when the SSH target belongs to a remote Orca server (runtime
// environment): Connect and the failed-connect resync then route to that
// environment's runtime RPC and bucket instead of the local ssh.* API.
sshOwnerEnvironmentId?: string | null
}
// Why: relay deployment/reconnect are host-driven transient states; the
@@ -70,7 +78,8 @@ export function TerminalSshReconnectOverlay({
targetLabel,
status,
targetRemoved = false,
worktreeId
worktreeId,
sshOwnerEnvironmentId = null
}: TerminalSshReconnectOverlayProps): React.JSX.Element {
const [connecting, setConnecting] = useState(false)
const mountedRef = useMountedRef()
@@ -85,11 +94,16 @@ export function TerminalSshReconnectOverlay({
}
setConnecting(true)
try {
const connectState = await window.api.ssh.connect({ targetId })
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands;
// the waiting deferred PTY reattach path keys off this renderer store.
setSshConnectionState(targetId, connectState)
if (sshOwnerEnvironmentId) {
// Bucket state is written inside the helper, mirroring the local path.
await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
} else {
const connectState = await window.api.ssh.connect({ targetId })
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands;
// the waiting deferred PTY reattach path keys off this renderer store.
setSshConnectionState(targetId, connectState)
}
}
} catch (err) {
toast.error(
@@ -100,12 +114,27 @@ export function TerminalSshReconnectOverlay({
'SSH connection failed'
)
)
// Why: a failed connect usually means the renderer's target metadata is
// stale (target removed, or re-added under a new id). Resync it so the
// overlay converges to the ghost/re-adopted state instead of offering
// the same failing Connect forever (STA-1468). Apply the target list
// first — a removed-labels failure must not discard it.
if (sshOwnerEnvironmentId) {
void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {})
} else {
void (async () => {
const targets = await window.api.ssh.listTargets()
useAppStore.getState().setSshTargetsMetadata(targets)
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
})().catch(() => {})
}
} finally {
if (mountedRef.current) {
setConnecting(false)
}
}
}, [isConnecting, mountedRef, setSshConnectionState, targetId])
}, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
return (
<div
@@ -215,6 +215,115 @@ describe('createRemoteRuntimePtyTransport', () => {
})
})
it('re-derives the host session handle after a transport close instead of resubscribing the stale one', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtySpawn = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtySpawn
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-1',
cols: 80,
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-1' })
// Why: while the tunnel was down the host re-minted this pane's handle;
// resubscribing the stale closure handle would bind the mirror to a
// different PTY (#7718). The transport must re-derive from the snapshot.
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'session.tabs.list'
? {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Terminal',
isActive: true,
status: 'ready',
terminal: 'terminal-2'
}
]
}
}
: { ok: true, result: {} }
)
const subscribeCallsBefore = runtimeSubscribe.mock.calls.length
// The dedicated multiplex socket dies (liveness/close) → onTransportClose.
subscriptionCallbacks?.onClose?.()
await vi.waitFor(() =>
expect(runtimeSubscribe.mock.calls.length).toBeGreaterThan(subscribeCallsBefore)
)
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' })
)
expect(transport.getPtyId()).toContain('terminal-2')
expect(onPtySpawn).toHaveBeenCalledWith(expect.stringContaining('terminal-2'))
})
it('retires the mirror when the host no longer publishes the surface after a transport close', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-1',
cols: 80,
rows: 24,
callbacks: { onError }
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'session.tabs.list'
? {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
}
: { ok: true, result: {} }
)
subscriptionCallbacks?.onClose?.()
// Why: no red xterm error — retire quietly and let the next session-tabs
// snapshot drive respawn/removal.
await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1'))
expect(transport.getPtyId()).toBeNull()
expect(onError).not.toHaveBeenCalled()
})
it('does not close host-owned terminal handles attached from session snapshots', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
@@ -182,6 +182,16 @@ export function createRemoteRuntimePtyTransport(
return null
}
async function listHostSessionHandle(hostTabId: string): Promise<string | null> {
if (!worktreeId) {
return null
}
const listed = await callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: toRuntimeWorktreeSelector(worktreeId)
})
return findReadyHostSessionHandle(listed, hostTabId)
}
async function attachHostSessionMirror(
options: Parameters<PtyTransport['connect']>[0]
): Promise<PtyConnectResult | undefined> {
@@ -280,7 +290,9 @@ export function createRemoteRuntimePtyTransport(
}
return true
} catch (error) {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
// Why: stale-handle errors must retire the mirror (recoverable via the
// next snapshot) rather than dead-end in a red xterm banner (#7718).
handleRemoteTerminalError(error)
return false
}
}
@@ -298,7 +310,7 @@ export function createRemoteRuntimePtyTransport(
text,
client: { id: clientId, type: 'desktop' }
}).catch((error) => {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
handleRemoteTerminalError(error)
})
})
@@ -371,6 +383,31 @@ export function createRemoteRuntimePtyTransport(
storedCallbacks.onError?.(message)
}
// Why: after a transport drop the host may have re-minted this pane's
// handle (reconnect, epoch or PTY change). Re-derive it from the current
// session snapshot instead of resubscribing the stale closure value, which
// would mirror (and type into) whatever PTY now sits behind it (#7718).
async function resubscribeAfterTransportClose(previousHandle: string): Promise<void> {
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId))
if (destroyed || !connected || handle !== previousHandle) {
return
}
if (!nextHandle) {
// Why: the host no longer publishes this surface; retire quietly and
// let the next session-tabs snapshot drive respawn/removal.
retireRemoteTerminalId()
return
}
if (nextHandle !== previousHandle) {
handle = nextHandle
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
onPtySpawn?.(remotePtyId)
}
}
await subscribeToHandle()
}
async function subscribeToHandle(): Promise<void> {
if (!handle) {
return
@@ -453,10 +490,9 @@ export function createRemoteRuntimePtyTransport(
}
resubscribing = true
const resubscribeHandle = handle
const resubscribePtyId = remotePtyId
void subscribeToHandle()
void resubscribeAfterTransportClose(resubscribeHandle)
.catch((error) => {
if (isCurrentRemoteTerminal(resubscribeHandle, resubscribePtyId)) {
if (!destroyed && connected && handle) {
handleRemoteTerminalError(error)
}
})
@@ -659,7 +695,7 @@ export function createRemoteRuntimePtyTransport(
text,
client: { id: clientId, type: 'desktop' }
}).catch((error) => {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
handleRemoteTerminalError(error)
})
return true
},
@@ -5,6 +5,7 @@ import {
buildRuntimeClientEventEnvironmentKey,
buildNewWorkspaceShortcutModalData,
getNewlyConnectedRuntimeEnvironmentIds,
getNewlyDisconnectedRuntimeEnvironmentIds,
getRuntimeProjectRefreshEnvironmentIds,
openNewWorkspaceFromShortcut,
resolveBrowserSessionTabTarget,
@@ -57,6 +58,15 @@ describe('getNewlyConnectedRuntimeEnvironmentIds', () => {
})
})
describe('getNewlyDisconnectedRuntimeEnvironmentIds', () => {
it('returns only environments whose transport was just observed down', () => {
expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a', 'env-b'], ['env-a'])).toEqual([
'env-b'
])
expect(getNewlyDisconnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual([])
})
})
describe('getRuntimeProjectRefreshEnvironmentIds', () => {
it('refreshes when an already-desired runtime becomes reachable', () => {
expect(
+96 -41
View File
@@ -83,6 +83,11 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import {
applyRuntimeEnvironmentSshStateChanged,
hydrateRuntimeEnvironmentSshState
} from '@/runtime/runtime-environment-ssh-state'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
import { detectLanguage } from '@/lib/language-detect'
@@ -769,6 +774,15 @@ export function getNewlyConnectedRuntimeEnvironmentIds(
return [...new Set(next)].filter((environmentId) => !known.has(environmentId))
}
/** Ids in `previous` not in `next` — runtime environments whose transport was
* just observed down. Their mirrored SSH buckets get downgraded to unknown. */
export function getNewlyDisconnectedRuntimeEnvironmentIds(
previous: readonly string[],
next: readonly string[]
): string[] {
return getNewlyConnectedRuntimeEnvironmentIds(next, previous)
}
export function getRuntimeProjectRefreshEnvironmentIds(args: {
previousDesired: readonly string[]
nextDesired: readonly string[]
@@ -954,6 +968,13 @@ export function useIpcEvents(): void {
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
refresh: async (environmentId) => {
if (!isPairedWebClientWindow()) {
// Why: mirrored SSH-backed workspaces read the owning environment's
// SSH bucket; refresh it whenever the environment (re)connects so a
// pre-drop snapshot can't keep a reconnect overlay stale. The web
// client mirrors host SSH state through the global store instead.
void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {})
}
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
await refreshRuntimeProjectWorktrees(repos)
await useAppStore.getState().fetchWorktreeLineage()
@@ -963,11 +984,28 @@ export function useIpcEvents(): void {
}
})
// Assigned later in this effect, next to the ssh.onStateChanged wiring;
// events can't fire before that because subscriptions attach asynchronously.
let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null =
null
const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => {
if (event.type === 'reposChanged') {
runtimeProjectRefreshScheduler.request(environmentId)
return
}
if (event.type === 'sshStateChanged') {
// Why: a paired web client mirrors host SSH state in the global store —
// its whole ssh.* API routes to that one host (STA-1468). A desktop
// client owns a local SSH surface those maps must keep describing, so a
// remote host's state goes into that environment's own bucket instead.
if (isPairedWebClientWindow()) {
handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state })
} else {
applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state)
}
return
}
if (event.type === 'worktreesChanged') {
void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() =>
worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId })
@@ -992,7 +1030,17 @@ export function useIpcEvents(): void {
const runtimeClientEventsSync = createRuntimeClientEventsSync({
getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds,
subscribe: subscribeRuntimeClientEvents,
subscribe: (environmentId, onEvent, onError) =>
subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => {
if (isPairedWebClientWindow()) {
return
}
// Why: sshStateChanged events during the transport gap are lost, so
// the pre-drop bucket may hold a stale "connected". Downgrade to
// unknown, then refetch the authoritative state.
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {})
}),
onEvent: handleRuntimeClientEvent
})
@@ -1033,6 +1081,13 @@ export function useIpcEvents(): void {
})) {
runtimeProjectRefreshScheduler.request(environmentId)
}
for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds(
reachableRuntimeEnvironmentIds,
nextReachableEnvironmentIds
)) {
// No-op when the environment has no SSH bucket (e.g. web client).
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
}
runtimeClientEventEnvironmentIds = nextEnvironmentIds
runtimeClientEventEnvironmentKey = nextKey
reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds
@@ -2667,49 +2722,49 @@ export function useIpcEvents(): void {
let sshTargetStateEventId = 0
const latestSshTargetStateEventByTargetId = new Map<string, number>()
unsubs.push(
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
const stateEventId = ++sshTargetStateEventId
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
if (!store.sshTargetLabels.has(data.targetId)) {
// Why: targets added after boot aren't in the labels map, while
// removed targets can still race a final disconnect event. Confirm
// with main before mutating renderer state for an unknown target id.
window.api.ssh
.listTargets()
// Why: this refresh is now a deletion guard, not just a label fetch.
// Retry once so a transient IPC failure does not drop a real added-target event.
.catch(() => window.api.ssh.listTargets())
.then((targets) => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
return
}
handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
const stateEventId = ++sshTargetStateEventId
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
if (!store.sshTargetLabels.has(data.targetId)) {
// Why: targets added after boot aren't in the labels map, while
// removed targets can still race a final disconnect event. Confirm
// with main before mutating renderer state for an unknown target id.
window.api.ssh
.listTargets()
// Why: this refresh is now a deletion guard, not just a label fetch.
// Retry once so a transient IPC failure does not drop a real added-target event.
.catch(() => window.api.ssh.listTargets())
.then((targets) => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
const latestStore = useAppStore.getState()
if (!targets.some((target) => target.id === data.targetId)) {
// Why: disconnect/state events can race after target removal.
// Treat absence from main's target list as deletion, not a new target.
latestStore.clearRemovedSshTargetState(data.targetId)
return
}
latestStore.setSshTargetsMetadata(targets)
applySshConnectionStateChange(data.targetId, state)
})
.catch(() => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
latestSshTargetStateEventByTargetId.delete(data.targetId)
const latestStore = useAppStore.getState()
if (!targets.some((target) => target.id === data.targetId)) {
// Why: disconnect/state events can race after target removal.
// Treat absence from main's target list as deletion, not a new target.
latestStore.clearRemovedSshTargetState(data.targetId)
return
}
latestStore.setSshTargetsMetadata(targets)
applySshConnectionStateChange(data.targetId, state)
})
.catch(() => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
}
})
return
}
}
})
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
})
)
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
}
unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent))
let remoteWorkspaceClientId: string | null = null
let remoteWorkspaceClientIdPromise: Promise<string | null> | null = null
+6 -6
View File
@@ -137,6 +137,9 @@
"e3bcd082ac": "Connect to Orca",
"mobileScopeRejected": "This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host."
},
"web": {
"preload": {
"api": {
@@ -2588,8 +2591,7 @@
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.",
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
},
@@ -9648,9 +9650,7 @@
"logPath": "Log path",
"originalPaneUnavailable": "Original pane is no longer available.",
"worktreeUnavailable": "Worktree is no longer available.",
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.",
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
},
@@ -11729,7 +11729,7 @@
"f2d0c22d67": "Delete annotation {{value0}}",
"11c5084aa2": "Clear annotations",
"734e4343ec": "Clear browser annotations",
"95af781091": "Send feedback to a new agent",
"95af781091": "Send feedback to an agent",
"ac39b9366b": "Send",
"a3508d7e6e": "{{value0}} annotation{{value1}} ready. Select another element or copy all feedback.",
"f796c774a4": "Type a URL above to start browsing.",
+6 -6
View File
@@ -137,6 +137,9 @@
"e3bcd082ac": "Conectar con Orca",
"mobileScopeRejected": "Este código QR solo da acceso limitado para móvil. Para usar la app web completa, abre el enlace para navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución."
},
"web": {
"preload": {
"api": {
@@ -2588,8 +2591,7 @@
"localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.",
"openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.",
"sessionQueued": "Sesión en cola",
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.",
"openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.",
"openSupportedWorkspace": "Abre un espacio de trabajo antes de reanudar una sesión.",
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host."
},
@@ -9648,9 +9650,7 @@
"sessionsShownCompact": "{{value0}} mostradas",
"originalPaneUnavailable": "El panel original ya no está disponible.",
"worktreeUnavailable": "El worktree ya no está disponible.",
"runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.",
"runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.",
"openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.",
"openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.",
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.",
"sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla."
},
@@ -11729,7 +11729,7 @@
"f2d0c22d67": "Eliminar anotación {{value0}}",
"11c5084aa2": "Borrar anotaciones",
"734e4343ec": "Borrar anotaciones del navegador",
"95af781091": "Enviar comentarios a un nuevo agente",
"95af781091": "Enviar comentarios a un agente",
"ac39b9366b": "Enviar",
"a3508d7e6e": "{{value0}} anotación{{value1}} listo. Seleccione otro elemento o copie todos los comentarios.",
"f796c774a4": "Escriba una URL arriba para comenzar a navegar.",
+6 -6
View File
@@ -137,6 +137,9 @@
"e3bcd082ac": "Orca に接続",
"mobileScopeRejected": "この QR コードは制限付き(モバイル)アクセスを許可します。完全な Web アプリを使うには、設定 → リモート Orca サーバー → この Orca サーバーを共有する → 新規リンク からブラウザー用アクセスリンクを開いてください。"
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。"
},
"web": {
"preload": {
"api": {
@@ -2588,8 +2591,7 @@
"localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。",
"openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。",
"sessionQueued": "セッションをキューに追加しました",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。",
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。"
},
@@ -9648,9 +9650,7 @@
"sessionsShownCompact": "{{value0}} 件表示",
"originalPaneUnavailable": "元のペインは利用できなくなりました。",
"worktreeUnavailable": "ワークツリーは利用できなくなりました。",
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。",
"sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。"
},
@@ -11729,7 +11729,7 @@
"f2d0c22d67": "注釈 {{value0}} を削除",
"11c5084aa2": "注釈をクリアする",
"734e4343ec": "ブラウザの注釈をクリアする",
"95af781091": "新規 agent にフィードバックを送信する",
"95af781091": "agent にフィードバックを送信する",
"ac39b9366b": "送信",
"a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。",
"f796c774a4": "上に URL を入力して閲覧を開始します。",
+6 -6
View File
@@ -137,6 +137,9 @@
"e3bcd082ac": "Orca에 연결",
"mobileScopeRejected": "이 QR 코드는 제한된(모바일) 액세스 권한만 제공합니다. 전체 웹 앱을 사용하려면 설정 → 원격 Orca 서버 → 이 Orca 서버 공유 → 새 링크에서 브라우저 액세스 링크를 여세요."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다."
},
"web": {
"preload": {
"api": {
@@ -2588,8 +2591,7 @@
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
"openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.",
"sessionQueued": "세션이 대기열에 추가됨",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.",
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요."
},
@@ -9648,9 +9650,7 @@
"sessionsShownCompact": "{{value0}} 표시됨",
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.",
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
"openSupportedWorkspace": "Open a workspace before resuming a session.",
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.",
"sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요."
},
@@ -11729,7 +11729,7 @@
"f2d0c22d67": "주석 {{value0}} 삭제",
"11c5084aa2": "주석 지우기",
"734e4343ec": "브라우저 주석 지우기",
"95af781091": "새 agent에게 피드백 보내기",
"95af781091": "agent에게 피드백 보내기",
"ac39b9366b": "보내기",
"a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.",
"f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.",
+6 -6
View File
@@ -137,6 +137,9 @@
"e3bcd082ac": "连接到 Orca",
"mobileScopeRejected": "此二维码仅授予受限(移动端)访问权限。若要使用完整 Web 应用,请从设置 → 远程 Orca 服务器 → 分享此 Orca 服务器 → 新链接打开浏览器访问链接。"
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。"
},
"web": {
"preload": {
"api": {
@@ -2588,8 +2591,7 @@
"localWorkspacesOnly": "从历史恢复仅支持本地工作区。",
"openLocalWorkspace": "恢复会话前请先打开一个本地工作区。",
"sessionQueued": "会话已排队",
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。",
"sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。"
},
@@ -9648,9 +9650,7 @@
"sessionsShownCompact": "已显示 {{value0}} 个",
"originalPaneUnavailable": "原始面板不再可用。",
"worktreeUnavailable": "工作树不再可用。",
"runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。",
"runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。",
"sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。"
},
@@ -11729,7 +11729,7 @@
"f2d0c22d67": "删除注释 {{value0}}",
"11c5084aa2": "清晰的注释",
"734e4343ec": "清除浏览器注释",
"95af781091": "向新智能体发送反馈",
"95af781091": "向智能体发送反馈",
"ac39b9366b": "发送",
"a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。",
"f796c774a4": "在上面输入 URL 即可开始浏览。",
@@ -114,10 +114,33 @@ describe('ai vault session storage compatibility', () => {
).toBe(true)
})
it('never allows runtime or unknown targets', () => {
it('allows runtime sessions only on the matching runtime target', () => {
expect(
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' })
canResumeAiVaultSessionOnTarget({
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
sessionExecutionHostId: 'runtime:env-1',
targetStatus: 'runtime',
targetExecutionHostId: 'runtime:env-1'
})
).toBe(true)
expect(
canResumeAiVaultSessionOnTarget({
sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl',
sessionExecutionHostId: 'runtime:env-1',
targetStatus: 'runtime',
targetExecutionHostId: 'runtime:env-2'
})
).toBe(false)
expect(
canResumeAiVaultSessionOnTarget({
sessionFilePath: wslSessionFile,
targetStatus: 'runtime',
targetExecutionHostId: 'runtime:env-1'
})
).toBe(false)
})
it('never allows unknown targets', () => {
expect(
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' })
).toBe(false)
@@ -149,8 +172,11 @@ describe('ai vault resume target ownership', () => {
true
)
expect(
isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
isSupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
).toBe(true)
expect(
isUnsupportedAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
).toBe(false)
})
it('resolves runtime-owned worktree targets through their repo owner', () => {
+14 -4
View File
@@ -35,7 +35,7 @@ export function isSupportedAiVaultResumeRepo(
}
export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTargetStatus): boolean {
return status === 'local' || status === 'ssh'
return status === 'local' || status === 'ssh' || status === 'runtime'
}
export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean {
@@ -48,11 +48,20 @@ export function canResumeAiVaultSessionOnTarget(args: {
targetStatus: AiVaultResumeTargetStatus
targetExecutionHostId?: ExecutionHostId | null
}): boolean {
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
if (args.targetStatus === 'runtime') {
// Runtime session stores live on one paired server; only queue resumes back
// onto that exact server host.
return Boolean(
sessionExecutionHostId &&
targetExecutionHostId &&
sessionExecutionHostId === targetExecutionHostId
)
}
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
return false
}
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
if (sessionExecutionHostId) {
if (targetExecutionHostId) {
if (sessionExecutionHostId === targetExecutionHostId) {
@@ -84,7 +93,8 @@ export function canResumeAiVaultSessionOnTarget(args: {
export function isUnsupportedAiVaultResumeRepo(
repo: AiVaultResumeRepoOwner | null | undefined
): boolean {
return getAiVaultResumeRepoTargetStatus(repo) === 'runtime'
const status = getAiVaultResumeRepoTargetStatus(repo)
return status !== 'unknown' && !isSupportedAiVaultResumeTargetStatus(status)
}
export function getAiVaultResumeWorktreeTargetStatus(args: {
@@ -9,6 +9,7 @@ const mockPasteDraftWhenAgentReady = vi.fn()
const mockSeedNativeChatLaunchPrompt = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockTrack = vi.fn()
const mockToastMessage = vi.fn()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -80,7 +81,7 @@ vi.mock('@/store', () => ({
const mockToastError = vi.fn()
vi.mock('sonner', () => ({
toast: { message: vi.fn(), error: mockToastError }
toast: { message: mockToastMessage, error: mockToastError }
}))
vi.mock('@/components/tab-bar/reconcile-order', () => ({
@@ -545,7 +546,7 @@ describe('launchAgentInNewTab', () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const prompt = 'x'.repeat(25_000)
launchAgentInNewTab({
const result = launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
prompt,
@@ -553,6 +554,7 @@ describe('launchAgentInNewTab', () => {
launchPlatform: 'win32'
})
expect(result).not.toHaveProperty('promptDeliveryResult')
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
'tab-1',
expect.objectContaining({
@@ -570,16 +572,46 @@ describe('launchAgentInNewTab', () => {
)
})
it('logs rejected non-deferred prompt delivery without exposing it to callers', async () => {
const error = new Error('paste failed')
const originalConsole = console
const consoleError = vi.fn()
vi.stubGlobal('console', { ...originalConsole, error: consoleError })
mockPasteDraftWhenAgentReady.mockRejectedValue(error)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const prompt = 'x'.repeat(25_000)
try {
const result = launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
prompt,
promptDelivery: 'draft',
launchPlatform: 'win32'
})
expect(result).not.toHaveProperty('promptDeliveryResult')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(consoleError).toHaveBeenCalledWith('Prompt delivery failed after launch', error)
} finally {
vi.stubGlobal('console', originalConsole)
}
})
it('seeds working after Command Code submit-after-ready prompt delivery', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
store.terminalLayoutsByTabId = { 'tab-1': { activeLeafId: LEAF_ID } }
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
await Promise.resolve()
await Promise.resolve()
@@ -610,17 +642,114 @@ describe('launchAgentInNewTab', () => {
mockPasteDraftWhenAgentReady.mockResolvedValue(false)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: false
})
await Promise.resolve()
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('marks failed submit-after-ready delivery as notified after readiness timeout toast', async () => {
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
onTimeout?.()
return Promise.resolve(false)
})
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mockToastMessage).toHaveBeenCalledWith(
"Your prompt wasn't sent — paste it once the agent is ready."
)
})
it('marks a cancelled submit-after-ready launch notified when the user closed the tab', async () => {
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
onTimeout?.()
return Promise.resolve(false)
})
// User closed the tab before the agent became ready — it is gone from the list.
store.tabsByWorktree = { 'wt-1': [] }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('marks a cancelled submit-after-ready launch notified when the user switched worktrees', async () => {
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
onTimeout?.()
return Promise.resolve(false)
})
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] }
store.activeWorktreeId = 'wt-2'
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('leaves a genuine launch failure unnotified so the caller surfaces it', async () => {
mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => {
onTimeout?.()
return Promise.resolve(false)
})
// PTY never spawned: a real failure, not a user cancellation.
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null } as never] }
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'command-code',
worktreeId: 'wt-1',
prompt: 'large generated prompt',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: false
})
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('queues per-launch CLI arguments without putting generated prompts in argv', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
+33 -18
View File
@@ -69,6 +69,7 @@ export type LaunchAgentInNewTabResult = {
tabId: string | null
startupPlan: AgentStartupPlan
pasteDraftAfterLaunch: boolean
promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }>
} | null
/**
@@ -153,6 +154,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
let pasteDraftAfterLaunch: string | null = null
let submitPastedPrompt = false
let forcePasteAfterLaunch = false
let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined
if (hasPrompt && promptDelivery === 'submit-after-ready') {
// Why: generated multi-line prompts are too large to echo through a shell
@@ -300,9 +302,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
// the user closed the tab or switched worktrees so the toast/telemetry
// don't fire for user-initiated cancellation (mirrors the 5s launch
// watchdog in QuickLaunchButton).
const tabId = tab.id
void deliverLaunchPromptToAgentTab({
tabId,
let failureNotified = false
const deliveryPromise = deliverLaunchPromptToAgentTab({
tabId: tab.id,
content: pasteDraftAfterLaunch,
agent,
submit: submitPastedPrompt,
@@ -310,28 +312,28 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
onTimeout: () => {
const state = useAppStore.getState()
const tabsForWorktree = state.tabsByWorktree[worktreeId] ?? []
const tab = tabsForWorktree.find((t) => t.id === tabId)
// Why: if the PTY never spawned, QuickLaunch's 5s watchdog already
// surfaced the launch failure. Don't double-toast for the same root
// cause. Looking up directly in `worktreeId` (not scanning every
// worktree) also preserves "still in this worktree" intent.
if (!tab) {
return // tab closed by user
const currentTab = tabsForWorktree.find((t) => t.id === tab.id)
if (currentTab?.ptyId === null) {
// Why: PTY never spawned — a genuine launch failure. Stay silent so
// the single notice comes from the caller (source-control dialog
// toast, or QuickLaunch's watchdog); leaving failureNotified false lets it fire.
return
}
if (tab.ptyId === null) {
return // launch failed; QuickLaunch handled the user-facing toast
}
if (state.activeWorktreeId !== worktreeId) {
if (!currentTab || state.activeWorktreeId !== worktreeId) {
// Why: user-initiated cancellation (closed the tab or switched
// worktrees) — mark notified so the deferred source-control caller
// suppresses its generic "couldn't start" toast too, not just this nudge.
failureNotified = true
return
}
const label = submitPastedPrompt ? 'prompt' : 'notes'
toast.message(
translate(
'auto.lib.launch.agent.in.new.tab.a5a1f7033f',
"Your {{value0}} wasn't sent — paste it once the agent is ready.",
{ value0: label }
{ value0: submitPastedPrompt ? 'prompt' : 'notes' }
)
)
failureNotified = true
track('agent_error', {
error_class: 'paste_readiness_timeout',
agent_kind: tuiAgentToAgentKind(agent)
@@ -342,11 +344,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
if (agent === 'command-code' && submitPastedPrompt) {
// Why: Command Code has no prompt-submit hook; when Orca submits a
// generated prompt after readiness, seed working at delivery time.
seedCommandCodeSubmittedPromptStatus(tabId, trimmedPrompt)
seedCommandCodeSubmittedPromptStatus(tab.id, trimmedPrompt)
}
onPromptDelivered?.()
}
return { delivered, failureNotified: !delivered && failureNotified }
})
if (promptDelivery === 'submit-after-ready') {
promptDeliveryResult = deliveryPromise
} else {
void deliveryPromise.catch((error) =>
console.error('Prompt delivery failed after launch', error)
)
}
} else if (hasPrompt) {
onPromptDelivered?.()
}
@@ -373,5 +383,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
order.push(tab.id)
fresh.setTabBarOrder(worktreeId, order)
return { tabId: tab.id, startupPlan, pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null }
return {
tabId: tab.id,
startupPlan,
pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null,
...(promptDeliveryResult ? { promptDeliveryResult } : {})
}
}
@@ -5,6 +5,11 @@ const mockCreateEmptySplitGroup = vi.fn()
const mockQueueTabStartupCommand = vi.fn()
const mockSetActiveTabType = vi.fn()
const mockSetTabBarOrder = vi.fn()
const runtimeMocks = vi.hoisted(() => ({
createWebRuntimeSessionTerminal: vi.fn(),
getRuntimeEnvironmentIdForWorktree: vi.fn<() => string | null>(() => null),
isWebRuntimeSessionActive: vi.fn(() => false)
}))
const mockState = {
createTab: mockCreateTab,
@@ -37,11 +42,23 @@ vi.mock('@/lib/telemetry', () => ({
tuiAgentToAgentKind: (agent: string) => agent
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: runtimeMocks.getRuntimeEnvironmentIdForWorktree
}))
vi.mock('@/runtime/web-runtime-session', () => ({
createWebRuntimeSessionTerminal: runtimeMocks.createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive: runtimeMocks.isWebRuntimeSessionActive
}))
import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session'
describe('launchAiVaultSessionInNewTab', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null)
runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false)
runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue(true)
mockState.tabsByWorktree = {}
mockState.openFiles = []
mockState.browserTabsByWorktree = {}
@@ -118,4 +135,45 @@ describe('launchAiVaultSessionInNewTab', () => {
expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', 'group-new')
})
it('creates runtime-hosted resume terminals through the paired host', async () => {
runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('env-1')
runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true)
const result = launchAiVaultSessionInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
targetGroupId: 'group-1',
command: "codex resume 'session-1'",
env: { CODEX_PROFILE: 'runtime' },
launchConfig: {
agentCommand: 'codex',
agentArgs: '',
agentEnv: { CODEX_PROFILE: 'runtime' }
}
})
expect(result.tabId).toBeNull()
expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'env-1',
targetGroupId: 'group-1',
command: "codex resume 'session-1'",
env: { CODEX_PROFILE: 'runtime' },
launchConfig: {
agentCommand: 'codex',
agentArgs: '',
agentEnv: { CODEX_PROFILE: 'runtime' }
},
launchAgent: 'codex',
activate: true
})
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockQueueTabStartupCommand).not.toHaveBeenCalled()
if (result.tabId === null) {
await expect(result.runtimeLaunch).resolves.toBe(true)
}
expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal')
})
})
@@ -1,10 +1,19 @@
import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import type { AiVaultAgent } from '../../../shared/ai-vault-types'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { TabSplitDirection } from '@/store/slices/tabs'
export type LaunchAiVaultSessionInNewTabResult =
| { tabId: string; groupId?: string }
| { tabId: null; groupId?: string; runtimeLaunch: Promise<boolean> }
export function launchAiVaultSessionInNewTab(args: {
agent: AiVaultAgent
worktreeId: string
@@ -13,9 +22,33 @@ export function launchAiVaultSessionInNewTab(args: {
launchConfig?: SleepingAgentLaunchConfig
targetGroupId?: string
splitDirection?: TabSplitDirection
}): { tabId: string; groupId?: string } {
}): LaunchAiVaultSessionInNewTabResult {
const store = useAppStore.getState()
let targetGroupId = args.targetGroupId
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, args.worktreeId)
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
const runtimeLaunch = createWebRuntimeSessionTerminal({
worktreeId: args.worktreeId,
environmentId: runtimeEnvironmentId,
...(targetGroupId ? { targetGroupId } : {}),
command: args.command,
...(args.env ? { env: args.env } : {}),
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
launchAgent: args.agent,
activate: true
}).then((created) => {
if (created) {
useAppStore.getState().setActiveTabType('terminal')
}
return created
})
return {
tabId: null,
...(targetGroupId ? { groupId: targetGroupId } : {}),
runtimeLaunch
}
}
if (args.splitDirection && targetGroupId) {
targetGroupId =
store.createEmptySplitGroup(args.worktreeId, targetGroupId, args.splitDirection) ??

Some files were not shown because too many files have changed in this diff Show More