diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 4afaca9293c..67a50e6b5b7 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.9m + + downloads: 4.0m @@ -15,7 +15,7 @@ downloads downloads - 3.9m - 3.9m + 4.0m + 4.0m diff --git a/package.json b/package.json index a3a4d50c3e7..8d8b7f14251 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts new file mode 100644 index 00000000000..ffb2a1c398f --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -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}` + } +} diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts new file mode 100644 index 00000000000..7a700892261 --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -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 { + 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() + } +} diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts new file mode 100644 index 00000000000..ea21dc590f6 --- /dev/null +++ b/src/main/ai-vault/session-list-results.ts @@ -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() + 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() + } +} diff --git a/src/main/index.ts b/src/main/index.ts index e9270e16478..e6b0323b4fd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 9cc8060eee2..0fae2259f12 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -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, diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 4551379afde..94a8de0c475 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -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 +} + +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 | 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 { + 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 { @@ -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() - 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 { diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e1149d1b6d1..1eb9bbb17a2 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -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; stdout: EventEmitter } { + const { stdout, code = 0, error, pid = 4242, hang = false } = options + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter & { setEncoding: ReturnType } + } + child.pid = pid + child.kill = vi.fn() + const stdoutStream = new EventEmitter() as EventEmitter & { + setEncoding: ReturnType + } + 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 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 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 () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 276c3ba5f71..2dc3c81f91b 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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 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 @@ -118,13 +120,32 @@ function readCommandStdout( ): Promise { return new Promise((resolve, reject) => { let settled = false - let child: ReturnType | undefined + let child: ReturnType | 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 { 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) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 25894ffb723..072f8239dcb 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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((_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 + + 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() + }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 85c0ce56e31..7e7006a375e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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() ipcMain.handle( 'fs:listFiles', async ( _event, - args: { rootPath: string; connectionId?: string; excludePaths?: string[] } + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + } ): Promise => { - 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', diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 172114ce6aa..d54fd316271 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 ') 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 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 + 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 diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f63b43719f0..bdd4cc13b77 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 0b7c4e07146..6a73db56da6 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 50dbd0a8ff4..f651cd002d7 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index a1db12efa51..0c5a51d9ae2 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -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 () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 1fa15be1df4..8b5756bff38 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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 { + return sshStore?.listRemovedTargetLabels() ?? {} +} + export async function disconnectRegisteredSshTarget(targetId: string): Promise { 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 { + 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) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ca2248cbeaa..52f826935d4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 4ff4971933b..b10fa4fe910 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 978d3e94b2a..99d52002dec 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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 = { diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 6d4170209e7..d35460bd489 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -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', () => { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 3adeeec6cb8..ce5256d2744 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -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 { + async listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise { const params: Record = { 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> { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 28d5d1d9447..c8e2680366b 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -197,7 +197,10 @@ export type IFilesystemProvider = { copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise - listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index d68f4014b23..7d0eeb5b4e3 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -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//... for wsl.exe', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 98b119d8597..3889edfc5c1 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -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"', diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index ec210e5db3b..9d7c36c6cfb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => { it('marks OMP status and hook env for Windows to WSL import', () => { const env: Record = { 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') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 2e4a878f52b..40a694a87ed 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): 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', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 62e9cbe7d62..ced9387d46f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [ + { + 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) => { + 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:` even with a null executionHostId, + // so a same-path runtime import must create a new repo instead of adopting it. + const repos: Record[] = [ + { + 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) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [ + { + id: 'repo-local-1', + path: '/workspace', + displayName: 'workspace', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [ + { + 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) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + 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[] = [] 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 + 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 + } + 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 + } + 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() + } + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12b6d6036d9..c245c9d8307 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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:), so it must not be adopted/matched by a runtime host at the same path. +function runtimeRepoMatchesExecutionHost( + repo: Pick, + 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 { 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 { - 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 { + async addRepo( + path: string, + kind: 'git' | 'folder' = 'git', + executionHostId?: ExecutionHostId | null + ): Promise { 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 { + async cloneRepo( + url: string, + destination: string, + executionHostId?: ExecutionHostId | null + ): Promise { 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 { 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 { const mutationId = opts.clientMutationId @@ -16239,6 +16335,7 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { 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 { 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((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 { diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts new file mode 100644 index 00000000000..c7cc56a6490 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts new file mode 100644 index 00000000000..49591ea1e40 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -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 + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 8018375a8a0..bc0a73985f9 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index f2bf1641bda..d27d771f321 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 94791a7fdd3..8efab508345 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -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 } }) + }) }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e4d836cedff..6de8634a881 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -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() }) }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4ebe781495f..a750438e03e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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) diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index ab255856216..59e4d2d1b29 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -41,7 +41,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { >() const cleanups = new Map 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:~$ ', diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 5ceee818dbb..69e956aa155 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -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 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 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 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 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 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((_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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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() diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 82e47100088..b778f814977 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -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' }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a5418500c1d..c2f949c2733 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 8c56ae145b0..10123d56a25 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -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') diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 09642387ee8..8cb0a24f302 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record) => voi export type RequestHandler = (params: Record) => Promise | 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() + // 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 { + if (this.disposed) { + return Promise.resolve(false) + } + return new Promise((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 diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts new file mode 100644 index 00000000000..09be7c1f686 --- /dev/null +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -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 +} { + 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) + }) +}) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 3746308db36..6404d035f22 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -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 + timer: ReturnType | 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 @@ -17,6 +37,8 @@ type ScanHandle = { export class PortScanner { private handles = new Map() + 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 => { 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 => { + 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) } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 0dfa2edc8d8..614c121d56a 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2462bbd94c3..4b29d7f7ea3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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 diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts new file mode 100644 index 00000000000..fa6507d6260 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -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 => ({ + id: 'tab1', + ptyId: null, + worktreeId: 'r1::/wt', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides +}) + +const makeSession = (overrides: Partial = {}): 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 => + ({ ...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) + }) +}) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts new file mode 100644 index 00000000000..a917c4393e9 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -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:@@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 "" 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 | 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 +} diff --git a/src/main/window/main-window-visibility.test.ts b/src/main/window/main-window-visibility.test.ts new file mode 100644 index 00000000000..335dc4a87d6 --- /dev/null +++ b/src/main/window/main-window-visibility.test.ts @@ -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) + }) +}) diff --git a/src/main/window/main-window-visibility.ts b/src/main/window/main-window-visibility.ts new file mode 100644 index 00000000000..8b4ca4c4647 --- /dev/null +++ b/src/main/window/main-window-visibility.ts @@ -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() + +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 +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 35ac8802402..78d7eb38e79 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2247,7 +2247,9 @@ export type PreloadApi = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }) => Promise + cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise importExternalPaths: (args: { sourcePaths: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index b3b6a2978db..e47ae057cd8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2648,7 +2648,10 @@ const api = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), search: (args: { query: string rootPath: string diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index da1f67c7ffa..8a98b9fa444 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -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 { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } const gitPaths = new Set() 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) + }) } /** diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts new file mode 100644 index 00000000000..02cd8da6afc --- /dev/null +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -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).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + ;(p as unknown as Record).exitCode = null + ;(p as unknown as Record).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() + }) +}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 99e3eb97ff9..bbe7912ee61 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -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 { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } return new Promise((resolve, reject) => { const files = new Set() 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))) }) }) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index e49609996d1..d7c9172183b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -23,10 +23,12 @@ import { */ export async function listFilesWithReaddir( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, - budget: createQuickOpenReaddirBudget() + budget: createQuickOpenReaddirBudget(), + signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index c6564c51fbe..f6417b38d3c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -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() 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): Promise { + private listFiles(params: Record, context?: RequestContext): Promise { 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 { 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)) } } diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts new file mode 100644 index 00000000000..588ddb5fe51 --- /dev/null +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -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((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 + 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 { + // 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']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.test.ts b/src/relay/fs-list-files-scan-coordinator.test.ts new file mode 100644 index 00000000000..a142c120cdb --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.test.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 + 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((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 + 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 + }) + ).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']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.ts b/src/relay/fs-list-files-scan-coordinator.ts new file mode 100644 index 00000000000..6e6e30c5f0d --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.ts @@ -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 + attachedCount: number +} + +export class ListFilesScanCoordinator { + private readonly scansByClient = new Map() + + run(opts: { + clientId: number + key: string + signal?: AbortSignal + start: (signal: AbortSignal) => Promise + }): Promise { + 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 { + 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((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)) + ) + }) + } +} diff --git a/src/relay/relay-diagnostic-log.test.ts b/src/relay/relay-diagnostic-log.test.ts new file mode 100644 index 00000000000..38adbcb7669 --- /dev/null +++ b/src/relay/relay-diagnostic-log.test.ts @@ -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 + // " " 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() + } + }) +}) diff --git a/src/relay/relay-diagnostic-log.ts b/src/relay/relay-diagnostic-log.ts new file mode 100644 index 00000000000..a42106101d9 --- /dev/null +++ b/src/relay/relay-diagnostic-log.ts @@ -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`) +} diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index 6db263bcc92..fb64c57ec16 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -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( diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 99225227903..65f90e88628 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { : 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 { } 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 { // 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) }) diff --git a/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx b/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx new file mode 100644 index 00000000000..fec2f84c084 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx @@ -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 +} + +vi.mock('@/components/editor/ReviewNotesSendMenuContent', () => ({ + ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record) { + 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> + if (typeof element.type === 'function') { + const Component = element.type as (props: Record) => 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( + + ) + + 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(/ void +} + +export function BrowserAnnotationSendMenuContent({ + worktreeId, + groupId, + prompt, + onPromptDelivered +}: BrowserAnnotationSendMenuContentProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 6931cf21465..0d75bc980a4 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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({ {translate( 'auto.components.browser.pane.BrowserPane.95af781091', - 'Send feedback to a new agent' + 'Send feedback to an agent' )} @@ -5307,13 +5306,10 @@ function BrowserPagePane({ onInteractOutside={preventAgentSendTargetOutsideDismiss} onPointerDownOutside={preventAgentSendTargetOutsideDismiss} > - @@ -5571,7 +5567,7 @@ function BrowserPagePane({ {translate( 'auto.components.browser.pane.BrowserPane.95af781091', - 'Send feedback to a new agent' + 'Send feedback to an agent' )} @@ -5581,13 +5577,10 @@ function BrowserPagePane({ onInteractOutside={preventAgentSendTargetOutsideDismiss} onPointerDownOutside={preventAgentSendTargetOutsideDismiss} > - diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index ff76e430553..4a2305e06e3 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -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) + + 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) + }) }) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 9a7ffe8d596..13e3738e226 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -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]) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 8442e73f54e..4a6bccec9db 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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(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 ? ( -
- {translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeBrowseLocalHistory', - 'Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.' - )} -
- ) : null} - {error ? (
{error} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 2b3cc2802b0..b64308b943e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -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 ( @@ -224,9 +216,9 @@ export function VaultHostScopeMenu({ value={executionHostScope} onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)} > - {hostOptions.map((scope) => ( - - {getExecutionHostLabel(scope)} + {hostOptions.map((option) => ( + + {option.label} ))} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index b463e188a7e..b3ec2ba36e3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -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({
{ 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 = () => {} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts index 73af6ab915e..bfd98ca51b0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -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 @@ -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 { 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' } + ]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts index 8993e8a00e6..0acb4f54644 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -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(defaultExecutionHostScope) @@ -36,7 +48,8 @@ export function useAiVaultExecutionHostScope(args: { const allowedScopes = new Set([ 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[] +): 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() + 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 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index c55acb1e939..78daad963d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -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 ): 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.' ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 5e8bc3f4242..381b1d49c73 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -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' }) }) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index 61924494157..ec89aa87288 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -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({ diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts new file mode 100644 index 00000000000..345f1b6a09b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -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[0]> = {} +): Parameters[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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index f180cc56dfb..ee6c24e326c 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -62,6 +62,7 @@ export async function runSourceControlAgentActionStart({ onClose }: RunSourceControlAgentActionStartArgs): Promise { 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 } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index af09c852cab..2ae7765022a 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -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] diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e73d1964619..7bdb5d20392 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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--. - 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} diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index 4653b88e46c..7f46a518153 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -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) => fallback.replace('{{value0}}', values?.value0 ?? '') })) -function installSshConnect(connect: ReturnType): void { +function installSshConnect( + connect: ReturnType, + overrides: Record> = {} +): 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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', diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index b2be21b6627..b3ee59833c5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -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 (
{ }) }) + 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', { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 9caf6210475..e9a8f361f5b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -182,6 +182,16 @@ export function createRemoteRuntimePtyTransport( return null } + async function listHostSessionHandle(hostTabId: string): Promise { + if (!worktreeId) { + return null + } + const listed = await callRuntime('session.tabs.list', { + worktree: toRuntimeWorktreeSelector(worktreeId) + }) + return findReadyHostSessionHandle(listed, hostTabId) + } + async function attachHostSessionMirror( options: Parameters[0] ): Promise { @@ -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 { + 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 { 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 }, diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index b86a258bdc6..0623acbba53 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3b327b924d4..e318b7120d2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() - 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 | null = null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a51b6f4c6a..d0dc2e8dea5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 95f2fd339b8..8923d561dfb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 158ec815bd1..e5bdf33842f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 を入力して閲覧を開始します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aba282e254e..db8a2aea5d1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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을 입력하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 582ad7e30de..d6cce4de202 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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 即可开始浏览。", diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 207bc525a59..f7903f13000 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 823840ec85b..f52e26c62e8 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -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: { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fd4dcf004c0..fd25fc85df6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -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') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 2f06dca71a9..50397347791 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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 } : {}) + } } diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index e6a1c083a76..ef0425b58e6 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index a22f00271c8..e04e353452e 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -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 } + 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) ?? diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index 70acb400662..3d388888498 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -55,4 +55,47 @@ describe('subscribeRuntimeClientEvents', () => { subscription.unsubscribe() expect(unsubscribe).toHaveBeenCalledTimes(1) }) + + it('signals a replay-tagged response so event-derived state can resync after a reconnect', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + const onEvent = vi.fn() + const onReplayed = vi.fn() + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { subscribe } + } + }) + + await subscribeRuntimeClientEvents('env-1', onEvent, vi.fn(), onReplayed) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ + ok: true, + result: { type: 'ready', subscriptionId: 'sub-1' } + }) + expect(onReplayed).not.toHaveBeenCalled() + + capturedOnResponse({ + ok: true, + result: { type: 'ready', subscriptionId: 'sub-1' }, + _replayedAfterReconnect: true + }) + expect(onReplayed).toHaveBeenCalledTimes(1) + + // A replay-tagged event frame both signals and still delivers the event. + capturedOnResponse({ + ok: true, + result: { type: 'worktreesChanged', repoId: 'repo-1' }, + _replayedAfterReconnect: true + }) + expect(onReplayed).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' }) + }) }) diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 51827546a2e..adbdc232433 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -3,6 +3,7 @@ import type { RuntimeClientEventStreamMessage } from '../../../shared/runtime-client-events' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' export type RuntimeClientEventSubscription = { unsubscribe: () => void @@ -11,7 +12,12 @@ export type RuntimeClientEventSubscription = { export async function subscribeRuntimeClientEvents( environmentId: string, onEvent: (event: RuntimeClientEvent) => void, - onError: (error: unknown) => void = console.warn + onError: (error: unknown) => void = console.warn, + // Why: client events emitted while the shared-control transport was down are + // lost, not queued. The replay tag on the first post-reconnect response is + // the renderer's only signal that mirrored event-derived state (e.g. the + // per-environment SSH bucket) may have missed transitions and must resync. + onReplayedAfterReconnect?: () => void ): Promise { const handle = await window.api.runtimeEnvironments.subscribe( { @@ -21,7 +27,7 @@ export async function subscribeRuntimeClientEvents( }, { onResponse: (response) => { - handleRuntimeClientEventResponse(response, onEvent, onError) + handleRuntimeClientEventResponse(response, onEvent, onError, onReplayedAfterReconnect) }, onError } @@ -32,12 +38,16 @@ export async function subscribeRuntimeClientEvents( function handleRuntimeClientEventResponse( response: RuntimeRpcResponse, onEvent: (event: RuntimeClientEvent) => void, - onError: (error: unknown) => void + onError: (error: unknown) => void, + onReplayedAfterReconnect?: () => void ): void { if (response.ok === false) { onError(response.error) return } + if (isRuntimeSubscriptionReplayResponse(response)) { + onReplayedAfterReconnect?.() + } const message = response.result as RuntimeClientEventStreamMessage if (message.type === 'ready' || message.type === 'end') { return @@ -53,6 +63,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' ) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts new file mode 100644 index 00000000000..1e2a9939ba2 --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnectionState } from '../../../shared/ssh-types' +import { useAppStore } from '@/store' +import { + applyRuntimeEnvironmentSshStateChanged, + connectRuntimeEnvironmentSshTarget, + hydrateRuntimeEnvironmentSshState, + resyncRuntimeEnvironmentSshTargets +} from './runtime-environment-ssh-state' +import { callRuntimeRpc } from './runtime-rpc-client' + +vi.mock('./runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc: vi.fn() +})) + +const callRuntimeRpcMock = vi.mocked(callRuntimeRpc) + +function connState( + targetId: string, + status: SshConnectionState['status'] = 'connected' +): SshConnectionState { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +type RpcResponses = { + targets?: { id: string; label: string }[] + labels?: Record + states?: Record + failListTargets?: boolean + failRemovedLabels?: boolean +} + +function installRpcResponses(responses: RpcResponses): void { + callRuntimeRpcMock.mockImplementation((_target, method, params) => { + switch (method) { + case 'ssh.listTargets': + if (responses.failListTargets) { + return Promise.reject(new Error('method not found')) + } + return Promise.resolve({ targets: responses.targets ?? [] } as never) + case 'ssh.listRemovedTargetLabels': + if (responses.failRemovedLabels) { + return Promise.reject(new Error('method not found')) + } + return Promise.resolve({ labels: responses.labels ?? {} } as never) + case 'ssh.getState': { + const targetId = (params as { targetId: string }).targetId + return Promise.resolve({ state: responses.states?.[targetId] ?? null } as never) + } + default: + return Promise.reject(new Error(`unexpected method ${method}`)) + } + }) +} + +let envCounter = 0 +function nextEnvId(): string { + envCounter += 1 + return `env-${envCounter}` +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + callRuntimeRpcMock.mockReset() +}) + +describe('hydrateRuntimeEnvironmentSshState', () => { + it('populates the environment bucket with targets, tombstones, and per-target states', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [ + { id: 'ssh-1', label: 'devbox' }, + { id: 'ssh-2', label: 'buildbox' } + ], + labels: { 'ssh-old': 'retired box' }, + states: { 'ssh-1': connState('ssh-1', 'connected') } + }) + + await hydrateRuntimeEnvironmentSshState(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') + expect(bucket?.removedTargetLabels.get('ssh-old')).toBe('retired box') + expect(bucket?.connectionStates.get('ssh-1')?.status).toBe('connected') + // ssh-2 had no live state: absent, so reads fall back to 'disconnected'. + expect(bucket?.connectionStates.has('ssh-2')).toBe(false) + // Local maps stay untouched. + expect(useAppStore.getState().sshTargetLabels.size).toBe(0) + expect(useAppStore.getState().sshTargetsHydrated).toBe(false) + // Every call was routed to the owning environment. + for (const [target] of callRuntimeRpcMock.mock.calls) { + expect(target).toEqual({ kind: 'environment', environmentId: envId }) + } + }) + + it('skips refetching when already hydrated unless forced', async () => { + const envId = nextEnvId() + installRpcResponses({ targets: [] }) + await hydrateRuntimeEnvironmentSshState(envId) + const callsAfterFirst = callRuntimeRpcMock.mock.calls.length + + await hydrateRuntimeEnvironmentSshState(envId) + expect(callRuntimeRpcMock.mock.calls.length).toBe(callsAfterFirst) + + await hydrateRuntimeEnvironmentSshState(envId, { force: true }) + expect(callRuntimeRpcMock.mock.calls.length).toBeGreaterThan(callsAfterFirst) + }) + + it('leaves the bucket un-hydrated when the host lacks the ssh RPC methods', async () => { + const envId = nextEnvId() + installRpcResponses({ failListTargets: true }) + + await expect(hydrateRuntimeEnvironmentSshState(envId)).rejects.toThrow() + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated ?? false).toBe(false) + }) + + it('still hydrates the target list when the removed-labels fetch fails', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-1', label: 'devbox' }], + failRemovedLabels: true + }) + + await hydrateRuntimeEnvironmentSshState(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') + }) +}) + +describe('applyRuntimeEnvironmentSshStateChanged', () => { + it('applies a known target state directly into the owning bucket without RPC', () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', connState('ssh-1', 'disconnected')) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status + ).toBe('disconnected') + expect(callRuntimeRpcMock).not.toHaveBeenCalled() + // Local map untouched. + expect(useAppStore.getState().sshConnectionStates.size).toBe(0) + }) + + it('does not touch another environment bucket or local state (no cross-pollution)', () => { + const envA = nextEnvId() + const envB = nextEnvId() + useAppStore.getState().setEnvironmentSshTargetsMetadata(envA, [{ id: 'ssh-1', label: 'a' }]) + useAppStore.getState().setEnvironmentSshTargetsMetadata(envB, [{ id: 'ssh-1', label: 'b' }]) + const bucketBBefore = useAppStore.getState().sshStateByEnvironment.get(envB) + const localStatesBefore = useAppStore.getState().sshConnectionStates + + applyRuntimeEnvironmentSshStateChanged(envA, 'ssh-1', connState('ssh-1', 'connected')) + + const state = useAppStore.getState() + expect(state.sshStateByEnvironment.get(envA)?.connectionStates.get('ssh-1')?.status).toBe( + 'connected' + ) + expect(state.sshStateByEnvironment.get(envB)).toBe(bucketBBefore) + expect(state.sshStateByEnvironment.get(envB)?.connectionStates.size).toBe(0) + expect(state.sshConnectionStates).toBe(localStatesBefore) + expect(state.sshConnectionStates.size).toBe(0) + }) + + it('re-fetches the authoritative target list for an unknown target instead of trusting the event', async () => { + const envId = nextEnvId() + // The event races a removal: the authoritative list does not contain it. + installRpcResponses({ targets: [], labels: { 'ssh-gone': 'old devbox' } }) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-gone', connState('ssh-gone', 'disconnected')) + await vi.waitFor(() => { + expect(useAppStore.getState().sshStateByEnvironment.get(envId)?.targetsHydrated).toBe(true) + }) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + // The trailing event must not resurrect the removed target's state. + expect(bucket?.connectionStates.has('ssh-gone')).toBe(false) + expect(bucket?.removedTargetLabels.get('ssh-gone')).toBe('old devbox') + }) + + it('picks up a just-added target through the forced refresh', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'fresh box' }], + states: { 'ssh-new': connState('ssh-new', 'connecting') } + }) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-new', connState('ssh-new', 'connecting')) + await vi.waitFor(() => { + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.targetLabels.get('ssh-new') + ).toBe('fresh box') + }) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-new') + ?.status + ).toBe('connecting') + }) +}) + +describe('connectRuntimeEnvironmentSshTarget', () => { + it('routes ssh.connect to the owning environment and mirrors the returned state', async () => { + const envId = nextEnvId() + callRuntimeRpcMock.mockResolvedValue({ state: connState('ssh-1', 'connected') } as never) + + const state = await connectRuntimeEnvironmentSshTarget(envId, 'ssh-1') + + expect(state?.status).toBe('connected') + expect(callRuntimeRpcMock).toHaveBeenCalledWith( + { kind: 'environment', environmentId: envId }, + 'ssh.connect', + { targetId: 'ssh-1' }, + expect.anything() + ) + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status + ).toBe('connected') + expect(useAppStore.getState().sshConnectionStates.size).toBe(0) + }) + + it('propagates connect failures without writing any state', async () => { + const envId = nextEnvId() + callRuntimeRpcMock.mockRejectedValue(new Error('SSH target not found')) + + await expect(connectRuntimeEnvironmentSshTarget(envId, 'ssh-dead')).rejects.toThrow( + 'SSH target not found' + ) + expect(useAppStore.getState().sshStateByEnvironment.has(envId)).toBe(false) + }) +}) + +describe('resyncRuntimeEnvironmentSshTargets', () => { + it('applies the target list even when the removed-labels refresh fails', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-live', label: 'devbox' }], + failRemovedLabels: true + }) + + await resyncRuntimeEnvironmentSshTargets(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetLabels.get('ssh-live')).toBe('devbox') + expect(bucket?.targetsHydrated).toBe(true) + }) +}) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts new file mode 100644 index 00000000000..263d444832c --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -0,0 +1,165 @@ +import { useAppStore } from '@/store' +import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' +import { callRuntimeRpc } from './runtime-rpc-client' + +/** + * Mirrors a remote Orca server's own SSH targets into that environment's + * per-environment SSH bucket (store slice `runtime-environment-ssh`), so a + * desktop client attached to the server gets live reconnect overlays for the + * server's SSH-backed workspaces (STA-1468, desktop topology). Never touches + * the local SSH maps. + */ + +const SSH_RPC_TIMEOUT_MS = 15_000 + +function environmentTarget(environmentId: string): { kind: 'environment'; environmentId: string } { + return { kind: 'environment', environmentId } +} + +async function fetchEnvironmentSshTargets(environmentId: string): Promise { + const { targets } = await callRuntimeRpc<{ targets: SshTarget[] }>( + environmentTarget(environmentId), + 'ssh.listTargets', + undefined, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + return targets +} + +/** Applies the environment's target list, then best-effort removal tombstones. + * Targets land first — a removed-labels failure must not discard them + * (they alone are enough evidence for the ghost-host derivation). */ +async function syncEnvironmentSshTargetMetadata(environmentId: string): Promise { + const targets = await fetchEnvironmentSshTargets(environmentId) + useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets) + try { + const { labels } = await callRuntimeRpc<{ labels: Record }>( + environmentTarget(environmentId), + 'ssh.listRemovedTargetLabels', + undefined, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + useAppStore.getState().setEnvironmentRemovedSshTargetLabels(environmentId, labels) + } catch { + // Best-effort — a missing map just falls back to the raw target id. + } + return targets +} + +async function fetchEnvironmentSshConnectionStates( + environmentId: string, + targets: readonly SshTarget[] +): Promise { + for (const target of targets) { + try { + const { state } = await callRuntimeRpc<{ state: SshConnectionState | null }>( + environmentTarget(environmentId), + 'ssh.getState', + { targetId: target.id }, + { timeoutMs: SSH_RPC_TIMEOUT_MS } + ) + if (state) { + useAppStore.getState().setEnvironmentSshConnectionState(environmentId, target.id, state) + } + } catch { + // A missing state just reads as 'disconnected'; the overlay's Connect + // action and push events converge it. + } + } +} + +type HydrationEntry = { promise: Promise; rerunRequested: boolean } +const hydrationsInFlight = new Map() + +async function runEnvironmentSshHydration(environmentId: string): Promise { + const targets = await syncEnvironmentSshTargetMetadata(environmentId) + await fetchEnvironmentSshConnectionStates(environmentId, targets) +} + +/** + * Fetches the environment's SSH targets, removal tombstones, and per-target + * connection states into its bucket. Single-flight per environment; a `force` + * request during an in-flight run schedules exactly one follow-up run so a + * refresh triggered by a just-added target can't be swallowed by a stale + * in-flight fetch. + * + * Hosts without the ssh.* RPC methods fail here and leave the bucket + * un-hydrated — reads then resolve to "unknown", never to destructive UI. + */ +export async function hydrateRuntimeEnvironmentSshState( + environmentId: string, + options: { force?: boolean } = {} +): Promise { + const inFlight = hydrationsInFlight.get(environmentId) + if (inFlight) { + if (options.force) { + inFlight.rerunRequested = true + } + return inFlight.promise + } + const bucket = useAppStore.getState().sshStateByEnvironment.get(environmentId) + if (!options.force && bucket?.targetsHydrated) { + return + } + const entry: HydrationEntry = { promise: Promise.resolve(), rerunRequested: false } + entry.promise = (async () => { + try { + await runEnvironmentSshHydration(environmentId) + while (entry.rerunRequested) { + entry.rerunRequested = false + await runEnvironmentSshHydration(environmentId) + } + } finally { + hydrationsInFlight.delete(environmentId) + } + })() + hydrationsInFlight.set(environmentId, entry) + return entry.promise +} + +/** + * Applies a `sshStateChanged` runtime client event from `environmentId` to + * that environment's bucket. For a target the bucket doesn't know yet + * (added after hydration, or a disconnect racing a removal), the authoritative + * target list is re-fetched instead of trusting the event: the forced + * hydration also re-reads the state, so a removed target's trailing event + * can't resurrect it. + */ +export function applyRuntimeEnvironmentSshStateChanged( + environmentId: string, + targetId: string, + state: SshConnectionState +): void { + const store = useAppStore.getState() + const bucket = store.sshStateByEnvironment.get(environmentId) + if (bucket?.targetsHydrated && bucket.targetLabels.has(targetId)) { + store.setEnvironmentSshConnectionState(environmentId, targetId, state) + return + } + void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) +} + +/** Connects the environment's own SSH target via its runtime RPC and mirrors + * the returned state into the bucket (ssh.connect can resolve before the + * push event lands). */ +export async function connectRuntimeEnvironmentSshTarget( + environmentId: string, + targetId: string +): Promise { + const { state } = await callRuntimeRpc<{ state: SshConnectionState | null }>( + environmentTarget(environmentId), + 'ssh.connect', + { targetId }, + { timeoutMs: 60_000 } + ) + if (state) { + useAppStore.getState().setEnvironmentSshConnectionState(environmentId, targetId, state) + } + return state +} + +/** Resyncs the environment's target metadata after a failed connect so a + * stale overlay converges to the ghost/re-adopted state (STA-1468). */ +export async function resyncRuntimeEnvironmentSshTargets(environmentId: string): Promise { + await syncEnvironmentSshTargetMetadata(environmentId) +} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index f1ce3f69e9c..90366acbfa4 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -3,6 +3,7 @@ preload API plus remote fallbacks; keeping route coverage together makes local versus environment behavior easy to audit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelRuntimeFileList, copyRuntimePath, createRuntimePath, deleteRuntimePath, @@ -37,6 +38,8 @@ const fsDeletePath = vi.fn() const fsStat = vi.fn() const fsPathExists = vi.fn() const fsSearch = vi.fn() +const fsListFiles = vi.fn() +const fsCancelListFiles = vi.fn() const fsDownloadFile = vi.fn() const fsSaveDownloadedFile = vi.fn() const fsStartDownloadedFile = vi.fn() @@ -63,6 +66,9 @@ beforeEach(() => { fsStat.mockReset() fsPathExists.mockReset() fsSearch.mockReset() + fsListFiles.mockReset() + fsCancelListFiles.mockReset() + fsCancelListFiles.mockResolvedValue(undefined) fsDownloadFile.mockReset() fsSaveDownloadedFile.mockReset() fsStartDownloadedFile.mockReset() @@ -102,6 +108,8 @@ beforeEach(() => { stat: fsStat, pathExists: fsPathExists, search: fsSearch, + listFiles: fsListFiles, + cancelListFiles: fsCancelListFiles, downloadFile: fsDownloadFile, saveDownloadedFile: fsSaveDownloadedFile, startDownloadedFile: fsStartDownloadedFile, @@ -1357,6 +1365,56 @@ describe('runtime file client', () => { }) }) + it('passes the cancellation token through the IPC file listing path (#7721)', async () => { + fsListFiles.mockResolvedValue(['src/index.ts']) + + await expect( + listRuntimeFiles( + { + settings: {}, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + { + rootPath: '/remote/repo', + requestToken: 'token-1' + } + ) + ).resolves.toEqual(['src/index.ts']) + + expect(fsListFiles).toHaveBeenCalledWith({ + rootPath: '/remote/repo', + connectionId: 'ssh-1', + excludePaths: undefined, + requestToken: 'token-1' + }) + }) + + it('cancelRuntimeFileList aborts the IPC listing but not environment listings (#7721)', () => { + cancelRuntimeFileList( + { + settings: {}, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + 'token-1' + ) + expect(fsCancelListFiles).toHaveBeenCalledWith({ requestToken: 'token-1' }) + + fsCancelListFiles.mockClear() + cancelRuntimeFileList( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + 'token-2' + ) + expect(fsCancelListFiles).not.toHaveBeenCalled() + }) + it('routes markdown document listing and stat through the selected runtime', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index e9161f754c1..eddda11a6c1 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -654,14 +654,15 @@ export async function searchRuntimeFiles( export async function listRuntimeFiles( context: RuntimeFileOperationArgs, - args: { rootPath: string; excludePaths?: string[] } + args: { rootPath: string; excludePaths?: string[]; requestToken?: string } ): Promise { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listFiles({ rootPath: args.rootPath, connectionId: context.connectionId, - excludePaths: args.excludePaths + excludePaths: args.excludePaths, + requestToken: args.requestToken }) } return callRuntimeRpc( @@ -675,6 +676,24 @@ export async function listRuntimeFiles( ) } +/** + * Best-effort abort of an in-flight listRuntimeFiles call (#7721). Switching + * workspaces must stop the previous workspace's full-tree scan — over SSH an + * abandoned scan keeps loading the relay and starves fs.readDir/fs.stat. + */ +export function cancelRuntimeFileList( + context: RuntimeFileOperationArgs, + requestToken: string +): void { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + void window.api.fs.cancelListFiles({ requestToken }).catch(() => { + /* cancellation is advisory; the request path has its own timeouts */ + }) + } + // Environment runtimes bound files.listAll with their own RPC timeout. +} + export async function listRuntimeMarkdownDocuments( context: RuntimeFileOperationArgs, rootPath: string diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index be97ef98b87..7090ac68bc1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -22,6 +22,7 @@ import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../s import type { OpenFile } from '../store/slices/editor' import { _getWebSessionTabsTrackingCountsForTest, + acceptReplayedWebSessionTabsSnapshot, applyFreshWebSessionTabsSnapshot, applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots, @@ -145,6 +146,46 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(sameEpochOlder, ENV)).toBe(false) }) + it('accepts a replayed same-epoch same-version snapshot after a transport reconnect', () => { + // Why: after a shared-control reconnect the server re-emits the current + // snapshot with an UNCHANGED epoch/version (the host did not restart). + // Without the replay reset the monotonic gate rejects it and the mirror + // stays frozen (#7718). + const snapshot = makeSnapshot([], { snapshotVersion: 5, activeTabType: null }) + + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + // Same frame again during normal operation: still rejected as stale. + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(false) + + acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + + // The replay reset re-primes tracking: ordering protection resumes for + // subsequent frames (an older same-epoch frame is still rejected). + const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false) + const newer = makeSnapshot([], { snapshotVersion: 6, activeTabType: null }) + expect(shouldApplyWebSessionTabsSnapshot(newer, ENV)).toBe(true) + }) + + it('scopes the replay reset to the replayed environment and worktree', () => { + const snapshot = makeSnapshot([], { snapshotVersion: 5, activeTabType: null }) + const otherWorktree = makeSnapshot([], { + worktree: 'repo::/other-worktree', + snapshotVersion: 5, + activeTabType: null + }) + + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(otherWorktree, ENV)).toBe(true) + + acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree) + + // Only the replayed worktree re-applies; the other stays gated. + expect(shouldApplyWebSessionTabsSnapshot(otherWorktree, ENV)).toBe(false) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + }) + it('ignores remote snapshots for the local floating workspace', () => { const floatingTab: TerminalTab = { id: 'floating-tab-1', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8d0eba1361d..47942c67433 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -66,6 +66,7 @@ import { endWebRuntimeWakeTerminalRespawn, shouldSkipWebRuntimeWakeTerminalRespawn } from './web-runtime-wake-terminal-respawn' +import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' @@ -172,6 +173,18 @@ export function getLastKnownHostTerminalTabCount( ) } +// Why: a post-reconnect subscription replay re-emits the current snapshot with +// an unchanged epoch/version; dropping the freshness entry lets the monotonic +// gate accept that replay as authoritative instead of freezing the mirror +// (#7718). Normal-operation ordering protection is untouched — this only runs +// for responses the connection tagged as reconnect replays. +export function acceptReplayedWebSessionTabsSnapshot( + environmentId: string, + worktreeId: string +): void { + latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId)) +} + export function shouldApplyWebSessionTabsSnapshot( snapshot: RuntimeMobileSessionTabsResult, environmentId: string @@ -2500,7 +2513,13 @@ export function useWebSessionTabsSync(): void { return } const event = response.result as SessionTabsStreamEvent + const replayed = isRuntimeSubscriptionReplayResponse(response) if (event.type === 'snapshots') { + if (replayed) { + for (const snapshot of event.snapshots) { + acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) + } + } useAppStore.setState((state) => applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) ) @@ -2509,6 +2528,9 @@ export function useWebSessionTabsSync(): void { if (event.type !== 'snapshot' && event.type !== 'updated') { return } + if (replayed) { + acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) + } useAppStore.setState((state) => applyFreshWebSessionTabsSnapshot(state, event, environmentId) ) @@ -2587,6 +2609,9 @@ export function useWebSessionTabsSync(): void { if (event.type !== 'snapshot' && event.type !== 'updated') { return } + if (isRuntimeSubscriptionReplayResponse(response)) { + acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree) + } const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId) const syncState = useAppStore.getState() const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index e75bbffa5c9..19941c75cbe 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -23,6 +23,7 @@ import { createOpenCodeUsageSlice } from './slices/opencode-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' +import { createRuntimeEnvironmentSshSlice } from './slices/runtime-environment-ssh' import { createAgentStatusSlice } from './slices/agent-status' import { createPaneForegroundAgentSlice } from './slices/pane-foreground-agent' import { createDiffCommentsSlice } from './slices/diffComments' @@ -61,6 +62,7 @@ export const useAppStore = create()((...a) => ({ ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index e4862f12913..f1538a53fb2 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -128,6 +128,7 @@ import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' +import { createRuntimeEnvironmentSshSlice } from './runtime-environment-ssh' import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' @@ -165,6 +166,7 @@ function createTestStore() { ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts new file mode 100644 index 00000000000..22b5015884b --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createTestStore } from './store-test-helpers' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from './runtime-environment-ssh' + +const ENV_A = 'env-a' +const ENV_B = 'env-b' + +function connState( + targetId: string, + status: SshConnectionState['status'] = 'connected' +): SshConnectionState { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +function markReachable(store: ReturnType, environmentId: string): void { + store.getState().setRuntimeEnvironmentStatus(environmentId, { + status: { runtimeId: environmentId } as RuntimeStatus, + checkedAt: Date.now() + }) +} + +describe('runtime-environment-ssh slice', () => { + it('routes state into the owning environment bucket without touching local maps or other buckets', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + const localStatesBefore = store.getState().sshConnectionStates + const localLabelsBefore = store.getState().sshTargetLabels + const bucketBBefore = store.getState().sshStateByEnvironment.get(ENV_B) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, { 'ssh-dead': 'old box' }) + + const state = store.getState() + // Local maps: bit-identical (same references, still empty). + expect(state.sshConnectionStates).toBe(localStatesBefore) + expect(state.sshTargetLabels).toBe(localLabelsBefore) + expect(state.sshConnectionStates.size).toBe(0) + expect(state.sshTargetsHydrated).toBe(false) + // Environment B's bucket: untouched reference. + expect(state.sshStateByEnvironment.get(ENV_B)).toBe(bucketBBefore) + // Environment A's bucket: populated. + const bucketA = state.sshStateByEnvironment.get(ENV_A) + expect(bucketA?.connectionStates.get('ssh-a')?.status).toBe('connected') + expect(bucketA?.targetLabels.get('ssh-a')).toBe('a-box') + expect(bucketA?.removedTargetLabels.get('ssh-dead')).toBe('old box') + expect(bucketA?.targetsHydrated).toBe(true) + }) + + it('local slice writes never leak into environment buckets', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + const bucketBefore = store.getState().sshStateByEnvironment.get(ENV_A) + + store.getState().setSshConnectionState('ssh-a', connState('ssh-a', 'disconnected')) + store.getState().setSshTargetsMetadata([{ id: 'ssh-a', label: 'local a' } as never]) + + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.connectionStates.size).toBe(0) + }) + + it('keeps references stable when applying an identical state or target list', () => { + const store = createTestStore() + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + const mapBefore = store.getState().sshStateByEnvironment + const bucketBefore = mapBefore.get(ENV_A) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, {}) + + expect(store.getState().sshStateByEnvironment).toBe(mapBefore) + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) + }) + + it('flips the hydrated flag on the first fetch of an empty target list', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetsHydrated).toBe(true) + }) + + it('drops only the detached environment bucket on retain', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + store.getState().setSshTargetsMetadata([{ id: 'ssh-local', label: 'local' } as never]) + const bucketB = store.getState().sshStateByEnvironment.get(ENV_B) + const localLabels = store.getState().sshTargetLabels + + store.getState().retainEnvironmentSshState([ENV_B]) + + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + expect(store.getState().sshStateByEnvironment.get(ENV_B)).toBe(bucketB) + expect(store.getState().sshTargetLabels).toBe(localLabels) + }) + + it('detaching an environment through setRuntimeEnvironments drops its bucket', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshTargetsMetadata(ENV_B, [{ id: 'ssh-b', label: 'b-box' }]) + + store.getState().setRuntimeEnvironments([{ id: ENV_B, name: 'B', createdAt: 0 } as never]) + + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + expect(store.getState().sshStateByEnvironment.has(ENV_B)).toBe(true) + }) + + it('markEnvironmentSshStateStale downgrades to unknown but keeps labels for display', () => { + const store = createTestStore() + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box' }]) + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', connState('ssh-a')) + expect(selectRuntimeAwareSshStatus(store.getState(), ENV_A, 'ssh-a')).toBe('connected') + + store.getState().markEnvironmentSshStateStale(ENV_A) + + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetsHydrated).toBe(false) + expect(bucket?.connectionStates.size).toBe(0) + expect(bucket?.targetLabels.get('ssh-a')).toBe('a-box') + // Un-hydrated bucket reads as unknown — no overlay, no removal evidence. + expect(selectRuntimeAwareSshStatus(store.getState(), ENV_A, 'ssh-a')).toBeNull() + expect(selectRuntimeAwareSshTargetRemoved(store.getState(), ENV_A, 'ssh-a')).toBe(false) + }) + + describe('selectors', () => { + it('resolve the local maps when the owning environment is null', () => { + const store = createTestStore() + store.getState().setSshConnectionState('ssh-local', connState('ssh-local', 'disconnected')) + store.getState().setSshTargetsMetadata([{ id: 'ssh-local', label: 'local box' } as never]) + // A same-id target in a bucket must not shadow the local resolution. + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, null, 'ssh-local')).toBe('disconnected') + expect(selectRuntimeAwareSshTargetLabel(state, null, 'ssh-local')).toBe('local box') + expect(selectRuntimeAwareSshTargetRemoved(state, null, 'ssh-local')).toBe(false) + expect(selectRuntimeAwareSshTargetRemoved(state, null, 'ssh-gone')).toBe(true) + }) + + it('resolve the owning environment bucket, not local state, for a remote-owned target', () => { + const store = createTestStore() + markReachable(store, ENV_A) + // Same target id exists locally as connected — must not leak into the + // environment read. + store.getState().setSshConnectionState('ssh-1', connState('ssh-1', 'connected')) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-1', label: 'devbox' }]) + store + .getState() + .setEnvironmentSshConnectionState(ENV_A, 'ssh-1', connState('ssh-1', 'disconnected')) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, ENV_A, 'ssh-1')).toBe('disconnected') + expect(selectRuntimeAwareSshTargetLabel(state, ENV_A, 'ssh-1')).toBe('devbox') + expect(selectRuntimeAwareSshTargetRemoved(state, ENV_A, 'ssh-1')).toBe(false) + }) + + it('return unknown (null status) for an unreachable environment even with a hydrated bucket', () => { + const store = createTestStore() + markReachable(store, ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-1', label: 'devbox' }]) + store + .getState() + .setEnvironmentSshConnectionState(ENV_A, 'ssh-1', connState('ssh-1', 'disconnected')) + // Environment probe now records unreachable. + store.getState().setRuntimeEnvironmentStatus(ENV_A, { status: null, checkedAt: Date.now() }) + + const state = store.getState() + expect(selectRuntimeAwareSshStatus(state, ENV_A, 'ssh-1')).toBeNull() + expect(selectRuntimeAwareSshTargetRemoved(state, ENV_A, 'ssh-1')).toBe(false) + }) + + it('never report removal from an un-hydrated bucket, and report it from a hydrated one', () => { + const store = createTestStore() + markReachable(store, ENV_A) + const before = store.getState() + expect(selectRuntimeAwareSshTargetRemoved(before, ENV_A, 'ssh-ghost')).toBe(false) + expect(selectRuntimeAwareSshStatus(before, ENV_A, 'ssh-ghost')).toBeNull() + + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, []) + const after = store.getState() + expect(selectRuntimeAwareSshTargetRemoved(after, ENV_A, 'ssh-ghost')).toBe(true) + // Removal tombstones win the label fallback for ghost hosts. + store.getState().setEnvironmentRemovedSshTargetLabels(ENV_A, { 'ssh-ghost': 'old devbox' }) + expect(selectRuntimeAwareSshTargetLabel(store.getState(), ENV_A, 'ssh-ghost')).toBe( + 'old devbox' + ) + }) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts new file mode 100644 index 00000000000..16ea3929df4 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -0,0 +1,263 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { + SshConnectionState, + SshConnectionStatus, + SshTarget +} from '../../../../shared/ssh-types' +import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' + +/** + * SSH state of one remote Orca server's own SSH targets, mirrored on this + * client. Kept strictly separate from the local `SshSlice` maps so a remote + * machine's targets can never pollute local SSH settings, pickers, or the + * execution-host registry — and vice versa (STA-1468, desktop topology). + */ +export type RuntimeEnvironmentSshBucket = { + connectionStates: Map + targetLabels: Map + removedTargetLabels: Map + /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence + * from `targetLabels` only counts as target removal once a target list + * actually loaded from that environment (even an empty one). */ + targetsHydrated: boolean +} + +export type RuntimeEnvironmentSshSlice = { + /** + * Per-runtime-environment SSH state buckets, keyed by environment id. + * Do NOT read this map directly from components — use the + * `selectRuntimeAwareSsh*` selectors below, which route between the local + * SSH maps (`environmentId === null`) and the owning environment's bucket. + */ + sshStateByEnvironment: Map + setEnvironmentSshConnectionState: ( + environmentId: string, + targetId: string, + state: SshConnectionState + ) => void + setEnvironmentSshTargetsMetadata: ( + environmentId: string, + targets: Pick[] + ) => void + setEnvironmentRemovedSshTargetLabels: ( + environmentId: string, + labels: Record + ) => void + /** Transport to the environment dropped: its mirrored SSH state can no + * longer be trusted (it may hold a pre-drop "connected"). Downgrades the + * bucket to unhydrated and clears connection states so reads resolve to + * "unknown" until a reconnect re-hydrates. */ + markEnvironmentSshStateStale: (environmentId: string) => void + removeEnvironmentSshState: (environmentId: string) => void + /** Drops buckets for environments no longer in the saved set. */ + retainEnvironmentSshState: (environmentIds: Iterable) => void +} + +const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { + connectionStates: new Map(), + targetLabels: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: false +} + +function getBucket( + buckets: Map, + environmentId: string +): RuntimeEnvironmentSshBucket { + return buckets.get(environmentId) ?? EMPTY_BUCKET +} + +function withBucket( + s: Pick, + environmentId: string, + bucket: RuntimeEnvironmentSshBucket +): Pick { + const next = new Map(s.sshStateByEnvironment) + next.set(environmentId, bucket) + return { sshStateByEnvironment: next } +} + +function removedLabelsEqual(current: Map, labels: Record): boolean { + const entries = Object.entries(labels) + return ( + entries.length === current.size && entries.every(([id, label]) => current.get(id) === label) + ) +} + +export const createRuntimeEnvironmentSshSlice: StateCreator< + AppState, + [], + [], + RuntimeEnvironmentSshSlice +> = (set) => ({ + sshStateByEnvironment: new Map(), + + setEnvironmentSshConnectionState: (environmentId, targetId, state) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (sshConnectionStatesEqual(bucket.connectionStates.get(targetId), state)) { + return s + } + const connectionStates = new Map(bucket.connectionStates) + connectionStates.set(targetId, state) + return withBucket(s, environmentId, { ...bucket, connectionStates }) + }), + + setEnvironmentSshTargetsMetadata: (environmentId, targets) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (sshTargetLabelsEqual(bucket.targetLabels, targets)) { + // Why: an unchanged (even empty) list is still a successful load — the + // hydration flag must flip on the first fetch of an empty target set. + return bucket.targetsHydrated + ? s + : withBucket(s, environmentId, { ...bucket, targetsHydrated: true }) + } + return withBucket(s, environmentId, { + ...bucket, + targetLabels: new Map(targets.map((target) => [target.id, target.label])), + targetsHydrated: true + }) + }), + + setEnvironmentRemovedSshTargetLabels: (environmentId, labels) => + set((s) => { + const bucket = getBucket(s.sshStateByEnvironment, environmentId) + if (removedLabelsEqual(bucket.removedTargetLabels, labels)) { + return s + } + return withBucket(s, environmentId, { + ...bucket, + removedTargetLabels: new Map(Object.entries(labels)) + }) + }), + + markEnvironmentSshStateStale: (environmentId) => + set((s) => { + const bucket = s.sshStateByEnvironment.get(environmentId) + if (!bucket || (!bucket.targetsHydrated && bucket.connectionStates.size === 0)) { + return s + } + // Labels are kept so a re-hydrating overlay can still show a friendly + // host name; hydration=false alone forces reads back to "unknown". + return withBucket(s, environmentId, { + ...bucket, + connectionStates: new Map(), + targetsHydrated: false + }) + }), + + removeEnvironmentSshState: (environmentId) => + set((s) => { + if (!s.sshStateByEnvironment.has(environmentId)) { + return s + } + const next = new Map(s.sshStateByEnvironment) + next.delete(environmentId) + return { sshStateByEnvironment: next } + }), + + retainEnvironmentSshState: (environmentIds) => + set((s) => { + const keep = new Set(environmentIds) + let changed = false + const next = new Map(s.sshStateByEnvironment) + for (const id of next.keys()) { + if (!keep.has(id)) { + next.delete(id) + changed = true + } + } + return changed ? { sshStateByEnvironment: next } : s + }) +}) + +type RuntimeAwareSshReadState = Pick< + AppState, + | 'sshConnectionStates' + | 'sshTargetLabels' + | 'removedSshTargetLabels' + | 'sshTargetsHydrated' + | 'sshStateByEnvironment' +> & + Partial> + +function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { + return Boolean(state.runtimeStatusByEnvironmentId?.get(environmentId)?.status) +} + +/** + * Reconnect-overlay status for an SSH target owned by `environmentId` (a + * remote Orca server) or by this machine (`environmentId === null`). + * + * Returns null when the state is unknown — environment unreachable or its + * bucket not hydrated — so callers show nothing rather than another machine's + * stale state. The runtime environment's own disconnected UI outranks any SSH + * overlay in that case. + */ +export function selectRuntimeAwareSshStatus( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): SshConnectionStatus | null { + if (environmentId === null) { + return state.sshConnectionStates.get(targetId)?.status ?? 'disconnected' + } + if (!isEnvironmentReachable(state, environmentId)) { + return null + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket?.targetsHydrated) { + return null + } + return bucket.connectionStates.get(targetId)?.status ?? 'disconnected' +} + +export function selectRuntimeAwareSshTargetLabel( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): string { + if (environmentId === null) { + return ( + state.sshTargetLabels.get(targetId) ?? + // 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--. + state.removedSshTargetLabels.get(targetId) ?? + targetId + ) + } + const bucket = state.sshStateByEnvironment.get(environmentId) + return bucket?.targetLabels.get(targetId) ?? bucket?.removedTargetLabels.get(targetId) ?? targetId +} + +/** + * True only on positive evidence that the target was removed on its owning + * host: a removal tombstone, or a hydrated target list that lacks the id. + * An unreachable environment or un-hydrated bucket never reports removal, so + * destructive "remove workspace" UI cannot be offered out of ignorance. + */ +export function selectRuntimeAwareSshTargetRemoved( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): boolean { + if (environmentId === null) { + return ( + state.removedSshTargetLabels.has(targetId) || + (state.sshTargetsHydrated && !state.sshTargetLabels.has(targetId)) + ) + } + if (!isEnvironmentReachable(state, environmentId)) { + return false + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket) { + return false + } + return ( + bucket.removedTargetLabels.has(targetId) || + (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)) + ) +} diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index b2c5eec0838..e9dae52e299 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -66,6 +66,8 @@ export const createRuntimeStatusSlice: StateCreator environment.id)) + // A detached environment's mirrored SSH state must not outlive it. + get().retainEnvironmentSshState?.(environments.map((environment) => environment.id)) }, setRuntimeEnvironmentStatus: (environmentId, status) => { diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index af8f17fd30d..ef5ebf67752 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -146,7 +146,7 @@ describe('createSshSlice', () => { it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { const store = createTestStore() const labels = new Map([['ssh-1', 'Remote']]) - store.setState({ sshTargetLabels: labels }) + store.setState({ sshTargetLabels: labels, sshTargetsHydrated: true }) const previousState = store.getState() store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) @@ -155,6 +155,18 @@ describe('createSshSlice', () => { expect(store.getState().sshTargetLabels).toBe(labels) }) + it('marks targets hydrated on the first load, even when the list is empty', () => { + const store = createTestStore() + expect(store.getState().sshTargetsHydrated).toBe(false) + + store.getState().setSshTargetsMetadata([]) + + // Why: an empty target set is still positive knowledge — the overlay's + // targetRemoved derivation may only trust absence after a real load. + expect(store.getState().sshTargetsHydrated).toBe(true) + expect(store.getState().sshTargetLabels.size).toBe(0) + }) + it('keeps SSH connection state references stable when duplicate state arrives', () => { const store = createTestStore() const sshConnectionStates = new Map([ diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 8b98b77b623..6b80e054353 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -37,6 +37,11 @@ export type SshSlice = { * tombstones). Lets ghost-host UI show a friendly name instead of the raw id * for a workspace still pinned to a deleted target. */ removedSshTargetLabels: Map + /** True once a target list actually loaded (even an empty one). Distinguishes + * "this client knows the target set" from "never hydrated" (e.g. a paired + * client on a host without the ssh RPC), so absence from sshTargetLabels + * only counts as removal evidence when this is set. */ + sshTargetsHydrated: boolean remoteWorkspaceHydratedTargetIds: Set remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] @@ -71,6 +76,7 @@ export const createSshSlice: StateCreator = (set) => sshConnectionStates: new Map(), sshTargetLabels: new Map(), removedSshTargetLabels: new Map(), + sshTargetsHydrated: false, remoteWorkspaceHydratedTargetIds: new Set(), remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], @@ -101,10 +107,13 @@ export const createSshSlice: StateCreator = (set) => setSshTargetsMetadata: (targets) => set((s) => { if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { - return s + // Why: an unchanged (even empty) list is still a successful load — the + // hydration flag must flip on the first fetch of an empty target set. + return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true } } return { - sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) + sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), + sshTargetsHydrated: true } }), clearRemovedSshTargetState: (targetId) => diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index b1e09bf4ea6..df36fa5a983 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -31,6 +31,7 @@ import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' +import { createRuntimeEnvironmentSshSlice } from './runtime-environment-ssh' import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' @@ -77,6 +78,7 @@ export function createTestStore() { ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), ...createAgentStatusSlice(...a), ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index b5c6f446fa2..3a266d67861 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -21,6 +21,7 @@ import type { OpenCodeUsageSlice } from './slices/opencode-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' +import type { RuntimeEnvironmentSshSlice } from './slices/runtime-environment-ssh' import type { AgentStatusSlice } from './slices/agent-status' import type { PaneForegroundAgentSlice } from './slices/pane-foreground-agent' import type { DiffCommentsSlice } from './slices/diffComments' @@ -56,6 +57,7 @@ export type AppState = RepoSlice & BrowserSlice & RateLimitSlice & SshSlice & + RuntimeEnvironmentSshSlice & AgentStatusSlice & PaneForegroundAgentSlice & DiffCommentsSlice & diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 4a1d7b36ff6..bf46bd40390 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -592,6 +592,104 @@ describe('web MiniMax preload API', () => { }) }) +describe('web AI Vault preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('routes session scans through the paired runtime host', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + const scanResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-04T00:00:00.000Z' + } + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: scanResult, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.aiVault.listSessions({ + executionHostScope: 'all', + limit: 25, + force: true, + scopePaths: ['/srv/app'] + }) + ).resolves.toEqual(scanResult) + expect(runtimeCalls).toEqual([ + { + method: 'aiVault.listSessions', + params: { + limit: 25, + force: true, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:web-env-1' + } + } + ]) + }) + + it('returns unavailable history for explicit non-runtime host scopes', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { sessions: [], issues: [], scannedAt: '2026-07-04T00:00:00.000Z' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.aiVault.listSessions({ executionHostScope: 'local' }) + ).resolves.toEqual({ + sessions: [], + issues: [ + expect.objectContaining({ + executionHostId: 'local', + agent: 'codex' + }) + ], + scannedAt: expect.any(String) + }) + expect(runtimeCalls).toEqual([]) + }) +}) + describe('web UI preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 30b97677b04..7e181cdbb2b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -10,6 +10,7 @@ import type { NativeChatAppendedMessages } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' import type { ComputerUsePermissionSetupResult, @@ -34,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, getDefaultSettings, @@ -47,7 +49,13 @@ import { import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' -import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostScope, + normalizeExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { @@ -626,15 +634,7 @@ function createWebPreloadApi(): Partial { memory: { getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot()) }, - aiVault: { - listSessions: () => - Promise.resolve({ - sessions: [], - issues: [], - scannedAt: new Date().toISOString() - }), - onWindowFocused: () => () => {} - }, + aiVault: createAiVaultApi(), preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), @@ -1116,6 +1116,48 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim } } +function createAiVaultApi(): NonNullable['aiVault']> { + return { + listSessions: (args?: AiVaultListArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + const requestedScope = normalizeExecutionHostScope( + args?.executionHostScope ?? executionHostId + ) + if (requestedScope !== 'all' && requestedScope !== executionHostId) { + return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) + } + // Why: the browser client has no local filesystem; every history scan + // runs on the paired server and must be stamped as that runtime host. + return callRuntimeResult('aiVault.listSessions', { + limit: args?.limit, + force: args?.force, + scopePaths: args?.scopePaths, + executionHostId + }) + }, + onWindowFocused: () => noopUnsubscribe + } +} + +function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId, + agent: 'codex', + path: executionHostId, + message: translate( + 'auto.web.webPreloadApi.aiVaultUnavailableForHost', + 'Agent Session History is not available for this execution host.' + ) + } + ], + scannedAt: new Date().toISOString() + } +} + function createReposApi(): NonNullable['repos']> { return { list: async () => (await callRuntimeResult<{ repos: Repo[] }>('repo.list')).repos, @@ -1467,6 +1509,10 @@ function createFileApi(): NonNullable['fs']> { ) return result.files.map((entry) => entry.relativePath) }, + cancelListFiles: async () => { + // Why: the paired-web path lists files over runtime RPC with its own + // request timeout; there is no host-side scan to abort from here. + }, search: async (args) => { const file = await resolveRuntimeFilePath(args.rootPath) return callRuntimeResult('files.search', { @@ -2618,19 +2664,51 @@ function createPtyApi(): NonNullable['pty']> { function createSshApi(): NonNullable['ssh']> { return { - listTargets: () => Promise.resolve([]), - listRemovedTargetLabels: () => Promise.resolve({}), + // Why: SSH connections are owned by the paired host. Read/connect route to + // its runtime RPC so remote worktrees can show real connection state and + // reconnect (STA-1468); target management stays desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>('ssh.listTargets') + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, addTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), updateTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), importConfig: () => Promise.resolve([]), - connect: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, disconnect: () => Promise.resolve(), terminateSessions: () => Promise.resolve(), resetRelay: () => Promise.resolve(), - getState: () => Promise.resolve(null), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, needsPassphrasePrompt: () => Promise.resolve(false), testConnection: () => Promise.resolve({ diff --git a/src/shared/file-listing-cancellation.ts b/src/shared/file-listing-cancellation.ts new file mode 100644 index 00000000000..3296b80c925 --- /dev/null +++ b/src/shared/file-listing-cancellation.ts @@ -0,0 +1,38 @@ +/** + * Cancellation identity for workspace file-list scans (fs.listFiles). + * + * Why a dedicated error class: the readdir fallback wraps budget errors into + * "install rg" guidance and Quick Open surfaces load errors verbatim, so a + * cancelled scan must stay distinguishable from a genuine listing failure at + * every layer (relay rg/git/readdir, main-process local scan, renderer). + */ +export class FileListingCancelledError extends Error { + constructor(message = 'File listing cancelled') { + super(message) + this.name = 'FileListingCancelledError' + } +} + +/** + * Build the rejection for an aborted scan, preferring the abort reason so a + * superseded scan reports "superseded" rather than a generic cancellation. + * Only FileListingCancelledError reasons pass through — a bare abort() sets + * signal.reason to a DOMException that must not leak past the classifier. + */ +export function fileListingCancellationError(signal?: AbortSignal): Error { + const reason = signal?.reason + if (reason instanceof FileListingCancelledError) { + return reason + } + return new FileListingCancelledError() +} + +export function isFileListingCancellation(error: unknown): boolean { + return error instanceof FileListingCancelledError +} + +export function throwIfFileListingCancelled(signal?: AbortSignal): void { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } +} diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 66029032a2c..cb73572bc34 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -24,6 +24,7 @@ import { listQuickOpenFilesWithReaddir, parseQuickOpenGitLsFilesEntry } from './quick-open-readdir-walk' +import { isFileListingCancellation } from './file-listing-cancellation' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -259,4 +260,35 @@ describe('quick-open readdir walk', () => { }) ).resolves.toEqual(['packages/app [one] space/src/main.ts']) }) + + it('stops the walk with a cancellation error when the signal aborts (#7721)', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/a.ts') + await writeRel(root, 'src/b.ts') + + const controller = new AbortController() + controller.abort() + + const rejection = listQuickOpenFilesWithReaddir(root, { signal: controller.signal }) + await expect(rejection).rejects.toSatisfy(isFileListingCancellation) + // Cancellation must never be mistaken for a budget error, which callers + // translate into "install rg" guidance. + await rejection.catch((err) => expect(isQuickOpenReaddirBudgetError(err)).toBe(false)) + }) + + it('stops nested-repo expansion when the signal aborts (#7721)', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/kept.ts') + + const controller = new AbortController() + controller.abort() + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: ['src/kept.ts'], + signal: controller.signal + }) + ).rejects.toSatisfy(isFileListingCancellation) + }) }) diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index 5d3a0a52f53..90ce3ea9fdf 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -1,5 +1,6 @@ import { lstat, readdir } from 'node:fs/promises' import { join, relative } from 'node:path' +import { throwIfFileListingCancelled } from './file-listing-cancellation' import { HIDDEN_DIR_BLOCKLIST, shouldExcludeQuickOpenRelPath, @@ -157,6 +158,7 @@ export async function listQuickOpenFilesWithReaddir( opts: { excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + signal?: AbortSignal } = {} ): Promise { const files: string[] = [] @@ -164,6 +166,9 @@ export async function listQuickOpenFilesWithReaddir( const excludePathPrefixes = opts.excludePathPrefixes ?? [] async function walk(dir: string): Promise { + // Why: an abandoned scan (workspace switch) must stop consuming IO and + // event-loop time on the single-threaded relay, not just run to budget. + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) let entries @@ -176,6 +181,7 @@ export async function listQuickOpenFilesWithReaddir( } for (const entry of entries) { + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) const name = entry.name @@ -206,6 +212,7 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { gitPaths: Iterable excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + signal?: AbortSignal }): Promise { const files = new Set() const excludePathPrefixes = opts.excludePathPrefixes ?? [] @@ -224,6 +231,7 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { } for (const rawPath of opts.gitPaths) { + throwIfFileListingCancelled(opts.signal) assertWithinDeadline(budget) const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) @@ -240,7 +248,8 @@ export async function expandQuickOpenGitFilesWithNestedRepos(opts: { // nested repo so the walk prunes excluded subtrees during traversal // instead of burning the shared budget and filtering them at the end. excludePathPrefixes: rebaseExcludePrefixesForNestedRepo(excludePathPrefixes, relPath), - budget + budget, + signal: opts.signal }) for (const nestedFile of nestedFiles) { addFinalPath(`${relPath}/${nestedFile}`) diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index ccb09c15dc3..5febb2f2ddf 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -104,6 +104,37 @@ describe('subscribeRemoteRuntimeRequest', () => { } }) + it('closes a half-open subscription socket via client liveness so callers can resubscribe', async () => { + // Why: dedicated stream sockets (terminal.multiplex, browser.screencast) + // must not hang forever when a tunnel goes half-open — no close frame, no + // pongs, no data (#7718). Client liveness surfaces onError/onClose so the + // renderer's onTransportClose resubscribe path can run. + const server = await createSubscriptionServer({ disableAutoPong: true }) + const onResponse = vi.fn() + const onError = vi.fn() + const onClose = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.multiplex', + {}, + 1000, + { onResponse, onError, onClose }, + { pingIntervalMs: 50, livenessTimeoutMs: 200 } + ) + + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + await vi.waitFor( + () => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'remote_runtime_unavailable' }) + ), + { timeout: 5000 } + ) + expect(onClose).toHaveBeenCalledOnce() + subscription.close() + }) + it('closes established subscription sockets after terminal protocol errors', async () => { const offSpy = vi.spyOn(WebSocketClient.prototype, 'off') try { @@ -223,6 +254,9 @@ describe('sendRemoteRuntimeRequest', () => { async function createSubscriptionServer( options: { sendMismatchedResponseAfterSubscribe?: boolean + // Why: half-open simulation — the socket stays open but never answers + // protocol pings, like a wedged tunnel that swallows frames silently. + disableAutoPong?: boolean } = {} ): Promise<{ pairing: PairingOffer @@ -233,7 +267,7 @@ async function createSubscriptionServer( const nextBinary = new Promise((resolve) => { resolveBinary = resolve }) - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) wss.on('connection', (ws) => { diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 2f9c2f59bff..ecd9f1df790 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -24,6 +24,11 @@ import { // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. import { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { + startRemoteRuntimeSocketLiveness, + type RemoteRuntimeSocketLivenessMonitor, + type RemoteRuntimeSocketLivenessOptions +} from './remote-runtime-socket-liveness' export { RemoteRuntimeClientError } from './remote-runtime-client-error' @@ -354,7 +359,8 @@ export async function subscribeRemoteRuntimeRequest( method: string, params: unknown, timeoutMs: number, - callbacks: RemoteRuntimeSubscriptionCallbacks + callbacks: RemoteRuntimeSubscriptionCallbacks, + livenessOptions?: RemoteRuntimeSocketLivenessOptions ): Promise { return await new Promise((resolve, reject) => { const requestId = randomUUID() @@ -364,8 +370,11 @@ export async function subscribeRemoteRuntimeRequest( let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null + let liveness: RemoteRuntimeSocketLivenessMonitor | null = null const cleanupSocketListeners = (): WebSocket | null => { + liveness?.stop() + liveness = null const socket = ws if (!socket) { return null @@ -374,6 +383,8 @@ export async function subscribeRemoteRuntimeRequest( socket.off('error', onError) socket.off('close', onClose) socket.off('message', onMessage) + socket.off('pong', onLivenessSignal) + socket.off('ping', onLivenessSignal) ws = null // Why: startup failures detach Orca callbacks before closing the ws, // but ws can still emit a late transport error while close is in flight. @@ -485,6 +496,7 @@ export async function subscribeRemoteRuntimeRequest( } function onMessage(data: WebSocket.RawData, isBinary: boolean): void { + liveness?.noteActivity() if (isBinary) { handleBinaryFrame(new Uint8Array(data as Buffer)) return @@ -515,10 +527,46 @@ export async function subscribeRemoteRuntimeRequest( handleRpcFrame(plaintext) } + function onLivenessSignal(): void { + liveness?.noteActivity() + } + ws.once('open', onOpen) ws.once('error', onError) ws.on('close', onClose) ws.on('message', onMessage) + ws.on('pong', onLivenessSignal) + ws.on('ping', onLivenessSignal) + + // Why: dedicated stream sockets (terminal.multiplex, browser.screencast) + // ride the same tunnels as shared control; a half-open drop must surface + // as a close so the renderer's onTransportClose resubscribe path runs + // instead of freezing the stream forever (#7718/#7489). + const monitoredWs = ws + liveness = startRemoteRuntimeSocketLiveness({ + ping: () => { + if (monitoredWs.readyState === WebSocket.OPEN) { + monitoredWs.ping() + } + }, + onDead: () => { + // Why: fail() first so listeners detach before terminate's close event; + // otherwise the close handler would emit a second onClose to callers. + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime stopped responding; the stream connection was reset.' + ) + ) + try { + // Why: close() on a half-open socket can hang for the OS TCP timeout. + monitoredWs.terminate() + } catch { + // Best-effort terminate; the subscription is already settled. + } + }, + options: livenessOptions + }) function handleReadyFrame(frame: string): void { let ready: unknown diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts index 40b4e999df9..e5b0ad8212d 100644 --- a/src/shared/remote-runtime-request-websocket.ts +++ b/src/shared/remote-runtime-request-websocket.ts @@ -22,6 +22,10 @@ export type RemoteRuntimeWebSocketCallbacks = { onClose: (ws: WebSocket, code: number, reason: Buffer) => void onError: (ws: WebSocket, error: RemoteRuntimeClientError) => void onTextFrame: (ws: WebSocket, frame: string) => void + // Why: protocol-level pongs (and server heartbeat pings) are the liveness + // signal for detecting half-open tunnels that never deliver `close` (#7718). + onPong?: (ws: WebSocket) => void + onPing?: (ws: WebSocket) => void } export function openRemoteRuntimeWebSocket( @@ -64,6 +68,8 @@ export function openRemoteRuntimeWebSocket( } callbacks.onTextFrame(ws, data.toString()) } + const onPong = (): void => callbacks.onPong?.(ws) + const onPing = (): void => callbacks.onPing?.(ws) const cleanup = (): void => { if (cleanedUp) { return @@ -73,6 +79,8 @@ export function openRemoteRuntimeWebSocket( ws.off('error', onError) ws.off('close', onClose) ws.off('message', onMessage) + ws.off('pong', onPong) + ws.off('ping', onPing) // Why: a manually closed ws can still emit a late transport error; keep // that from becoming an unhandled EventEmitter error after detaching Orca. if (ws.readyState !== WebSocket.CLOSED) { @@ -84,6 +92,8 @@ export function openRemoteRuntimeWebSocket( ws.on('error', onError) ws.on('close', onClose) ws.on('message', onMessage) + ws.on('pong', onPong) + ws.on('ping', onPing) return { ok: true, socket: { ws, sharedKey, cleanup } } } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index e807a519bc8..250ec27bf40 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -13,6 +13,7 @@ import { import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' +import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' const TEST_PROJECT_PATH = path.join('tmp', 'project') @@ -406,6 +407,83 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) + it('detects a half-open socket via client liveness, reconnects, and tags the replayed response', async () => { + // Why: the server keeps the TCP connection open but stops answering — + // no close frame, no pongs (autoPong disabled), no responses. This is the + // half-open devtunnel scenario from #7718: edge-triggered reconnect never + // fires, so client liveness must terminate the socket itself. + const server = await createServer({ disableAutoPong: true }) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing, { + liveness: { pingIntervalMs: 50, livenessTimeoutMs: 200 } + }) + const onResponse = vi.fn() + const onClose = vi.fn() + + await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { + onResponse, + onError: vi.fn(), + onClose + }) + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + expect(isRuntimeSubscriptionReplayResponse(onResponse.mock.calls[0]?.[0])).toBe(false) + + // Liveness terminates the silent socket and the reconnect path replays + // the subscription on a fresh connection. + await vi.waitFor(() => expect(server.connectionCount()).toBeGreaterThanOrEqual(2), { + timeout: 5000 + }) + await vi.waitFor( + () => + expect( + server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') + .length + ).toBeGreaterThanOrEqual(2), + { timeout: 5000 } + ) + // The first response after the reconnect replay carries the replay tag so + // snapshot freshness gates can accept the re-emitted snapshot. + await vi.waitFor( + () => + expect( + onResponse.mock.calls.some(([response]) => isRuntimeSubscriptionReplayResponse(response)) + ).toBe(true), + { timeout: 5000 } + ) + expect(onClose).not.toHaveBeenCalled() + + connection.close() + }) + + it('tears down the socket when a request times out and reconnects active subscriptions', async () => { + const server = await createServer({ silentMethods: ['worktree.hang'] }) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + const onResponse = vi.fn() + const onClose = vi.fn() + + await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { + onResponse, + onError: vi.fn(), + onClose + }) + await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + + // Why: mirrors RemoteRuntimeRequestConnection — a request the server never + // answered marks the socket as suspect and must not leave it 'ready'. + await expect(connection.request('worktree.hang', undefined, 50)).rejects.toThrow('Timed out') + + await vi.waitFor(() => expect(server.connectionCount()).toBe(2), { timeout: 5000 }) + await vi.waitFor( + () => + expect( + server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') + ).toHaveLength(2), + { timeout: 5000 } + ) + expect(onClose).not.toHaveBeenCalled() + + connection.close() + }) + it('rejects pending requests and records close diagnostics when the socket closes', async () => { const server = await createServer({ closeBeforeResponse: true }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) @@ -434,6 +512,10 @@ async function createServer( closeAfterFirstStreamingResponse?: boolean closeBeforeResponse?: boolean suppressReadyFrame?: boolean + // Why: half-open simulation — the socket stays open but never answers + // protocol pings, like a wedged tunnel that swallows frames silently. + disableAutoPong?: boolean + silentMethods?: string[] } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -441,7 +523,7 @@ async function createServer( const delayedResponses: (() => void)[] = [] let connectionCount = 0 let closedAfterFirstStreamingResponse = false - const wss = new WebSocketServer({ port: 0 }) + const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) wss.on('connection', (ws) => { @@ -531,10 +613,14 @@ function handleRequest( sendUnknownResponseBeforeResponse?: boolean closeAfterStreamingResponse?: () => boolean closeBeforeResponse?: boolean + silentMethods?: string[] }, delayedResponses: (() => void)[] ): void { requests.push(request) + if (options.silentMethods?.includes(request.method)) { + return + } if (options.closeBeforeResponse) { ws.close(4001, 'test close') return diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 2ea957e9fdc..585d3478cba 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -12,13 +12,14 @@ import { } from './remote-runtime-shared-control-ready' import { scheduleSharedControlReconnectOrFinish } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' -import { scheduleSharedControlStableReset } from './remote-runtime-shared-control-stability' +import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' import { sendSharedControlRequest, sendSharedControlSubscription } from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' +import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' import type { @@ -37,7 +38,7 @@ export class RemoteRuntimeSharedControlConnection { private sharedKey: Uint8Array | null = null private socketCleanup: (() => void) | null = null private reconnectTimer: ReturnType | null = null - private readyStableTimer: ReturnType | null = null + private readonly readyStableReset: SharedControlReadyStableResetTimer private reconnectAttempt = 0 private intentionallyClosed = false private lastConnectedAt: number | null = null @@ -46,11 +47,20 @@ export class RemoteRuntimeSharedControlConnection { private readonly pendingRequests = new Map>() private readonly subscriptions = new Map>() private readonly readyWaiters: SharedControlReadyWaiter[] = [] + private everReady = false constructor( private readonly pairing: PairingOffer, - private readonly options: { environmentId?: string; reconnectStableResetMs?: number } = {} - ) {} + private readonly options: { + environmentId?: string + reconnectStableResetMs?: number + liveness?: RemoteRuntimeSocketLivenessOptions + } = {} + ) { + this.readyStableReset = new SharedControlReadyStableResetTimer( + options.reconnectStableResetMs ?? 30_000 + ) + } request( method: string, @@ -64,7 +74,10 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId, requestMethod, requestParams) => - this.sendRequest(requestId, requestMethod, requestParams) + this.sendRequest(requestId, requestMethod, requestParams), + // Why: a timed-out request marks the socket as suspect (#7718) — tear + // it down so reconnect+replay runs instead of keeping a zombie socket. + onTimeout: (error) => this.handleSocketClosed(error) }) } @@ -145,7 +158,11 @@ export class RemoteRuntimeSharedControlConnection { this.lastError = error.message this.handleSocketClosed(error) }, - onTextFrame: (frame) => this.handleTextFrame(frame) + onTextFrame: (frame) => this.handleTextFrame(frame), + liveness: { + options: this.options.liveness, + onDead: (error) => this.handleSocketClosed(error) + } }) if (!opened.ok) { this.handleSocketClosed(opened.error) @@ -205,8 +222,11 @@ export class RemoteRuntimeSharedControlConnection { private replaySubscriptions(): void { sharedControlSubscriptions.replaySharedControlSubscriptions({ subscriptions: this.subscriptions, - send: (subscription) => this.sendSubscription(subscription) + send: (subscription) => this.sendSubscription(subscription), + // Why: only reconnects tag replays; first connects stay on the gated path. + tagReplayedResponses: this.everReady }) + this.everReady = true } private closeSubscription(requestId: string): void { @@ -260,7 +280,7 @@ export class RemoteRuntimeSharedControlConnection { socketCleanup: cleanup, ws, error, - clearReadyStableTimer: () => this.clearReadyStableTimer() + clearReadyStableTimer: () => this.readyStableReset.clear() }) this.ws = null this.sharedKey = null @@ -292,24 +312,12 @@ export class RemoteRuntimeSharedControlConnection { } private scheduleReconnectAttemptReset(): void { - this.clearReadyStableTimer() - this.readyStableTimer = scheduleSharedControlStableReset({ - delayMs: this.options.reconnectStableResetMs ?? 30_000, + this.readyStableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, reset: () => { this.reconnectAttempt = 0 - }, - clearCurrent: () => { - this.readyStableTimer = null } }) } - - private clearReadyStableTimer(): void { - if (this.readyStableTimer) { - clearTimeout(this.readyStableTimer) - this.readyStableTimer = null - } - } } diff --git a/src/shared/remote-runtime-shared-control-open.ts b/src/shared/remote-runtime-shared-control-open.ts index 6381f58dd83..ddb3eb0cd78 100644 --- a/src/shared/remote-runtime-shared-control-open.ts +++ b/src/shared/remote-runtime-shared-control-open.ts @@ -7,6 +7,10 @@ import { type RemoteRuntimeWebSocket } from './remote-runtime-request-websocket' import { formatSharedControlCloseMessage } from './remote-runtime-shared-control-protocol' +import { + startRemoteRuntimeSocketLiveness, + type RemoteRuntimeSocketLivenessOptions +} from './remote-runtime-socket-liveness' export function openSharedControlSocket( pairing: PairingOffer, @@ -15,9 +19,17 @@ export function openSharedControlSocket( onClose: (close: { code: number; reason: string }, error: RemoteRuntimeClientError) => void onError: (error: RemoteRuntimeClientError) => void onTextFrame: (frame: string) => void + // Why: reconnect is edge-triggered on `close`, but a half-open tunnel + // never delivers one. Liveness pings and declares the socket dead when + // the server goes silent, so the close/reconnect path can run (#7718). + liveness?: { + options?: RemoteRuntimeSocketLivenessOptions + onDead: (error: RemoteRuntimeClientError) => void + } } ): { ok: true; socket: RemoteRuntimeWebSocket } | { ok: false; error: RemoteRuntimeClientError } { - return openRemoteRuntimeWebSocket(pairing, { + let noteActivity: () => void = () => {} + const opened = openRemoteRuntimeWebSocket(pairing, { onClose: (ws, code, reason) => { if (callbacks.getCurrentSocket() === ws) { callbacks.onClose( @@ -33,8 +45,62 @@ export function openSharedControlSocket( }, onTextFrame: (ws, frame) => { if (callbacks.getCurrentSocket() === ws) { + noteActivity() callbacks.onTextFrame(frame) } + }, + onPong: (ws) => { + if (callbacks.getCurrentSocket() === ws) { + noteActivity() + } + }, + onPing: (ws) => { + if (callbacks.getCurrentSocket() === ws) { + noteActivity() + } } }) + if (!opened.ok || !callbacks.liveness) { + return opened + } + + const { ws, sharedKey, cleanup } = opened.socket + const liveness = callbacks.liveness + const monitor = startRemoteRuntimeSocketLiveness({ + ping: () => { + if (ws.readyState === 1) { + ws.ping() + } + }, + onDead: () => { + if (callbacks.getCurrentSocket() !== ws) { + return + } + try { + // Why: close() on a half-open socket can hang for the OS TCP timeout. + ws.terminate() + } catch { + // Best-effort terminate; the dead callback resets connection state. + } + liveness.onDead( + remoteRuntimeUnavailableError( + 'Remote Orca runtime stopped responding; resetting the control connection.' + ) + ) + }, + options: liveness.options + }) + noteActivity = monitor.noteActivity + + return { + ok: true, + socket: { + ws, + sharedKey, + cleanup: () => { + monitor.stop() + cleanup() + } + } + } } diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index bbd8900b9fe..5f8d0ca2b7c 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto' -import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' +import type { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { + remoteRuntimeTimeoutError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' @@ -12,6 +16,7 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void + onTimeout?: (error: RemoteRuntimeClientError) => void }): Promise> { const requestId = randomUUID() return new Promise>((resolve, reject) => { @@ -22,6 +27,15 @@ export function requestSharedControl(args: { } args.pendingRequests.delete(requestId) pending.reject(remoteRuntimeTimeoutError()) + // Why: a request the server never answered means the socket is suspect + // (half-open tunnels swallow frames silently); mirror + // RemoteRuntimeRequestConnection and hand the connection a teardown + // error so reconnect+replay runs instead of keeping a zombie socket. + args.onTimeout?.( + remoteRuntimeUnavailableError( + 'Remote Orca runtime did not answer in time; resetting the control connection.' + ) + ) }, args.timeoutMs) args.pendingRequests.set(requestId, { method: args.method, diff --git a/src/shared/remote-runtime-shared-control-stability.ts b/src/shared/remote-runtime-shared-control-stability.ts index 9e1024e69c8..867d5a6548a 100644 --- a/src/shared/remote-runtime-shared-control-stability.ts +++ b/src/shared/remote-runtime-shared-control-stability.ts @@ -21,3 +21,35 @@ export function scheduleSharedControlStableReset(args: { } return timer } + +// Why: owns the ready-stable timer's lifecycle so the connection only wires +// state accessors; scheduling always replaces any earlier pending timer. +export class SharedControlReadyStableResetTimer { + private timer: ReturnType | null = null + + constructor(private readonly delayMs: number) {} + + schedule(args: { + getState: () => SharedControlConnectionState + getSocket: () => WebSocket | null + reset: () => void + }): void { + this.clear() + this.timer = scheduleSharedControlStableReset({ + delayMs: this.delayMs, + getState: args.getState, + getSocket: args.getSocket, + reset: args.reset, + clearCurrent: () => { + this.timer = null + } + }) + } + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 9c22198f7df..72f139a4945 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -9,6 +9,7 @@ import type { SharedControlReadyWaiter } from './remote-runtime-shared-control-types' import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control-protocol' +import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' export function buildSharedControlDiagnostics(args: { state: SharedControlConnectionState @@ -149,7 +150,14 @@ export function handleSharedControlSubscriptionResponse( subscription.remoteSubscriptionId = subscriptionId } } - subscription.callbacks.onResponse(response) + let delivered = response + if (subscription.pendingReplayTag) { + subscription.pendingReplayTag = false + if (response.ok) { + delivered = tagRuntimeSubscriptionReplayResponse(response) + } + } + subscription.callbacks.onResponse(delivered) if (response.ok && isEndResult(response.result)) { finishSharedControlSubscription(subscriptions, subscription, false) } diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 466d1135471..d4122eac9e4 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -90,6 +90,9 @@ export function sendSharedControlCleanupRequest(args: { export function replaySharedControlSubscriptions(args: { subscriptions: Map> send: (subscription: SharedControlLogicalSubscription) => void + // Why: true only for a reconnect of a previously-ready connection; first + // connects deliver their initial snapshot through the normal gated path. + tagReplayedResponses?: boolean }): void { for (const subscription of args.subscriptions.values()) { if (subscription.closeAfterReady) { @@ -97,6 +100,9 @@ export function replaySharedControlSubscriptions(args: { } subscription.sent = false subscription.remoteSubscriptionId = null + if (args.tagReplayedResponses) { + subscription.pendingReplayTag = true + } args.send(subscription) } } diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 9f784faa8a6..4eb671ea3b3 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -30,6 +30,10 @@ export type SharedControlLogicalSubscription = { closed: boolean closeAfterReady: boolean remoteSubscriptionId: string | null + // Why: set while awaiting the first response after a reconnect replay; that + // response is the authoritative re-emitted snapshot and gets tagged so + // monotonic freshness gates don't drop it (#7718). + pendingReplayTag?: boolean } export type SharedControlReadyWaiter = { diff --git a/src/shared/remote-runtime-socket-liveness.ts b/src/shared/remote-runtime-socket-liveness.ts new file mode 100644 index 00000000000..2903a72c744 --- /dev/null +++ b/src/shared/remote-runtime-socket-liveness.ts @@ -0,0 +1,75 @@ +// Why: a half-open tunnel (devtunnel/NAT drop) never delivers a ws `close`, +// so edge-triggered reconnect logic on the client side never fires while the +// server has long since reaped its end (#7718/#7489). This monitor gives +// client sockets a level-based liveness check: ping on a cadence and declare +// the socket dead when no inbound traffic (frames, pings, or pongs) arrives +// within the window, so the existing close/reconnect path can run. + +// Why: pings ride the RFC 6455 control-frame layer, which every supported +// server (and the `ws` package it embeds) answers automatically — this stays +// backward compatible with old servers that predate client-side liveness. +export const REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS = 10_000 +// Why: just under two server heartbeat periods (15s), so a dead link is +// detected on a similar horizon to the server's own ping/terminate reaper. +export const REMOTE_RUNTIME_SOCKET_LIVENESS_TIMEOUT_MS = 25_000 + +export type RemoteRuntimeSocketLivenessOptions = { + pingIntervalMs?: number + livenessTimeoutMs?: number +} + +export type RemoteRuntimeSocketLivenessMonitor = { + noteActivity: () => void + stop: () => void +} + +export function startRemoteRuntimeSocketLiveness(args: { + ping: () => void + onDead: () => void + options?: RemoteRuntimeSocketLivenessOptions + now?: () => number +}): RemoteRuntimeSocketLivenessMonitor { + const now = args.now ?? Date.now + const pingIntervalMs = args.options?.pingIntervalMs ?? REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS + const livenessTimeoutMs = + args.options?.livenessTimeoutMs ?? REMOTE_RUNTIME_SOCKET_LIVENESS_TIMEOUT_MS + let lastActivityAt = now() + let stopped = false + + const timer = setInterval(() => { + if (stopped) { + return + } + if (now() - lastActivityAt > livenessTimeoutMs) { + stop() + args.onDead() + return + } + try { + args.ping() + } catch { + // Why: ping() can throw while a socket is mid-teardown; the liveness + // window (or the close handler) settles the socket's fate either way. + } + }, pingIntervalMs) + // Why: mobile typechecks shared code with DOM timer types where unref is absent. + const unrefable = timer as unknown as { unref?: () => void } + if (typeof unrefable.unref === 'function') { + unrefable.unref() + } + + function stop(): void { + if (stopped) { + return + } + stopped = true + clearInterval(timer) + } + + return { + noteActivity: () => { + lastActivityAt = now() + }, + stop + } +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index f73c4010f4e..2ebc69005f5 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -4,10 +4,15 @@ import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './types' +import type { SshConnectionState } from './ssh-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + // Why: SSH connections live on the runtime host; paired clients have no IPC + // channel for ssh:state-changed, so without this event their reconnect + // overlays never learn the host connected (STA-1468). + | { type: 'sshStateChanged'; targetId: string; state: SshConnectionState } | { type: 'linearLinkedIssueUpdated' worktreeId: string diff --git a/src/shared/runtime-subscription-replay.ts b/src/shared/runtime-subscription-replay.ts new file mode 100644 index 00000000000..b290f1f2ce8 --- /dev/null +++ b/src/shared/runtime-subscription-replay.ts @@ -0,0 +1,25 @@ +// Why: after a shared-control reconnect, the client replays its logical +// subscriptions and the server re-emits each stream's current snapshot. When +// the host did not restart, that snapshot carries the same publicationEpoch/ +// snapshotVersion the client already applied, so monotonic freshness gates +// (e.g. shouldApplyWebSessionTabsSnapshot) would silently drop it and leave +// mirrors frozen (#7718). The connection tags the first response delivered +// after a replay so consumers can treat it as authoritative. The tag is added +// client-side after wire parsing — nothing changes on the protocol, so old +// servers are unaffected. + +const RUNTIME_SUBSCRIPTION_REPLAY_FLAG = '_replayedAfterReconnect' + +export function tagRuntimeSubscriptionReplayResponse( + response: TResponse +): TResponse { + return { ...response, [RUNTIME_SUBSCRIPTION_REPLAY_FLAG]: true } +} + +export function isRuntimeSubscriptionReplayResponse(response: unknown): boolean { + return ( + typeof response === 'object' && + response !== null && + (response as Record)[RUNTIME_SUBSCRIPTION_REPLAY_FLAG] === true + ) +} diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 925937c8811..7cc30477126 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -117,6 +117,14 @@ describe('wsl login shell command helpers', () => { expect(command).toContain('getent passwd') expect(command).toContain('if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then') + expect(command).toContain('_orca_shell_ready_root=""') + expect(command).toContain('if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then') + expect(command).toContain('_orca_wsl_shell_name=$(basename "$_orca_wsl_shell"') + expect(command).toContain('bash)') + expect(command).toContain('--rcfile "${_orca_shell_ready_root}/bash/rcfile"') + expect(command).toContain('zsh)') + expect(command).toContain('export ZDOTDIR="${_orca_shell_ready_root}/zsh"') expect(command).toContain('exec "$_orca_wsl_shell" -l') + expectValidShSyntax(command) }) }) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 45356f4020f..d95939eaa36 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -45,6 +45,23 @@ export function buildWslInteractiveLoginShellCommand(): string { 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', ' _orca_wsl_shell=/bin/sh', 'fi', + '_orca_shell_ready_root=""', + 'if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then', + ' _orca_shell_ready_root="${ORCA_USER_DATA_PATH%/}/shell-ready"', + 'fi', + '_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")', + 'case "$_orca_wsl_shell_name" in', + ' bash)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -f "${_orca_shell_ready_root}/bash/rcfile" ]; then', + ' exec "$_orca_wsl_shell" --rcfile "${_orca_shell_ready_root}/bash/rcfile"', + ' fi', + ' ;;', + ' zsh)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -d "${_orca_shell_ready_root}/zsh" ]; then', + ' export ZDOTDIR="${_orca_shell_ready_root}/zsh"', + ' fi', + ' ;;', + 'esac', 'exec "$_orca_wsl_shell" -l' ].join('\n') }