From 402e49203da7866abca6e575c176e9c06e49ed09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:33:26 -0700 Subject: [PATCH 01/22] fix(codex): keep persistent panes logged in after home routing (#11720) --- src/main/codex-accounts/fs-utils.test.ts | 162 +++- src/main/codex-accounts/fs-utils.ts | 155 +++- ...legacy-shared-config-compatibility.test.ts | 144 ++++ .../legacy-shared-config-compatibility.ts | 25 + .../runtime-home-service.test.ts | 782 +++++++++++++++++- .../codex-accounts/runtime-home-service.ts | 599 ++++++++++++-- src/main/codex/codex-config-mirror.test.ts | 18 + src/main/codex/codex-config-mirror.ts | 48 +- src/main/codex/codex-pane-account-registry.ts | 121 ++- .../codex/codex-pane-launch-account.test.ts | 66 +- src/main/codex/codex-pane-launch-account.ts | 62 +- src/main/codex/codex-real-home-path.test.ts | 59 +- src/main/codex/codex-real-home-path.ts | 94 +++ .../codex/codex-stale-pane-accounts.test.ts | 318 ++++++- src/main/codex/codex-stale-pane-accounts.ts | 24 +- src/main/daemon/daemon-init.ts | 20 + src/main/index.ts | 13 + src/main/ipc/codex-accounts.ts | 3 +- src/main/ipc/pty.test.ts | 137 ++- src/main/ipc/pty.ts | 38 + .../startup/desktop-startup-ordering.test.ts | 22 + src/preload/api-types.ts | 12 +- src/preload/index.ts | 7 +- .../src/components/CodexRestartChip.tsx | 39 +- .../src/components/codex-restart-chip.test.ts | 42 + .../codex-restart-notice-key.test.ts | 12 +- .../components/codex-restart-notice-key.ts | 3 +- src/renderer/src/i18n/locales/en.json | 6 +- ...odex-session-restart-route-recheck.test.ts | 164 ++++ .../src/lib/codex-session-restart.test.ts | 22 + src/renderer/src/lib/codex-session-restart.ts | 63 +- .../codex-restart-notice-lifecycle.test.ts | 21 + src/renderer/src/store/slices/terminals.ts | 10 + 33 files changed, 3175 insertions(+), 136 deletions(-) create mode 100644 src/main/codex-accounts/legacy-shared-config-compatibility.test.ts create mode 100644 src/main/codex-accounts/legacy-shared-config-compatibility.ts create mode 100644 src/renderer/src/lib/codex-session-restart-route-recheck.test.ts diff --git a/src/main/codex-accounts/fs-utils.test.ts b/src/main/codex-accounts/fs-utils.test.ts index 0932ef41215..45ab88f2baa 100644 --- a/src/main/codex-accounts/fs-utils.test.ts +++ b/src/main/codex-accounts/fs-utils.test.ts @@ -1,13 +1,32 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { copyFileWithWindowsRetry, + removeFileAtomicallyIfUnchanged, renameFileWithWindowsRetry, - writeFileAtomically + writeFileAtomically, + writeFileAtomicallyIfUnchanged } from './fs-utils' +const fsMock = vi.hoisted(() => ({ linkSync: vi.fn(), renameSync: vi.fn() })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsMock.linkSync.mockImplementation(actual.linkSync) + fsMock.renameSync.mockImplementation(actual.renameSync) + return { ...actual, linkSync: fsMock.linkSync, renameSync: fsMock.renameSync } +}) + +const originalPlatform = process.platform + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + vi.restoreAllMocks() +}) + describe('writeFileAtomically', () => { let dir: string @@ -81,6 +100,145 @@ describe('writeFileAtomically', () => { cleanup() } }) + + it('replaces only the expected file generation', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('next') + + writeFileSync(target, 'concurrent') + expect(writeFileAtomicallyIfUnchanged(target, 'next', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) + + it('validates the generation moved after a Windows rename retry', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + Object.defineProperty(process, 'platform', { value: 'win32' }) + fsMock.renameSync.mockClear() + fsMock.renameSync.mockImplementationOnce(() => { + writeFileSync(target, 'concurrent') + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + expect(fsMock.renameSync).toHaveBeenCalledTimes(2) + } finally { + cleanup() + } + }) + + it('preserves a replacement created before guarded publication', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + const rename = fsMock.renameSync.getMockImplementation() + fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => { + rename!(source, destination) + writeFileSync(target, 'concurrent') + }) + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) + + it('recovers an interrupted guarded operation before retrying', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + const held = `${target}.orca-guarded` + writeFileSync(held, 'baseline') + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('next') + expect(existsSync(held)).toBe(false) + } finally { + cleanup() + } + }) + + it('fails before moving the target when hard-link publication is unsupported', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + fsMock.linkSync.mockImplementationOnce(() => { + throw Object.assign(new Error('unsupported'), { code: 'ENOTSUP' }) + }) + + expect(() => writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toThrow( + 'unsupported' + ) + expect(readFileSync(target, 'utf-8')).toBe('baseline') + expect(existsSync(`${target}.orca-guarded`)).toBe(false) + } finally { + cleanup() + } + }) + + it('removes only the expected file generation', () => { + setup() + try { + const target = join(dir, 'guarded-auth.json') + writeFileSync(target, 'baseline') + + expect(removeFileAtomicallyIfUnchanged(target, 'other')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('baseline') + expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(true) + expect(existsSync(target)).toBe(false) + } finally { + cleanup() + } + }) + + it('returns false when the target disappears during the removal probe', () => { + setup() + try { + const target = join(dir, 'guarded-auth.json') + writeFileSync(target, 'baseline') + fsMock.linkSync.mockImplementationOnce(() => { + rmSync(target) + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) + }) + + expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(false) + expect(existsSync(`${target}.orca-guarded`)).toBe(false) + } finally { + cleanup() + } + }) + + it('preserves a replacement created while the expected generation is quarantined', () => { + setup() + try { + const target = join(dir, 'guarded-auth.json') + writeFileSync(target, 'baseline') + const rename = fsMock.renameSync.getMockImplementation() + fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => { + rename!(source, destination) + writeFileSync(target, 'concurrent') + }) + + expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) }) describe('retrying file operations', () => { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 7773e8bd2ab..e0610cc79c7 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto' -import { copyFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { grantDirAcl, isPermissionError } from '../win32-utils' +import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' export function writeFileAtomically( targetPath: string, @@ -39,6 +40,158 @@ export function writeFileAtomically( } } +export function writeFileAtomicallyIfUnchanged( + targetPath: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } +): boolean { + try { + return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options) + } catch (error) { + if (!isPermissionError(error) || process.platform !== 'win32') { + throw error + } + grantDirAcl(dirname(targetPath)) + return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options) + } +} + +export function removeFileAtomicallyIfUnchanged( + targetPath: string, + expectedContents: string +): boolean { + const heldPath = getGuardedOperationHeldPath(targetPath) + recoverInterruptedGuardedOperation(heldPath, targetPath) + try { + assertHardLinkPublicationSupported(targetPath, targetPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + try { + renameFileWithWindowsRetry(targetPath, heldPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + try { + if (!nodeFileContentsEqualSync(heldPath, expectedContents)) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + return false + } + rmSync(heldPath, { force: true }) + return !existsSync(targetPath) + } catch (error) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + throw error + } +} + +export function recoverInterruptedGuardedFileOperation(targetPath: string): void { + recoverInterruptedGuardedOperation(getGuardedOperationHeldPath(targetPath), targetPath) +} + +function restoreMovedFileWithoutOverwrite(sourcePath: string, targetPath: string): void { + if (!existsSync(sourcePath)) { + return + } + publishFileWithoutOverwrite(sourcePath, targetPath) + rmSync(sourcePath, { force: true }) +} + +function getGuardedOperationHeldPath(targetPath: string): string { + return `${targetPath}.orca-guarded` +} + +function recoverInterruptedGuardedOperation(heldPath: string, targetPath: string): void { + if (!existsSync(heldPath)) { + return + } + publishFileWithoutOverwrite(heldPath, targetPath) + rmSync(heldPath, { force: true }) +} + +function attemptGuardedAtomicWrite( + targetPath: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } +): boolean { + const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp` + const heldPath = getGuardedOperationHeldPath(targetPath) + recoverInterruptedGuardedOperation(heldPath, targetPath) + try { + writeFileSync(tmpPath, contents, { encoding: 'utf-8', mode: options?.mode }) + if (expectedContents === null) { + return publishFileWithoutOverwrite(tmpPath, targetPath) + } + assertHardLinkPublicationSupported(tmpPath, targetPath) + try { + renameFileWithWindowsRetry(targetPath, heldPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + if (!nodeFileContentsEqualSync(heldPath, expectedContents)) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + return false + } + if (!publishFileWithoutOverwrite(tmpPath, targetPath)) { + rmSync(heldPath, { force: true }) + return false + } + rmSync(heldPath, { force: true }) + return true + } catch (error) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + throw error + } finally { + rmSync(tmpPath, { force: true }) + } +} + +function assertHardLinkPublicationSupported(sourcePath: string, targetPath: string): void { + const probePath = `${targetPath}.${process.pid}.${randomUUID()}.link-probe` + try { + if (!publishFileWithoutOverwrite(sourcePath, probePath)) { + throw new Error(`Guarded file publication probe already exists: ${probePath}`) + } + } finally { + rmSync(probePath, { force: true }) + } +} + +function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { + try { + linkSync(sourcePath, targetPath) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false + } + if (isPermissionError(error) && process.platform === 'win32') { + grantDirAcl(dirname(targetPath)) + try { + linkSync(sourcePath, targetPath) + return true + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') { + return false + } + throw retryError + } + } + throw error + } +} + // Why: on Windows, file replacement and backup-copy operations can fail with // EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) // holds the target file open. A short retry avoids transient failures without diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts new file mode 100644 index 00000000000..4e2dcdf3f74 --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type * as FsUtils from './fs-utils' + +const generationRace = vi.hoisted(() => ({ + path: null as string | null, + beforeGuardedReplace: null as (() => void) | null +})) + +vi.mock('./fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomicallyIfUnchanged: ( + path: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } + ) => { + if (path === generationRace.path) { + generationRace.beforeGuardedReplace?.() + } + return actual.writeFileAtomicallyIfUnchanged(path, expectedContents, contents, options) + } + } +}) + +let root: string +let sharedRuntimeHome: string +let systemCodexHome: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-home-')) + sharedRuntimeHome = join(root, 'codex-runtime-home', 'home') + systemCodexHome = join(root, 'system-home', '.codex') + mkdirSync(sharedRuntimeHome, { recursive: true }) + mkdirSync(systemCodexHome, { recursive: true }) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + generationRace.path = null + generationRace.beforeGuardedReplace = null + vi.restoreAllMocks() +}) + +describe('legacy shared Codex config compatibility', () => { + it('refreshes retained panes with the authenticated production provider config', () => { + const staleSharedAuth = '{"tokens":{"access_token":"stale"}}\n' + const systemConfig = [ + 'model_provider = "codex-lb"', + '', + '[model_providers.codex-lb]', + 'base_url = "https://codex-lb.example.test/v1"', + 'requires_openai_auth = true', + '' + ].join('\n') + writeFileSync(join(systemCodexHome, 'config.toml'), systemConfig, 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'auth.json'), staleSharedAuth) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model_provider = "stale-provider"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model_provider = "codex-lb"') + expect(sharedConfig).toContain('requires_openai_auth = true') + expect(sharedConfig).not.toContain('stale-provider') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe(systemConfig) + expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) + }) + + it('does not delete config when the canonical source is transiently missing', () => { + const staleConfig = 'model_provider = "stale-provider"\n' + writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe(staleConfig) + }) + + it('does not promote retained-pane settings into the real home', () => { + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'config.toml'), 'model = "retained-pane-change"\n') + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe( + 'model = "canonical"\n' + ) + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe( + 'model = "canonical"\n' + ) + }) + + it('does not overwrite trust appended by a retained Codex during the mirror', () => { + const runtimeConfigPath = join(sharedRuntimeHome, 'config.toml') + const runtimeConfig = 'model = "runtime-before"\n' + const concurrentConfig = [ + runtimeConfig.trimEnd(), + '', + '[projects."/newly-trusted"]', + 'trust_level = "trusted"', + '' + ].join('\n') + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + writeFileSync(runtimeConfigPath, runtimeConfig, 'utf-8') + generationRace.path = runtimeConfigPath + generationRace.beforeGuardedReplace = () => { + writeFileSync(runtimeConfigPath, concurrentConfig, 'utf-8') + } + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe(concurrentConfig) + }) + + it('does not block real-home launch when compatibility config refresh fails', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + mkdirSync(join(sharedRuntimeHome, 'config.toml')) + + expect(() => + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + ).not.toThrow() + + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Failed to refresh legacy shared config:', + expect.anything() + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.ts new file mode 100644 index 00000000000..65e663b0faf --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.ts @@ -0,0 +1,25 @@ +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { syncSystemConfigIntoLegacySharedCodexHome } from '../codex/codex-config-mirror' + +type LegacySharedCodexConfigPaths = { + sharedRuntimeHome: string + systemCodexHome: string +} + +export function syncLegacySharedCodexConfigForRetainedPanes( + paths?: LegacySharedCodexConfigPaths +): void { + try { + const resolvedPaths = paths ?? { + sharedRuntimeHome: getOrcaManagedCodexHomePath(), + systemCodexHome: getSystemCodexHomePath() + } + syncSystemConfigIntoLegacySharedCodexHome({ + runtimeHomePath: resolvedPaths.sharedRuntimeHome, + systemHomePath: resolvedPaths.systemCodexHome + }) + } catch (error) { + // Why: compatibility for a retained pane must never block real-home Codex. + console.warn('[codex-runtime-home] Failed to refresh legacy shared config:', error) + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 7351fd17a91..d1a8b1bed0d 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, readlinkSync, readFileSync, + renameSync, rmSync, statSync, symlinkSync, @@ -201,6 +202,20 @@ function getRuntimeCodexAuthPath(): string { return join(getRuntimeCodexHomePath(), 'auth.json') } +function getSharedRuntimeAuthProvenancePath(): string { + return join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json') +} + +function writePaneRegistry( + panes: Record +): void { + writeFileSync( + join(testState.userDataDir, 'codex-pane-accounts.json'), + `${JSON.stringify({ version: 2, panes })}\n`, + 'utf-8' + ) +} + function getLegacyActiveHostCodexHomePath(): string { return join(testState.userDataDir, 'codex-runtime-home', 'active', 'host', 'home') } @@ -252,7 +267,8 @@ function createCodexAuthJson( email: string, accountId: string, refreshToken: string, - expiresAt?: number + expiresAt?: number, + lastRefresh?: string ): string { const idToken = [ encodeJwtPart({ alg: 'none', typ: 'JWT' }), @@ -269,6 +285,7 @@ function createCodexAuthJson( return `${JSON.stringify({ auth_mode: 'chatgpt', + ...(lastRefresh === undefined ? {} : { last_refresh: lastRefresh }), tokens: { access_token: `access-${accountId}`, id_token: idToken, @@ -293,6 +310,13 @@ describe('CodexRuntimeHomeService', () => { process.env.ORCA_USER_DATA_PATH = testState.userDataDir mkdirSync(getSystemCodexHomePath(), { recursive: true }) mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writePaneRegistry({ + 'retained-shared-pane': { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + } + }) }) afterEach(() => { @@ -1041,12 +1065,14 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.getSelectedHostCodexHomeRoute()).toBe('real-home') expect(service.prepareForCodexLaunch()).toBeNull() expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) service.setRealHomeLaneGate(() => false) + expect(service.getSelectedHostCodexHomeRoute()).toBe('shared-home') expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) const markerPath = join( testState.userDataDir, @@ -1107,6 +1133,642 @@ describe('CodexRuntimeHomeService', () => { } }) + it('seeds shared auth for a pane-local custom home on the real-home lane', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const customHome = join(testState.fakeHomeDir, 'pane-custom-codex-home') + + expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: customHome })).toBe( + getRuntimeCodexHomePath() + ) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('skips retired-home reconciliation when no retained host pane can use it', async () => { + const syncLegacySharedCodexConfigForRetainedPanes = vi.fn() + vi.doMock('./legacy-shared-config-compatibility', () => ({ + syncLegacySharedCodexConfigForRetainedPanes + })) + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained') + const currentAuth = createCodexAuthJson('system@example.com', 'acct-system', 'current') + const retainedConfig = 'model = "retained"\n' + writePaneRegistry({ + 'real-home-pane': { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + } + }) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), retainedConfig, 'utf-8') + writeFileSync(getSystemCodexAuthPath(), currentAuth, 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), 'model = "current"\n', 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'system-default', authJson: retainedAuth })}\n`, + 'utf-8' + ) + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.reconcileLegacySharedHomeForRetainedPanes() + expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + + expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( + retainedConfig + ) + } finally { + vi.doUnmock('./legacy-shared-config-compatibility') + } + }) + + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { + const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const systemConfig = [ + 'model_provider = "codex-lb"', + '', + '[model_providers.codex-lb]', + 'base_url = "https://codex-lb.example.test/v1"', + 'requires_openai_auth = true', + '' + ].join('\n') + writeFileSync(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') + writeFileSync( + join(getRuntimeCodexHomePath(), 'config.toml'), + 'model_provider = "stale-provider"\n', + 'utf-8' + ) + + const service = new CodexRuntimeHomeService(store as never) + + service.setRealHomeLaneGate(() => true) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toContain( + 'stale-provider' + ) + service.reconcileLegacySharedHomeForRetainedPanes() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(systemConfig) + expect(service.prepareForCodexLaunch()).toBeNull() + }) + + it('preserves retained managed auth across a real-home main-process restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('preserves same-identity retained managed auth after restart and source logout', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + const restartedService = new CodexRuntimeHomeService(store as never) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('fences retained shared auth when a self-contained managed transition begins', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + rmSync(getSystemCodexAuthPath()) + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('restores retained system ownership when a self-contained transition leaves it untouched', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('restores authoritative system auth when the shared lane follows a managed transition', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(false) + service.syncForCurrentSelection() + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('refreshes untouched retained-pane auth during real-home rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('does not rewrite retained-auth provenance during unchanged rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const provenancePath = getSharedRuntimeAuthProvenancePath() + const originalInode = statSync(provenancePath).ino + + setRealHomeLaneForTest(true) + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + + expect(statSync(provenancePath).ino).toBe(originalInode) + }) + + it('does not rewrite completed retained logout metadata during rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + service.prepareForRateLimitFetch() + const metadataPaths = [ + getSharedRuntimeAuthProvenancePath(), + join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ] + const originalInodes = metadataPaths.map((path) => statSync(path).ino) + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + + expect(metadataPaths.map((path) => statSync(path).ino)).toEqual(originalInodes) + }) + + it('clears managed transition state before later retained-auth reconciliation', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + writeFileSync(getRuntimeCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json'), + `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n` + ) + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('does not overwrite auth changed by a retained Codex process', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('applies source logout after a retained pane refreshes the same identity', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + rmSync(getSystemCodexAuthPath()) + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + }) + + it('repairs a completed pending system-auth replacement after restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('recovers runtime auth quarantined by an interrupted guarded update', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + const runtimeAuthPath = getRuntimeCodexAuthPath() + const heldAuthPath = `${runtimeAuthPath}.orca-guarded` + renameSync(runtimeAuthPath, heldAuthPath) + + setRealHomeLaneForTest(true) + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(existsSync(heldAuthPath)).toBe(false) + }) + + it('fences a pending auth replacement when runtime bytes do not match its intent', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + }) + + it('does not treat malformed provenance as a missing migration marker', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('recreates proven logged-out shared auth after a real-home re-login', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + + it.each([ + ['committed provenance', false], + ['pre-provenance migration', true] + ])( + 'recreates retained auth after a %s crash between logout deletion and metadata commit', + async (_label, removeProvenance) => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + rmSync(getRuntimeCodexAuthPath()) + if (removeProvenance) { + rmSync(getSharedRuntimeAuthProvenancePath()) + } + rmSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json'), + { + force: true + } + ) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + } + ) + + it('recreates retained auth after interrupted logout crosses a managed transition', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: false, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + rmSync(getRuntimeCodexAuthPath()) + rmSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json'), + { + force: true + } + ) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + restartedService.reconcileLegacySharedHomeForRetainedPanes() + + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + restartedService.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + restartedService.syncForCurrentSelection() + + restartedService.prepareForRateLimitFetch() + restartedService.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + + it('preserves shared config changes when a pending real-home lane falls back', async () => { + const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') + const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') + writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') + + setRealHomeLaneForTest(true) + service.setRealHomeLaneGate(() => false) + service.reconcileLegacySharedHomeForRetainedPanes() + expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "baseline"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + }) + it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() @@ -3179,13 +3841,21 @@ describe('CodexRuntimeHomeService', () => { ).toEqual({ authJson: refreshedAuth }) }) - it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => { + it('reads back system-default token refreshes after a pre-provenance restart', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-old', + undefined, + '2026-07-30T12:00:00.000Z' + ) const refreshedAuth = createCodexAuthJson( 'system@example.com', 'acct-system', - 'system-refreshed' + 'system-refreshed', + undefined, + '2026-07-31T12:00:00.000Z' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const store = createStore(createSettings()) @@ -3194,6 +3864,7 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) const restartedService = new CodexRuntimeHomeService(store as never) restartedService.syncForCurrentSelection() @@ -3201,6 +3872,37 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) }) + it('does not read back older same-identity auth without provenance', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-newer', + undefined, + '2026-07-31T12:00:00.000Z' + ) + const staleRuntimeAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'runtime-older', + undefined, + '2026-07-30T12:00:00.000Z' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + }) + it('keeps a local runtime logout when the system-default auth still exists', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -3331,6 +4033,36 @@ describe('CodexRuntimeHomeService', () => { ).toBe(true) }) + it('clears refreshed runtime auth after a pre-provenance external logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system', + undefined, + '2026-07-30T12:00:00.000Z' + ) + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed', + undefined, + '2026-07-31T12:00:00.000Z' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) + rmSync(getSystemCodexAuthPath()) + new CodexRuntimeHomeService(store as never) + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('persists runtime auth refreshes after returning to system default', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') @@ -4429,6 +5161,48 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) + it('recreates retained auth after switching from managed to logged-out system default', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath()) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + + setRealHomeLaneForTest(true) + service.setRealHomeLaneGate(() => true) + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + service.reconcileLegacySharedHomeForRetainedPanes() + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 5813e4b0d68..e9256145b77 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -32,7 +32,12 @@ import type { CodexManagedAccount } from '../../shared/types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { writeFileAtomically } from './fs-utils' +import { + recoverInterruptedGuardedFileOperation, + removeFileAtomicallyIfUnchanged, + writeFileAtomically, + writeFileAtomicallyIfUnchanged +} from './fs-utils' import { getOrcaManagedCodexHomePath, getOrcaUserDataPath, @@ -63,9 +68,8 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' -import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' -import { isShellStartupEnvProbeSupported, readShellStartupEnvVar } from '../pty/shell-startup-env' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthIsFresher, @@ -74,6 +78,12 @@ import { } from './codex-auth-identity' import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' import { hasStoredCodexCredential } from './managed-codex-auth-readiness' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import { + hasRecordedLegacySharedCodexPane, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -84,6 +94,26 @@ type CodexRuntimeLogoutMarker = { loggedOutAt: number } +type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } +type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} +type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } +type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + type CodexRuntimeLogoutMarkerStatus = | { kind: 'missing' } | { kind: 'applies' } @@ -99,18 +129,40 @@ type CodexReadBackMatch = } | { kind: 'none' | 'ambiguous' } -function readLaunchEnvValue( - launchEnv: NodeJS.ProcessEnv, - key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' -): string | undefined { - return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] +function readCodexLastRefresh(authJson: string): number | null { + try { + const parsed = JSON.parse(authJson) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const value = (parsed as Record).last_refresh + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } } -function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { - CODEX_HOME: readLaunchEnvValue(launchEnv, 'CODEX_HOME'), - ORCA_CODEX_HOME: readLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') +function codexAuthIsMonotonicallyFresher( + candidateAuthJson: string, + baselineAuthJson: string +): boolean { + const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) + const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) + if (candidateLastRefresh !== null || baselineLastRefresh !== null) { + return ( + candidateLastRefresh !== null && + baselineLastRefresh !== null && + candidateLastRefresh > baselineLastRefresh + ) } + return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) } export class CodexRuntimeHomeService { @@ -126,8 +178,10 @@ export class CodexRuntimeHomeService { // Why: a flag-ON host account refreshes auth in its own home. Remember that // provenance so a later deselect/rollback never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false + private sharedAuthRefreshBlockedByManagedTransition = false constructor(private readonly store: Store) { + this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() this.safeMigrateLegacyActiveHomePointer() @@ -181,12 +235,12 @@ export class CodexRuntimeHomeService { if (this.isHostSystemDefaultRealHome(launchEnv)) { // Why (flag ON, system default): run Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // sessions, auth, and config all live in the native home. No system-> - // managed session bridge runs, so the real home stays the single source. + // the retired mirror is refreshed only for pre-rollout PTYs. + this.reconcileLegacySharedHomeForRetainedPanes() return null } this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) - this.syncForCurrentSelection() + this.syncForCurrentSelection(target, launchEnv) syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. @@ -264,6 +318,8 @@ export class CodexRuntimeHomeService { // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) syncSystemCodexResourcesIntoManagedHome(perAccountHome) syncSystemConfigIntoManagedCodexHome({ runtimeHomePath: perAccountHome, @@ -305,6 +361,8 @@ export class CodexRuntimeHomeService { if (perAccountHome) { this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) // Why: selection runs well before the user restarts a pane, so history is // already linked in by the time the newly launched Codex opens /resume. this.startSelfContainedSessionBridgeForLaunch(perAccountHome) @@ -424,6 +482,13 @@ export class CodexRuntimeHomeService { : null } + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. @@ -440,33 +505,26 @@ export class CodexRuntimeHomeService { const settings = this.store.getSettings() if ( !isCodexSystemDefaultRealHomeEnabled() || - normalizeCodexRuntimeSelection(settings).host !== null + normalizeCodexRuntimeSelection(settings).host !== null || + !isShellStartupEnvProbeSupported() ) { return false } - // Why: PTY callers can overlay environment values that the Electron main - // process never inherited. Those custom homes must keep the managed lane. - const effectiveEnv = launchEnv ? getEffectiveCodexHomeEnv(launchEnv) : process.env - if (hasCustomCodexHomeOverride(effectiveEnv)) { - return false - } - if (!isShellStartupEnvProbeSupported()) { - return false - } - // Why: Finder/Dock launches do not inherit shell exports, but the login - // shell can re-export a custom home after spawn and bypass the trusted lane. - const shellCodexHome = readShellStartupEnvVar( - 'CODEX_HOME', - launchEnv ? readLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME, - launchEnv ? readLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL - ) - return !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome }) + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) } isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() } + reconcileLegacySharedHomeForRetainedPanes(): void { + if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { + return + } + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() + } + syncActiveWslSelectionsBeforeRestart(): void { if (process.platform !== 'win32') { return @@ -546,6 +604,9 @@ export class CodexRuntimeHomeService { // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the // managed home, restarting the background OAuth conflict (#5370), so // pin this non-interactive lane to the native home explicitly. + if (hasRecordedLegacySharedCodexPane()) { + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + } return getSystemCodexHomePath() } this.syncForCurrentSelection() @@ -554,7 +615,10 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - syncForCurrentSelection(target?: CodexAccountSelectionTarget): void { + syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void { if (target?.runtime === 'wsl') { this.syncWslRuntimeForCurrentSelection(target) return @@ -567,7 +631,6 @@ export class CodexRuntimeHomeService { this.syncSelfContainedManagedSelection(selfContainedAccount) return } - const settings = this.store.getSettings() if (this.lastHostAccountUsedSelfContainedHome) { // Why: E auth is already canonical in the per-account home. Reset the @@ -576,10 +639,20 @@ export class CodexRuntimeHomeService { this.lastHostAccountUsedSelfContainedHome = false this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - if (this.isHostSystemDefaultRealHome()) { + if (this.isHostSystemDefaultRealHome(launchEnv)) { return } } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { this.captureSystemDefaultSnapshot({ force: false }) @@ -701,7 +774,10 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = null } this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) + this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), { + owner: 'managed', + accountId: activeAccount.id + }) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -835,6 +911,14 @@ export class CodexRuntimeHomeService { } } + private safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } + private getActiveAccount( accounts: CodexManagedAccount[], activeAccountId: string | null @@ -1244,6 +1328,10 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') } + private getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + private getRuntimeMetadataDir(): string { const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') mkdirSync(metadataDir, { recursive: true }) @@ -1534,14 +1622,41 @@ export class CodexRuntimeHomeService { try { const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const snapshotAuth = snapshot?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + const systemDefaultOwnershipProven = + provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven + const mirroredSystemDefaultAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null if (!existsSync(systemDefaultAuthPath)) { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) return } if ( + systemDefaultOwnershipProven && mirroredSystemDefaultAuth !== null && this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) ) { @@ -1550,29 +1665,154 @@ export class CodexRuntimeHomeService { return } const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth !== systemDefaultAuth) { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null - if ( - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.lastWrittenAuthJson = runtimeAuth - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth) + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) } catch (error) { console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) } } + private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const knownSystemAuthBaseline = + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' + ? provenanceStatus.provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson) + : undefined + if (systemAuth === null) { + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' && + provenanceStatus.provenance.authJson === null && + logoutMarkerStatus.kind === 'applies' && + snapshot?.authJson === null + ) { + this.lastWrittenAuthJson = null + return + } + // Why: commit a crashed logout before a managed transition can discard its recovery baseline. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if ( + logoutMarkerStatus.kind === 'system-default-changed' || + (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) + ) { + const replaced = this.writeRuntimeAuth( + systemAuth, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() const runtimeAuthPath = this.getRuntimeAuthPath() @@ -1580,7 +1820,7 @@ export class CodexRuntimeHomeService { if (existsSync(systemDefaultAuthPath)) { const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) return } @@ -1588,6 +1828,7 @@ export class CodexRuntimeHomeService { // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) return } @@ -1597,6 +1838,7 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) return } @@ -1613,22 +1855,25 @@ export class CodexRuntimeHomeService { if (!refreshedSnapshot) { rmSync(runtimeAuthPath, { force: true }) this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) return } if (refreshedSnapshot.authJson === null) { rmSync(runtimeAuthPath, { force: true }) this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) return } - this.writeRuntimeAuth(refreshedSnapshot.authJson) + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) return } if (snapshot.authJson === null) { rmSync(runtimeAuthPath, { force: true }) this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) return } - this.writeRuntimeAuth(snapshot.authJson) + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) } private writeSystemDefaultAuth(contents: string): void { @@ -1644,6 +1889,10 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) } private readSystemDefaultAuth(): string | null { @@ -1651,16 +1900,55 @@ export class CodexRuntimeHomeService { return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null } - private writeRuntimeAuth(contents: string): void { + private writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - this.clearRuntimeLogoutMarker() - if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { - this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + const runtimeAuthAlreadyMatches = this.fileContentsEqual(runtimeAuthPath, contents) + if ( + runtimeAuthAlreadyMatches && + this.sharedRuntimeAuthProvenanceMatches( + this.resolveSharedRuntimeAuthProvenanceStatus(), + provenance + ) + ) { + this.ensureOwnerOnlyMode(runtimeAuthPath) this.lastWrittenAuthJson = contents - return + this.clearRuntimeLogoutMarker() + return true + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) + if (runtimeAuthAlreadyMatches) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false } - writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true } private writeRuntimeAuthAtPath(authPath: string, contents: string): void { @@ -1680,6 +1968,13 @@ export class CodexRuntimeHomeService { } } + private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { + if (expectedContents === null) { + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + private ensureOwnerOnlyMode(targetPath: string): void { if (process.platform === 'win32') { return @@ -1744,6 +2039,180 @@ export class CodexRuntimeHomeService { rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) } + private persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + private markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + private getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + private restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + private sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean { + if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { + return false + } + return expected.owner === 'system-default' + ? status.provenance.owner === 'system-default' && + status.provenance.authJson === expected.authJson + : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId + } + + private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + private parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + private parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + private readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { let rawContents: string try { diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index b7e78518606..7fcd1754992 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -26,6 +26,7 @@ vi.mock('node:os', async () => { import { prepareSystemConfigForFreshRuntimeMirror, resolveCodexConfigMirrorSourceDirectory, + syncSystemConfigIntoLegacySharedCodexHome, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' @@ -749,6 +750,23 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) }) +describe('syncSystemConfigIntoLegacySharedCodexHome', () => { + it('recovers an interrupted runtime config when the system source is missing', () => { + const runtimeConfigPath = getRuntimeConfigPath() + const heldConfigPath = `${runtimeConfigPath}.orca-guarded` + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(heldConfigPath, 'model = "retained"\n', 'utf-8') + + syncSystemConfigIntoLegacySharedCodexHome({ + runtimeHomePath: join(userDataDir, 'codex-runtime-home', 'home'), + systemHomePath: getSystemCodexHomePath() + }) + + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "retained"\n') + expect(existsSync(heldConfigPath)).toBe(false) + }) +}) + describe('prepareSystemConfigForFreshRuntimeMirror', () => { it('uses the Linux-side directory for WSL UNC source homes', () => { const sourceDir = resolveCodexConfigMirrorSourceDirectory( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 4b439e20372..10ede4d9cca 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,7 +1,11 @@ import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { readAgentStateFileSync } from '../agent-state-file-reader' -import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + recoverInterruptedGuardedFileOperation, + writeFileAtomically, + writeFileAtomicallyIfUnchanged +} from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' @@ -87,6 +91,48 @@ export function syncSystemConfigIntoManagedCodexHome( ) } +/** + * Refreshes the retired shared home for PTYs that survived real-home rollout. + * + * This is deliberately one-way: a retained PTY may hold pre-rollout settings, + * so treating that home as a promotion source could overwrite the live config. + */ +export function syncSystemConfigIntoLegacySharedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): void { + const systemConfigPath = join(homes.systemHomePath, 'config.toml') + const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') + recoverInterruptedGuardedFileOperation(runtimeConfigPath) + const rawSystemConfig = existsSync(systemConfigPath) + ? readAgentStateFileSync(systemConfigPath) + : '' + // Why: a missing cloud-synced source is not proof the user cleared config. + if (rawSystemConfig.trim() === '') { + return + } + + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) + const runtimeConfigBeforeMirror = existsSync(runtimeConfigPath) + ? readAgentStateFileSync(runtimeConfigPath) + : null + const nextRuntimeConfig = + runtimeConfigBeforeMirror !== null + ? mergeSystemCodexConfigIntoRuntime( + runtimeConfigBeforeMirror, + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + ) + : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) + if (runtimeConfigBeforeMirror === nextRuntimeConfig) { + return + } + // Why: stage first, then compare immediately before replace so a retained + // Codex trust write during mirror preparation wins. + writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index f1d83989453..640dcd17506 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -1,6 +1,10 @@ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { getOrcaUserDataPath } from './codex-home-paths' +import type { + CodexEnvironmentHomeOverride, + CodexShellStartupHomeOverride +} from './codex-real-home-path' /** * Remembers which Codex account each live PTY was launched under. @@ -12,15 +16,28 @@ import { getOrcaUserDataPath } from './codex-home-paths' * the old account and the user is stuck there with no prompt to escape it. */ +export type CodexPaneHomeRoute = + | 'real-home' + | 'shared-home' + | 'account-home' + | 'custom-home' + | 'wsl-home' + export type CodexPaneAccountRecord = { /** 'host' or 'wsl:' — the selection lane this pane launched from. */ selectionKey: string /** Managed account id, or null for the system-default account. */ accountId: string | null + /** Absent only on records written before route provenance was introduced. */ + homeRoute?: CodexPaneHomeRoute + /** Rechecked when CODEX_HOME came from process-global shell startup. */ + shellStartupHomeOverride?: CodexShellStartupHomeOverride + /** Rechecked after restart when CODEX_HOME came from the process environment. */ + environmentHomeOverride?: CodexEnvironmentHomeOverride } type RegistryFile = { - version: 1 + version: 2 panes: Record } @@ -51,7 +68,7 @@ function readRegistryFile(): unknown { } function parseRegistry(parsed: unknown): RegistryFile { - const empty: RegistryFile = { version: 1, panes: {} } + const empty: RegistryFile = { version: 2, panes: {} } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return empty } @@ -61,12 +78,44 @@ function parseRegistry(parsed: unknown): RegistryFile { } for (const [ptyId, record] of Object.entries(panes)) { if (isPaneAccountRecord(record)) { - empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId } + empty.panes[ptyId] = { + selectionKey: record.selectionKey, + accountId: record.accountId, + ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}), + ...(isShellStartupHomeOverride(record.shellStartupHomeOverride) + ? { shellStartupHomeOverride: record.shellStartupHomeOverride } + : {}), + ...(isEnvironmentHomeOverride(record.environmentHomeOverride) + ? { environmentHomeOverride: record.environmentHomeOverride } + : {}) + } } } return empty } +function isEnvironmentHomeOverride(value: unknown): value is CodexEnvironmentHomeOverride { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const context = value as Partial + return typeof context.codexHome === 'string' && context.codexHome.length > 0 +} + +function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupHomeOverride { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const context = value as Partial + return ( + typeof context.home === 'string' && + context.home.length > 0 && + (context.shell === undefined || typeof context.shell === 'string') && + typeof context.codexHome === 'string' && + context.codexHome.length > 0 + ) +} + function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -78,6 +127,16 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { ) } +function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute { + return ( + value === 'real-home' || + value === 'shared-home' || + value === 'account-home' || + value === 'custom-home' || + value === 'wsl-home' + ) +} + function writeRegistry(registry: RegistryFile): void { const registryPath = getRegistryPath() const temporaryPath = `${registryPath}.${process.pid}.tmp` @@ -112,7 +171,16 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe return } const existing = registry.panes[ptyId] - if (existing?.selectionKey === record.selectionKey && existing.accountId === record.accountId) { + if ( + existing?.selectionKey === record.selectionKey && + existing.accountId === record.accountId && + existing.homeRoute === record.homeRoute && + shellStartupHomeOverridesEqual( + existing.shellStartupHomeOverride, + record.shellStartupHomeOverride + ) && + environmentHomeOverridesEqual(existing.environmentHomeOverride, record.environmentHomeOverride) + ) { return } registry.panes[ptyId] = record @@ -125,6 +193,24 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe writeRegistry(registry) } +function environmentHomeOverridesEqual( + left: CodexEnvironmentHomeOverride | undefined, + right: CodexEnvironmentHomeOverride | undefined +): boolean { + return left?.codexHome === right?.codexHome +} + +function shellStartupHomeOverridesEqual( + left: CodexShellStartupHomeOverride | undefined, + right: CodexShellStartupHomeOverride | undefined +): boolean { + return ( + left?.home === right?.home && + left?.shell === right?.shell && + left?.codexHome === right?.codexHome + ) +} + /** * Drops a PTY's record once it exits. */ @@ -159,6 +245,33 @@ export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record + record.selectionKey === 'host' && + (record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home') + ) +} + +/** Drops records whose daemon PTYs are authoritatively absent. */ +export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { + const registry = readRegistry() + const livePtyIdSet = new Set(livePtyIds) + let changed = false + for (const ptyId of Object.keys(registry.panes)) { + if (!livePtyIdSet.has(ptyId)) { + delete registry.panes[ptyId] + changed = true + } + } + if (changed) { + writeRegistry(registry) + } +} + export const _internals = { resetCache: (): void => { cachedRegistry = null diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index f7cb49eb95b..51a9a47508d 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -35,10 +35,22 @@ describe('resolveCodexPaneLaunchAccount', () => { pinnedByResume: false, launchCodexHomePath: '/data/codex-accounts/account-b/home', systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: 'account-b' }), + settings: settings({ + host: 'account-b', + accounts: [ + managedAccount({ + id: 'account-b', + managedHomePath: '/data/codex-accounts/account-b/home' + }) + ] + }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-b' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-b', + homeRoute: 'account-home' + }) }) it('records the origin account a resume pinned the pane to, not the selection', () => { @@ -55,7 +67,39 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-b', accounts }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + }) + + it('records a non-comparable route for a pane-local custom home', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: false, + launchCodexHomePath: '/data/codex-runtime-home/home', + recordComparableHomeRoute: false, + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: null }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }) + }) + + it('keeps an account-owned route comparable when a pane override is ignored', () => { + const accounts = [managedAccount({ id: 'account-a' })] + + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: false, + launchCodexHomePath: '/data/codex-accounts/account-a/home', + recordComparableHomeRoute: false, + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-a', accounts }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }) }) it('records the same account a resume pinned to when it is already selected', () => { @@ -71,7 +115,11 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-a', accounts }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) }) it('maps a resume redirected to the real system home to the system-default account', () => { @@ -86,7 +134,7 @@ describe('resolveCodexPaneLaunchAccount', () => { }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: null }) + ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }) }) it('maps a resume that injects no CODEX_HOME to the system-default account', () => { @@ -98,7 +146,7 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-a', accounts: [managedAccount({ id: 'account-a' })] }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: null }) + ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }) }) it('refuses to attribute a resume home no account owns', () => { @@ -150,7 +198,11 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ wsl: { Ubuntu: 'other-wsl-account' }, accounts }), target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) - ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account' }) + ).toEqual({ + selectionKey: 'wsl:Ubuntu', + accountId: 'wsl-account', + homeRoute: 'wsl-home' + }) }) it('tolerates settings that carry no managed account roster', () => { diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 33d091cef7c..1bb3cca9c28 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -6,7 +6,11 @@ import { getSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import type { CodexPaneAccountRecord } from './codex-pane-account-registry' +import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry' +import type { + CodexEnvironmentHomeOverride, + CodexShellStartupHomeOverride +} from './codex-real-home-path' type CodexPaneLaunchAccountSettings = Pick< GlobalSettings, @@ -28,19 +32,71 @@ type CodexPaneLaunchAccountSettings = Pick< export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean launchCodexHomePath: string | null + recordComparableHomeRoute?: boolean + shellStartupHomeOverride?: CodexShellStartupHomeOverride + environmentHomeOverride?: CodexEnvironmentHomeOverride systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneAccountRecord | null { const selectionKey = getCodexSelectionLaneKey(args.target) + const resolvedHomeRoute = resolveCodexPaneHomeRoute(args) + const homeRoute = + args.recordComparableHomeRoute === false && resolvedHomeRoute === 'shared-home' + ? 'custom-home' + : resolvedHomeRoute if (!args.pinnedByResume) { return { selectionKey, - accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target) + accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target), + ...(homeRoute ? { homeRoute } : {}), + ...(args.shellStartupHomeOverride + ? { shellStartupHomeOverride: args.shellStartupHomeOverride } + : {}), + ...(args.environmentHomeOverride + ? { environmentHomeOverride: args.environmentHomeOverride } + : {}) } } const accountId = resolveCodexHomeOwnerAccountId(args) - return accountId === undefined ? null : { selectionKey, accountId } + return accountId === undefined + ? null + : { + selectionKey, + accountId, + ...(homeRoute ? { homeRoute } : {}), + ...(args.shellStartupHomeOverride + ? { shellStartupHomeOverride: args.shellStartupHomeOverride } + : {}), + ...(args.environmentHomeOverride + ? { environmentHomeOverride: args.environmentHomeOverride } + : {}) + } +} + +function resolveCodexPaneHomeRoute(args: { + launchCodexHomePath: string | null + systemCodexHomePath: string + settings: CodexPaneLaunchAccountSettings + target: CodexAccountSelectionTarget +}): CodexPaneHomeRoute { + if (args.target.runtime === 'wsl') { + return 'wsl-home' + } + if ( + !args.launchCodexHomePath || + normalizeRuntimePathForComparison(args.launchCodexHomePath) === + normalizeRuntimePathForComparison(args.systemCodexHomePath) + ) { + return 'real-home' + } + const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) + const accountOwnsHome = args.settings.codexManagedAccounts?.some( + (account) => + getCodexSelectionTargetForAccount(account).runtime === 'host' && + normalizeRuntimePathForComparison(account.managedHomePath) === launchHome + ) + return accountOwnsHome ? 'account-home' : 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 3d91682d7cc..7dc961c7a18 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -1,6 +1,23 @@ -import { sep } from 'node:path' -import { describe, expect, it } from 'vitest' -import { hasCustomCodexHomeOverride } from './codex-real-home-path' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + getCustomCodexHomeOverrideForLaunch, + hasCustomCodexHomeOverride, + hasCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideMatches +} from './codex-real-home-path' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' + +const temporaryHomes: string[] = [] + +afterEach(() => { + __resetShellStartupEnvCache() + for (const path of temporaryHomes.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) describe('hasCustomCodexHomeOverride', () => { it('recognizes normalized aliases of Orca-owned CODEX_HOME', () => { @@ -22,4 +39,40 @@ describe('hasCustomCodexHomeOverride', () => { }) ).toBe(true) }) + + it('captures explicit environment provenance for restart comparison', () => { + const codexHome = join(process.cwd(), 'custom-codex-home') + + expect(getCustomCodexHomeOverrideForLaunch({ CODEX_HOME: codexHome })).toEqual({ + source: 'environment', + context: { codexHome } + }) + }) + + it.skipIf(process.platform === 'win32')( + 'detects a pane-local shell startup override from its launch HOME', + () => { + const paneHome = mkdtempSync(join(tmpdir(), 'orca-codex-pane-home-')) + temporaryHomes.push(paneHome) + writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') + + expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true) + const override = getCustomCodexHomeOverrideForLaunch({ + HOME: paneHome, + SHELL: '/bin/zsh' + }) + expect(override).toEqual({ + source: 'shell-startup', + context: { + home: paneHome, + shell: '/bin/zsh', + codexHome: join(paneHome, 'custom-codex-home') + } + }) + expect( + override?.source === 'shell-startup' && + shellStartupCodexHomeOverrideMatches(override.context) + ).toBe(true) + } + ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index 6f6db65710a..700359d4f37 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -1,5 +1,20 @@ import { resolve } from 'node:path' import { getSystemCodexHomePath } from './codex-home-paths' +import { readShellStartupEnvVar } from '../pty/shell-startup-env' + +export type CodexShellStartupHomeOverride = { + home: string + shell?: string + codexHome: string +} + +export type CodexEnvironmentHomeOverride = { + codexHome: string +} + +export type CustomCodexHomeOverrideForLaunch = + | { source: 'environment'; context: CodexEnvironmentHomeOverride } + | { source: 'shell-startup'; context: CodexShellStartupHomeOverride } /** True when the user points Codex outside its standard native home. */ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { @@ -18,6 +33,85 @@ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env) ) } +export function hasCustomCodexHomeOverrideForLaunch(launchEnv?: NodeJS.ProcessEnv): boolean { + return getCustomCodexHomeOverrideForLaunch(launchEnv) !== null +} + +export function getCustomCodexHomeOverrideForLaunch( + launchEnv?: NodeJS.ProcessEnv +): CustomCodexHomeOverrideForLaunch | null { + const effectiveEnv = launchEnv + ? { + CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), + ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') + } + : process.env + if (hasCustomCodexHomeOverride(effectiveEnv)) { + return { + source: 'environment', + context: { codexHome: effectiveEnv.CODEX_HOME!.trim() } + } + } + const home = launchEnv ? getLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME + const shell = launchEnv ? getLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL + const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell) + if (!home || !shellCodexHome || !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })) { + return null + } + return { + source: 'shell-startup', + context: { + home, + ...(shell ? { shell } : {}), + codexHome: shellCodexHome + } + } +} + +export function environmentCodexHomeOverrideContextsEqual( + left: CodexEnvironmentHomeOverride, + right: CodexEnvironmentHomeOverride +): boolean { + return normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) +} + +export function shellStartupCodexHomeOverrideMatches( + context: CodexShellStartupHomeOverride, + currentContext: CodexShellStartupHomeOverride = context +): boolean { + if (!shellStartupCodexHomeOverrideContextsEqual(context, currentContext)) { + return false + } + const currentCodexHome = readShellStartupEnvVar( + 'CODEX_HOME', + currentContext.home, + currentContext.shell + ) + return Boolean( + currentCodexHome && + hasCustomCodexHomeOverride({ CODEX_HOME: currentCodexHome }) && + normalizePathForComparison(currentCodexHome) === normalizePathForComparison(context.codexHome) + ) +} + +export function shellStartupCodexHomeOverrideContextsEqual( + left: CodexShellStartupHomeOverride, + right: CodexShellStartupHomeOverride +): boolean { + return ( + normalizePathForComparison(left.home) === normalizePathForComparison(right.home) && + left.shell === right.shell && + normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) + ) +} + +function getLaunchEnvValue( + launchEnv: NodeJS.ProcessEnv, + key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' +): string | undefined { + return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] +} + function normalizePathForComparison(value: string): string { const normalized = resolve(value) return process.platform === 'win32' ? normalized.toLowerCase() : normalized diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ed078f756dc..c5ee4802e5b 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/types' @@ -7,9 +7,12 @@ import { _internals, forgetCodexPaneAccount, getCodexPaneAccount, + hasRecordedLegacySharedCodexPane, + reconcileCodexPaneAccountsWithLivePtys, recordCodexPaneAccount } from './codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' let userDataPath: string let previousUserDataPath: string | undefined @@ -32,6 +35,7 @@ beforeEach(() => { }) afterEach(() => { + __resetShellStartupEnvCache() rmSync(userDataPath, { recursive: true, force: true }) if (previousUserDataPath === undefined) { delete process.env.ORCA_USER_DATA_PATH @@ -43,11 +47,105 @@ afterEach(() => { describe('codex pane account registry', () => { it('survives a process restart so a daemon-backed shell stays attributable', () => { - recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' }) + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home', + shellStartupHomeOverride: { + home: '/pane-home', + shell: '/bin/zsh', + codexHome: '/pane-home/custom-codex-home' + } + }) _internals.resetCache() - expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home', + shellStartupHomeOverride: { + home: '/pane-home', + shell: '/bin/zsh', + codexHome: '/pane-home/custom-codex-home' + } + }) + }) + + it('keeps pre-route records readable without inventing provenance', () => { + writeFileSync( + join(userDataPath, 'codex-pane-accounts.json'), + JSON.stringify({ + version: 1, + panes: { 'pty-1': { selectionKey: 'host', accountId: null } } + }) + ) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: null }) + expect(hasRecordedLegacySharedCodexPane()).toBe(true) + }) + + it('runs legacy reconciliation only for host panes that may use the shared home', () => { + recordCodexPaneAccount('pty-real', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + recordCodexPaneAccount('pty-account', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-wsl', { + selectionKey: 'wsl:Ubuntu', + accountId: null, + homeRoute: 'wsl-home' + }) + + expect(hasRecordedLegacySharedCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-custom', { + selectionKey: 'host', + accountId: null, + homeRoute: 'custom-home' + }) + + expect(hasRecordedLegacySharedCodexPane()).toBe(true) + + forgetCodexPaneAccount('pty-custom') + expect(hasRecordedLegacySharedCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-shared', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect(hasRecordedLegacySharedCodexPane()).toBe(true) + }) + + it('drops leaked records that are absent from the authoritative daemon inventory', () => { + recordCodexPaneAccount('pty-live', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + recordCodexPaneAccount('pty-dead', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + reconcileCodexPaneAccountsWithLivePtys(['pty-live']) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-live')).not.toBeNull() + expect(getCodexPaneAccount('pty-dead')).toBeNull() + expect(hasRecordedLegacySharedCodexPane()).toBe(true) + + reconcileCodexPaneAccountsWithLivePtys([]) + expect(hasRecordedLegacySharedCodexPane()).toBe(false) }) it('forgets a PTY so a reused id cannot inherit a dead pane account', () => { @@ -125,7 +223,14 @@ describe('listStaleCodexPanes', () => { ptyIds: ['pty-1'], settings: settingsWithSelection('account-b') }) - ).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: 'account-a', + activeAccountId: 'account-b', + reason: 'account-change' + } + ]) }) it('reports a managed pane after the selection drops to the system default', () => { @@ -133,7 +238,14 @@ describe('listStaleCodexPanes', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) }) - ).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: null }]) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: 'account-a', + activeAccountId: null, + reason: 'account-change' + } + ]) }) it('leaves a pane alone when its launch account is still selected', () => { @@ -144,6 +256,184 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) + it('reports a system-default pane after its home route changes', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + }) + + it('keeps account-switch copy when the account and home route both change', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection('account-a'), + activeHostHomeRoute: 'account-home' + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: 'account-a', + reason: 'account-change' + } + ]) + }) + + it('does not guess a route for panes recorded before route provenance', () => { + recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([]) + }) + + it('does not compare a pane-local custom home with the selected host route', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'custom-home' + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([]) + }) + + it('does not report a custom-home spelling change that keeps the shared route', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/custom/codex-a' } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home' + }) + ).toEqual([]) + }) + + it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/custom/codex-home' } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home' + }) + ).toEqual([]) + }) + + it('reports when removing a custom home changes the resolved route', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/custom/codex-home' } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + }) + + it.skipIf(process.platform === 'win32')( + 'reports a retained pane after its shell startup CODEX_HOME is removed', + () => { + const paneHome = join(userDataPath, 'pane-home') + mkdirSync(paneHome, { recursive: true }) + const startupPath = join(paneHome, '.zshrc') + const customHome = join(paneHome, 'custom-codex-home') + writeFileSync(startupPath, 'export CODEX_HOME="$HOME/custom-codex-home"\n') + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + shellStartupHomeOverride: { + home: paneHome, + shell: '/bin/zsh', + codexHome: customHome + } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home' + }) + ).toEqual([]) + + writeFileSync(startupPath, '') + __resetShellStartupEnvCache() + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + } + ) + it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') }) @@ -163,7 +453,14 @@ describe('listStaleCodexPanes', () => { ptyIds: ['pty-1', 'pty-2'], settings: settingsWithSelection('account-b') }) - ).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + ).toEqual([ + { + ptyId: 'pty-2', + launchAccountId: 'account-a', + activeAccountId: 'account-b', + reason: 'account-change' + } + ]) }) it('compares a WSL pane against its own distro selection', () => { @@ -177,6 +474,13 @@ describe('listStaleCodexPanes', () => { // distro's switch must not restart another distro's panes. settings: settingsWithSelection('account-b', { Ubuntu: 'account-a', Debian: 'account-d' }) }) - ).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-c', activeAccountId: 'account-d' }]) + ).toEqual([ + { + ptyId: 'pty-2', + launchAccountId: 'account-c', + activeAccountId: 'account-d', + reason: 'account-change' + } + ]) }) }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index ba1aa6eef94..b74529abc29 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -1,11 +1,16 @@ import type { GlobalSettings } from '../../shared/types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' -import { forgetCodexPaneAccount, getCodexPaneAccount } from './codex-pane-account-registry' +import { + forgetCodexPaneAccount, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from './codex-pane-account-registry' export type StaleCodexPane = { ptyId: string launchAccountId: string | null activeAccountId: string | null + reason: 'account-change' | 'home-route-change' } /** @@ -15,6 +20,7 @@ export type StaleCodexPane = { export function listStaleCodexPanes(args: { ptyIds: readonly string[] settings: GlobalSettings + activeHostHomeRoute?: CodexPaneHomeRoute }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] for (const ptyId of args.ptyIds) { @@ -26,8 +32,20 @@ export function listStaleCodexPanes(args: { args.settings, parseSelectionLaneKey(record.selectionKey) ) - if (record.accountId !== activeAccountId) { - stalePanes.push({ ptyId, launchAccountId: record.accountId, activeAccountId }) + const homeRouteChanged = + record.selectionKey === 'host' && + record.homeRoute !== undefined && + record.homeRoute !== 'custom-home' && + args.activeHostHomeRoute !== undefined && + record.homeRoute !== args.activeHostHomeRoute + const accountChanged = record.accountId !== activeAccountId + if (accountChanged || homeRouteChanged) { + stalePanes.push({ + ptyId, + launchAccountId: record.accountId, + activeAccountId, + reason: accountChanged ? 'account-change' : 'home-route-change' + }) } } return stalePanes diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d7fae082341..6c793a316b8 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -830,6 +830,26 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +/** Returns null unless every daemon generation supplied an authoritative inventory. */ +export async function listLiveDaemonPtyIds(): Promise { + if (!adapter) { + return null + } + const adapters = + adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider + ? adapter.getAllAdapters() + : [adapter] + const inventories = await Promise.allSettled( + adapters.map((daemonAdapter) => daemonAdapter.listProcesses()) + ) + if (inventories.some((inventory) => inventory.status === 'rejected')) { + return null + } + return inventories.flatMap((inventory) => + inventory.status === 'fulfilled' ? inventory.value.map((process) => process.id) : [] + ) +} + // Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference. export function replaceDaemonProvider(newAdapter: DaemonProvider): void { adapter = newAdapter diff --git a/src/main/index.ts b/src/main/index.ts index a53801a60ae..a80bea0d965 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,8 +35,13 @@ import { initDaemonPtyProvider, disconnectDaemon, getDaemonProvider, + listLiveDaemonPtyIds, shutdownDaemon } from './daemon/daemon-init' +import { + hasRecordedLegacySharedCodexPane, + reconcileCodexPaneAccountsWithLivePtys +} from './codex/codex-pane-account-registry' import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' @@ -844,6 +849,14 @@ function startTerminalRuntimeStartupServices(): Promise { await initDaemonPtyProvider(signal, { macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode }) + if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) { + const livePtyIds = await listLiveDaemonPtyIds() + if (livePtyIds) { + reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + } + } + // Why: retained shells can invoke Codex immediately after the startup gate. + codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) }, // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 22cee5340d2..0c111a5f6d8 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -16,7 +16,8 @@ export function registerCodexAccountHandlers( } return listStaleCodexPanes({ ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'), - settings + settings, + activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute() }) }) ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index ccc39e1b3ff..0a30c25ae3b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -249,6 +249,7 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { acquireWatcherRemovalGate } from './watcher-removal-gate' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { acceptSshPtyOutputData, acceptSshPtyOutputExit, @@ -386,6 +387,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.removeListener.mockReset() // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. _resetHiddenRendererPtyDeliveryGateForTest() + __resetShellStartupEnvCache() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel, catching re-registration that forgot removeHandler. handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { @@ -15002,17 +15004,139 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-a' }) registerPtyHandlers(mainWindow as never, undefined, undefined, getSettings as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-reattached' }) + const nativeCodexEnv = { CODEX_HOME: '', ORCA_CODEX_HOME: '' } + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: nativeCodexEnv }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: nativeCodexEnv, + sessionId: 'pty-reattached' + }) // Why: a reattached shell keeps the CODEX_HOME baked in at its original // spawn, so re-recording it under the current selection would erase the only // evidence that the pane is stale. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-fresh', { selectionKey: 'host', accountId: 'account-a' }] + ['pty-fresh', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'real-home' }] ]) }) + posixOnlyIt( + 'does not guess route provenance for a pane-local shell startup CODEX_HOME', + async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-custom-home' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + readFileSyncMock.mockImplementation((path: string) => + path === '/pane-home/.zshrc' ? 'export CODEX_HOME="$HOME/custom-codex-home"\n' : '' + ) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + getSettings as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { + CODEX_HOME: '', + ORCA_CODEX_HOME: '', + HOME: '/pane-home', + SHELL: '/bin/zsh' + } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-custom-home', { + selectionKey: 'host', + accountId: null, + homeRoute: 'custom-home' + }) + } + ) + + it('records route provenance for a process-wide CODEX_HOME', async () => { + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = '/process/custom-codex-home' + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-process-home' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + getSettings as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { CODEX_HOME: '/process/custom-codex-home' } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-process-home', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/process/custom-codex-home' } + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + } + }) + + it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { CODEX_HOME: '/pane/custom-codex-home' } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-pane-env-home', { + selectionKey: 'host', + accountId: null, + homeRoute: 'custom-home' + }) + }) + it('does not resume under another account when the origin auth stays unavailable', async () => { vi.useFakeTimers() const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' })) @@ -15127,7 +15251,7 @@ describe('registerPtyHandlers', () => { // Why: the resume deliberately overrides the selection, so the pane really // is on account-a. Recording that is what makes the restart prompt appear. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }] + ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() @@ -15247,7 +15371,10 @@ describe('registerPtyHandlers', () => { }) expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }] + [ + 'pty-runtime-resumed', + { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' } + ] ]) expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index bd8f3a515d4..39dd2b63d86 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,6 +198,11 @@ import { import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' +import { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideContextsEqual +} from '../codex/codex-real-home-path' import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -995,16 +1000,47 @@ function recordCodexPaneAccountForSpawn(args: { isReattach: boolean pinnedByResume: boolean launchCodexHomePath: string | null + launchEnv?: NodeJS.ProcessEnv target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return } + const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) + const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null + const recheckableEnvironmentOverride = + customHomeOverride?.source === 'environment' && + processHomeOverride?.source === 'environment' && + environmentCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const recheckableShellStartupOverride = + customHomeOverride?.source === 'shell-startup' && + processHomeOverride?.source === 'shell-startup' && + shellStartupCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined const record = args.settings ? resolveCodexPaneLaunchAccount({ pinnedByResume: args.pinnedByResume, launchCodexHomePath: args.launchCodexHomePath, + // Why: pane-local overrides cannot be re-derived when a restart builds + // a fresh launch env, so route prompts would guess and block valid input. + recordComparableHomeRoute: + args.pinnedByResume || + ((customHomeOverride?.source !== 'environment' || + recheckableEnvironmentOverride !== undefined) && + (customHomeOverride?.source !== 'shell-startup' || + recheckableShellStartupOverride !== undefined)), + shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, + environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, target: args.target @@ -4361,6 +4397,7 @@ export function registerPtyHandlers( isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, + launchEnv: args.env, target: codexSelectionTarget, settings: getSettings?.() }) @@ -5445,6 +5482,7 @@ export function registerPtyHandlers( isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, + launchEnv: baseEnv, target: codexSelectionTarget, settings: getSettings?.() }) diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index fabdc23556a..ec51c690ff6 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -95,6 +95,28 @@ describe('startup ordering', () => { ) }) + it('reconciles retained Codex homes after authoritative daemon inventory', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') + const routeGateIndex = source.indexOf( + 'codexRuntimeHome?.isHostSystemDefaultRealHome()', + daemonInitIndex + ) + const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) + const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) + const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) + + expect(daemonInitIndex).toBeGreaterThanOrEqual(0) + expect(routeGateIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(routeGateIndex) + expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) + expect(serveIndex).toBeGreaterThan(reconciliationIndex) + expect(desktopIndex).toBeGreaterThan(serveIndex) + expect(source.split(reconciliation)).toHaveLength(2) + }) + it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cdb5000d6cc..35c58ab5a71 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2379,10 +2379,14 @@ export type PreloadApi = { wslDistro?: string | null }) => Promise /** Live PTYs whose baked CODEX_HOME still points at a deselected account. */ - listStalePanes: (args: { - ptyIds: string[] - }) => Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + listStalePanes: (args: { ptyIds: string[] }) => Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + /** Optional for compatibility with a pre-reason main process. */ + reason?: 'account-change' | 'home-route-change' + }[] > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> diff --git a/src/preload/index.ts b/src/preload/index.ts index 9da862f485b..3862d973ff5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2046,7 +2046,12 @@ const api = { listStalePanes: (args: { ptyIds: string[] }): Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 92f19f41144..4df9c54a9ed 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -73,6 +73,7 @@ function isInsideHiddenTree(element: HTMLElement): boolean { type RestartNotice = { previousAccountLabel: string nextAccountLabel: string + homeRouteChanged?: true } export default function CodexRestartChip({ @@ -195,27 +196,41 @@ function LoudRestartOverlay({
- {translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')} + {restartNotice.homeRouteChanged + ? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed') + : translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
- {translate( - 'auto.components.CodexRestartChip.a4c8e1b2f7', - 'Codex is still signed in as {{value0}}', - { value0: restartNotice.previousAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.3ea91b5c07', + 'This Codex session is using an outdated configuration' + ) + : translate( + 'auto.components.CodexRestartChip.a4c8e1b2f7', + 'Codex is still signed in as {{value0}}', + { value0: restartNotice.previousAccountLabel } + )}
- {translate( - 'auto.components.CodexRestartChip.9375620cc3', - 'Restart this session to use {{value0}}. It stays on the previous account until you do.', - { value0: restartNotice.nextAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.e6b7139d2a', + 'Restart this session to load your current Codex configuration.' + ) + : translate( + 'auto.components.CodexRestartChip.9375620cc3', + 'Restart this session to use {{value0}}. It stays on the previous account until you do.', + { value0: restartNotice.nextAccountLabel } + )}
-
- - - -
-
-
-

Orca orchestration proposal

-

Strong primitives. Little magic. No orchestration product inside the product.

-

- Orca should make it intuitive for a coordinating agent to start workers, communicate, - wait, observe output, and recover safely. Orca supplies dependable building blocks; - the agent decides the orchestration strategy. -

- -
- CLI and runtime only - agent-directed - multi-server capable - explicit effects - no commit tracking -
- -
- -
- The common agent loop -

- Create and bind one Run for the coordination effort; after that its ID is carried - automatically. Create tasks, start workers on this or another connected Orca - server, send messages, wait for inbox mail, and read or stop workers. Options - refine those operations without adding policy. -

-
-
- -

- The test for the design is simple: after reading a few examples, an agent should be - able to predict what every command creates, reuses, blocks on, and returns. -

-
- -
-
-

What we learned

-

The current problems are coordination problems, not missing product surfaces.

-

- The research found real reliability gaps, but the earlier proposal responded by - adding a scheduler, integration subsystem, dashboard, and large control-plane model. - Those additions would make the common agent workflow harder to understand. -

-
- -
-
-

Current

-

Lifecycle mail arrives through prompt injection

-

- Messages persist, but coordinator delivery may wait for the agent to pause. If it - keeps polling or working, messages can collect and flood the editable input after - a manual interruption. -

-
- -
-

Needed

-

A structured, blocking inbox call

-

- Typed worker lifecycle messages return from a pending tool request or the next explicit - read. Routine lifecycle delivery never writes into the coordinator's prompt. -

-
-
- -
-
-

Current

-

Starting a worker is assembled from low-level pieces

-

- Creating a worktree may already create a terminal, but an agent can miss that and - create another terminal before launching the worker. -

-
- -
-

Needed

-

One composed start operation with full topology and setup choices

-

- The operation composes existing worktree, setup, terminal, and agent creation and - returns exactly which resources it created or reused. -

-
-
- -
-
-

Current

-

Terminal scrollback is treated as agent history

-

- Full-screen TUIs can redraw or discard the useful conversation, making terminal - reads incomplete or misleading. -

-
- -
-

Implemented

-

Exact structured output with a truthful fallback

-

- Orca reuses its pane-scoped hook association to read a supported Codex, Claude, - OpenClaude, or Grok transcript. When it cannot prove that source, it returns - labeled bounded terminal output instead of guessing a session. -

-
-
- -
-
-

Current

-

Results are mostly worker assertions

-

- Orca verifies that the active dispatch reported worker_done, but the - summary, changed files, tests, and report path come from the worker. -

-
- -
-

Needed

-

Be explicit about what Orca observed

-

- Keep lifecycle authority separate from worker-reported content. Do not add commit, - test, merge, or integration tracking merely to make the report appear stronger. -

-
-
-
- -
-
-

Design rules

-

Reliability should live underneath a small interface.

-

- Strong primitives have narrow, testable contracts. They do not need to expose every - mechanism used to make the contract safe. -

-
- -
-
- 01 -

The coordinator owns strategy

-

- The agent chooses decomposition, ordering, parallelism, placement, review, and when - to wait. Orca does not schedule ready tasks automatically. -

-
-
- 02 -

Every effect is visible

-

- Responses say which connected server, worktree, setup, terminal, execution host, - and agent were created or reused. Defaults are reported, not hidden. -

-
-
- 03 -

Simple default, explicit escape hatch

-

- The common path needs few arguments and preserves worktree/setup choices. Uncommon - custom launches stay on the existing low-level commands instead of bloating start. -

-
-
- 04 -

Observation is honest

-

- Orca distinguishes observed process state from worker-reported claims and labels - the source of transcript or terminal output. -

-
-
- 05 -

Remote ambiguity stays ambiguous

-

- A disconnect after a remote mutation returns outcome_unknown. Orca - does not silently repeat a command that may have succeeded. -

-
-
- 06 -

Safety is not orchestration policy

-

- Stable identity, stale-worker fencing, and owner routing prevent corruption. They - do not choose what work should happen next. -

-
-
- -

- A feature belongs in the core only if it makes an existing primitive safer or clearer. - Tracking extra domain facts—commits, merges, budgets, priorities, or organizational - roles—is not automatically a stronger primitive. -

-
- -
-
-

Primitive 1 · Scope and identity

-

Four public concepts, each with one job.

-

- A Run prevents unrelated coordination efforts from mixing. Tasks describe work, - Dispatches authorize workers, and Messages communicate. Existing worktree and - terminal resources remain independently usable. -

-
- -
-
-

Run

-

A lightweight namespace and stable coordinator mailbox. It never schedules work.

-
-
-

Task

-

A durable description, status, and optional dependencies. Creating it starts nothing.

-
-
-

Dispatch

-

One supervised worker assignment and its current lifecycle authority.

-
-
-

Message

-

Durable communication or a typed lifecycle report returned through the inbox.

-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Persisted worker stateTask state and allowed next action
starting / readyTask is dispatched; only show, read, message, or stop may act on it.
start_unknownTask is blocked; recover the same request receipt, inspect, stop, or abandon.
failed - Confirmed start failure and authenticated worker failure both leave Task - failed. Either permits an explicit --retry-of replacement. -
succeededTask is completed; create a follow-up Task rather than retrying it.
stopping / stop_unknown - Task is blocked and lifecycle authority is fenced; inspect termination or - explicitly abandon before replacement. -
stopped / abandonedTask is blocked and permits an explicit --retry-of replacement.
-
- -
- - - - - - - - - - - - - - - - - - - - - -
Run operationExact effect
run-create - Creates a run and stable coordinator mailbox on the coordinator's selected - Orca server—its Run home—then binds the current coordinator terminal as its - active consumer. -
run-use --id run_123 - Explicitly binds or rebinds the current terminal to that run. Rebinding fences - the prior consumer generation and cancels its waiter. A terminal has at most - one active run binding. -
run-current / run-list / run-showReports bindings and state without changing tasks, workers, or mail.
-
- -
-
-

Run IDs stay out of the common path

-

- A command resolves the run only from explicit --run or the current - terminal binding—never from a worktree or an “exactly one candidate” guess. - Commands return the resolved Run or runId where it is needed for later - control; agents do not carry a separate resolution-mode field. -

-
-
-

Low-level commands remain run-free

-

- Ordinary worktree, terminal, and full-handoff commands do not create or require a - Run. Runs exist only for supervised coordination that needs durable grouping. -

-
-
-

One home, workers anywhere

-

- A Run has one home server that owns its tasks and inbox. A Dispatch may point to a - worker on any connected Orca server; Orca relays that worker's messages back to the - home automatically. -

-
-
- -

- ELI5: a Run is a folder label plus a return address. It keeps one coordination - effort's tasks and mail together and gives workers on your Mac or Windows server the - same stable place to reply. - Create or select it once, then ordinary orchestration commands inherit it. It does - not create resources, choose workers, schedule tasks, or group projects. -

- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Canonical commandCompatibility decision
run-create/list/show/use/currentNew lightweight scope commands. They never decompose or schedule tasks.
task-create/list/updateKeep the current flat command names and add Run association.
dispatchKeep as the low-level binding to an exact existing terminal.
worker-start/show/read/stop/abandonNew composed supervised-worker operations.
send/ask/reply/checkKeep and strengthen the current message operations.
Current scheduler-like orchestration run --spec - Must be removed or renamed in Phase 0 before run-* can ship. It is - not an alias for a lightweight Run and is not part of this scheduler-free - design. -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDWho normally carries it
runIdThe terminal binding carries it; agents pass it only to override context.
taskIdThe coordinator uses it for dependencies and worker start.
dispatchId - Receipts and worker preambles carry it; show, read, stop, abandon, retry, and - lifecycle reports use it. -
deliveryIdThe current mailbox consumer carries only its last unacknowledged delivery.
messageId / threadId - reply takes a message ID and infers its thread. Agents do not - manage a separate question identifier. -
Resource IDsReceipt data until an agent explicitly reads, stops, or reuses that resource.
RPC request ID - The client transport creates it automatically. Agents only echo the returned - retry token after an unknown outcome; they never invent one. -
-
- -
orca orchestration run-create --objective "Improve message delivery"
-# → run_123, bound to this coordinator terminal
-orca orchestration task-create --spec "Audit message delivery"
-# → task_a, runId run_123 (bound)
-orca orchestration worker-start --task task_a --worktree current --agent codex
-orca orchestration check --wait --timeout-ms 60000
-
- -
-
-

Primitive 2 · Supervised worker start

-

One request, predictable behavior for every topology.

-

- worker-start is one synchronous composition of existing worktree, setup, - terminal, and dispatch operations. It is not a background executor, external - transaction, or placement engine. The coordinator chooses the topology; Orca returns - only after the composition is ready, failed, or honestly unknown. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TopologyDefault terminal behaviorSetup behavior
Current worktree - Create one fresh agent terminal. Never reuse the coordinator terminal or an - arbitrary idle terminal. Reuse requires explicit --terminal. - not_applicable; do not rerun setup or configured tabs.
Named existing worktreeCreate one fresh agent terminal; reuse only an explicitly selected terminal.not_applicable; creation-time setup is not replayed.
New child worktree - Use agent-first worktree creation and reuse its returned agent terminal. Never - create a second shell/agent terminal. - - Default to run. Setup and agent launch start side by side unless the - repository explicitly uses wait-for-setup. skip or - inherit must be explicit. -
New top-level worktreeSame agent-first behavior, with top-level Orca lineage. - Same setup default: run a configured hook; use an explicit escape hatch only - for a concrete reason. -
-
- -
-
-

Worktree option parity means pass-through

-

- The exact repository selector, name, base branch, child/top-level lineage, setup, - and display/comment metadata are validated and passed to the existing worktree - primitive. --on already chooses the connected Orca server, so V1 does - not add a second project/host placement vocabulary to worker-start; - agents may use low-level worktree create when that convenience selector - is important. -

- -
-
-

Choose a connected server only when needed

-

- The default is the Run home. Use --on windows (a saved Orca environment - name or ID) only to place a worker on another connected server. V1 resource IDs - are server-scoped, so every remote existing worktree or terminal also requires - --on. Orca never guesses an owner from a same-looking ID and echoes the - resolved server name in the receipt. -

-
-
-

Agent selection is honest

-

- When creating a terminal, V1 requires an explicit --agent that resolves - through Orca's configured launcher before any effect. Composed start does not - promise custom model, environment, or arbitrary command arguments that agent-first - worktree creation cannot actually pass through. -

-
-
-

Setup is the safe default

-

- For every new worktree, omitted --setup resolves to - run. If a setup hook exists, Orca launches it; if none exists, the - receipt says not_configured. Preserve the repository's existing - setupAgentStartupPolicy: its default is start-immediately, - so setup does not delay agent launch or task delivery. Only an explicit - wait-for-setup policy gates the agent. An agent may choose - skip or inherit only for a specific reason it states in its - work log. Orca trusts that judgment and adds no approval gate. -

-
-
-

Supervised means lifecycle injection

-

- Task and dispatch input is delivered only after agent readiness. Ordinary - worktree/terminal commands remain the full-handoff path without lifecycle duties. -

-
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Placement/federation errorMeaning
server_requiredThe selected runtime cannot provide connected-server orchestration.
worktree_not_found_on_serverThe exact worktree does not exist on the selected worker server.
terminal_worktree_mismatchThe exact terminal is not owned by the selected worktree.
resource_server_mismatch - A worker-server receipt names a different Dispatch or authenticated Run home; - Orca never adopts that attachment. -
agent_unconfiguredThe requested launcher is unavailable; no worktree or terminal was created.
-
- -
- - - - - - - - - - - - - - - - - - - - - -
Task acceptanceWorker-start effect
First start - Requires a ready Task with no current Dispatch. The runtime creates the - Dispatch and moves the Task to dispatched in one local transaction before - running the composed effects. -
Replacement attempt - Requires --retry-of naming the Task's current failed, stopped, or - abandoned Dispatch while the Task is failed or blocked. Start creates the next - Dispatch generation and moves the Task back to dispatched atomically; the agent - never performs a preparatory task-update. -
Anything else - Return task_not_startable naming the rejected Task/Dispatch and - perform no worker effects. Completed work gets a new follow-up Task; an unknown - or nonterminal Dispatch must first be inspected, stopped, or abandoned. -
-
- -

- ready has one testable meaning: the selected agent terminal reached - tui-idle, the local or remote Dispatch attachment is durable, and the - lifecycle preamble plus task input was accepted. Under the default - start-immediately policy, setup may still be running and its exact state is - returned in the receipt; its outcome never gates readiness, even if failure is observed - before the agent reaches tui-idle. Under an explicit - wait-for-setup repository policy, setup must complete successfully before - agent launch and task injection. Agent-first worktree creation launches without the - task prompt so Orca can establish authority before injection. The effective timeout - and startup policy are echoed in every receipt. - A successful gated receipt reports setup succeeded. A confirmed setup - spawn/script failure reports failed before task input, while a timeout may - honestly retain running rather than inventing a failure. -

- - - - - -
-
orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run
-
-# Returns when the worker is ready, start failed, or the outcome is unknown.
-# Coordinators can issue independent start calls in parallel.
-
# "result" excerpt from --json
-{
-  "runId": "run_123",
-  "taskId": "task_a",
-  "dispatchId": "dispatch_7",
-  "state": "ready",
-  "stage": "input_accepted",
-  "setup": {
-    "requested": "run",
-    "effective": "run",
-    "source": "explicit_request",
-    "hookFound": true,
-    "startupPolicy": "start-immediately",
-    "state": "running"
-  },
-  "timeoutMs": 60000,
-  "effects": [
-    { "kind": "worktree", "action": "created_child", "id": "worktree_9" },
-    { "kind": "terminal", "role": "setup", "action": "created", "id": "term_setup_11", "tabId": "tab_2", "leafId": "leaf_1" },
-    { "kind": "setup", "action": "run", "requested": "run", "effective": "run", "source": "explicit_request", "hookFound": true, "startupPolicy": "start-immediately", "state": "running", "terminalId": "term_setup_11" },
-    { "kind": "terminal", "role": "agent", "action": "reused_agent_terminal", "id": "term_12" },
-    { "kind": "terminal", "role": "configured_tab", "action": "created", "id": "term_13", "tabId": "tab_3", "leafId": "leaf_1" },
-    { "kind": "dispatch_input", "role": "agent", "id": "term_12", "state": "accepted" }
-  ],
-  "residualResources": [],
-  "mutation": { "requestId": "req_7", "replayed": false }
-}
-
- - - -
- - - - - - - - - - - - - - - - - - - - - -
Start stateMeaning and next action
ready - Agent is ready and lifecycle input was accepted. The return receipt contains - every created or reused effect; no separate startup notice is required. -
failed - Return the failed stage, last error, residual resources, Task/Dispatch state, - and the durable mutation receipt. The composition itself has returned, but any - surviving setup, terminal, or agent process is listed honestly as a residual - resource; Orca never implies that failure cleaned it up. The coordinator - chooses the matching recovery branch below. -
outcome_unknown - The connection failed after an effect may have happened. Return the operation - stage, mutation.requestId, durable effects/residuals, and exact - worker-show/worker-abandon commands. A replacement is - rejected until inspection proves it safe or the coordinator explicitly - abandons the old Dispatch. -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
Unknown-start recoveryExact contract
Query and reconcile - worker-show --dispatch dispatch_7 routes from the Run home to the - owning worker server and execution host. If its durable receipt proves the - original worker became ready, failed, or stopped, the home reconciles that same - Dispatch; no separate adopt operation exists. -
Safe retry - --retry-of dispatch_7 links a new attempt but does not silently - reuse prior placement. The coordinator repeats an explicit valid topology and - agent/terminal choice, which may deliberately differ from the old attempt. A - new Dispatch is allowed only after the prior one is failed, stopped, abandoned, - or reconciled as no-effect. While it remains unknown, return - task_not_startable without mutation. -
Explicit stop - worker-stop --dispatch dispatch_7 may fence a - ready or start_unknown Dispatch. The owning server - closes a terminal only when its durable attachment still matches the exact - pane and process incarnation. Unattached, missing, exited, or identity-changed - workers become stop_unknown with no process action. If the remote - server durably stopped the exact worker but its response was lost, a later - worker-show reconciles that authoritative stopped receipt. -
Explicit abandon - worker-abandon --dispatch dispatch_7 fences future lifecycle - mutations from that Dispatch and records every possibly-live resource. It sends - no remote command, claims no process stopped, deletes nothing, and warns that a - concurrent worker may remain. The coordinator may then start a replacement. - Abandoning an older superseded Dispatch is a no-op and cannot block or rewrite - the replacement Task. -
-
- -

- The Dispatch ID is the worker identity. Do not add another agent-facing start ID. - Every mutating CLI result includes one opaque mutation.requestId for - recovering that exact request after a lost response; agents never invent it. A - transport failure exposes the same value as orchestrationRequestId in its - error recovery data. Semantic retry - explicitly names the prior Dispatch ID and never replays an unknown effect. After a - remote start, show, read, stop, abandon, and message routing use the Dispatch receipt; - the agent does not repeat --on for those controls or carry server IDs. A - replacement worker-start is a new placement decision and names its target - again. -

- - -
- -
-
-

Primitives 3 and 4 · Message and wait

-

Structured mail is inbox-only, durable, and explicitly consumed.

-

- The inbox is an agent API, not product UI and not terminal input. It strengthens - Orca's existing message store and check --wait; it does not add an Event - subsystem or a second orchestration engine. -

-
- -

- send, ask, reply, completion, heartbeat, and runtime - notices only persist structured state and wake a pending tool waiter. Only explicit - dispatch --inject and terminal send may modify terminal input. - From an active Dispatch, lifecycle send and ask default to that - Dispatch's owning Run mailbox. “Sent” means durably accepted—not pasted, observed, or - acted upon. -

- -
-
-

run:run_123

-

The stable coordinator mailbox stored with that Run in the Orca runtime.

-
-
-

dispatch:dispatch_7

-

The exact supervised worker generation, independent of terminal handle changes.

-
-
- - - -

- Orca authenticates lifecycle reports automatically. The worker supplies only the Task - and Dispatch IDs injected in its preamble; it never supplies Run, runtime, host, - terminal, capability, or attestation IDs. At injection, Orca gives the managed pane a - narrow unforgeable Dispatch capability through its CLI bridge. The worker server - verifies that capability and its local pane; the Run home accepts the relayed report - only from the pinned paired peer for that Dispatch. Stop, abandon, or replacement - revokes it. Other reports remain stale history and cannot change Task state. This is - lifecycle integrity, not a general permission system. -

- - - -
-
-

One Run home

-

- The coordinator's server stores the authoritative Run, tasks, inbox ordering, and - acknowledgments. Worker servers do not replicate the Run database or elect a new - home. -

-
-
-

Remote mail waits safely

-

- A worker server durably retains messages for its remote Dispatch until the Run - home imports and acknowledges them. Replies wait at the home until the worker - server reconnects. This is a narrow relay queue, not a replicated global inbox. -

-
-
- -
-
-
Coordinator
-
Run home ↔ worker server
-
Worker
-
-
-
Starts every independent worker
-
Persists home record and remote attachment
-
Works concurrently
-
-
-
Calls check --wait only when no local work remains
-
Returns the outstanding batch or registers one waiter
-
Continues independently
-
-
-
Pending tool call is blocked
-
Durably relays question, failure, or completion
-
Reports one typed lifecycle message
-
-
-
Receives a structured batch
-
Persists and returns one opaque Delivery ID
-
No coordinator prompt injection
-
-
-
Processes every message, then acknowledges and waits
-
Atomically ack → check → register
-
May continue, stop, or receive a reply
-
-
- -

- check --wait always targets the Run home and fans in mail from every active - remote Dispatch. One disconnected worker server does not block local or other-server - messages. Imported relay items are idempotent by their authenticated Dispatch and - source sequence, then follow the same FIFO delivery and acknowledgment rules as local - mail. -

- -
-
orca orchestration check --wait --timeout-ms 60000
-# → returns delivery_81 with an ordered message batch
-
-# Process every message and start newly-ready work.
-orca orchestration check --ack delivery_81 --wait --timeout-ms 60000
-
# "result" excerpt from --json
-{
-  "runId": "run_123",
-  "deliveryId": "delivery_81",
-  "messages": [
-    {
-      "id": "msg_481",
-      "run_id": "run_123",
-      "from_handle": "dispatch:dispatch_7",
-      "to_handle": "run:run_123",
-      "subject": "Review complete",
-      "type": "worker_done",
-      "payload": "{\"taskId\":\"task_a\",\"dispatchId\":\"dispatch_7\",\"outcome\":\"succeeded\"}",
-      "read": 0
-    }
-  ],
-  "count": 1,
-  "replayed": false,
-  "acknowledged": null,
-  "timedOut": false,
-  "cancelled": false,
-  "connectionLost": false
-}
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Inbox ruleV1 contract
Ordering and sizeFIFO by mailbox sequence, bounded to 50 messages per delivery.
Outstanding batch - One per mailbox. Until acknowledged, return the same Delivery ID and batch; - newer mail waits behind it. -
Acknowledgment - Whole-batch and idempotent. Repeating an acknowledged Delivery ID returns the - recorded result and does not consume newer mail. A Delivery is bound to the - consumer generation that received it; a fenced coordinator gets - consumer_fenced and cannot consume the replacement's mail. The same - current-consumer check applies to coordinator replies and other mailbox - mutations. -
Waiters - One active actionable waiter per mailbox. A second returns - waiter_exists; it never races to consume the batch. -
Crash safety - Receiving a batch never marks it consumed. Unacknowledged mail survives client, - mailbox-consumer, and runtime restart. -
Atomic continuation - check --ack delivery_81 --wait commits ack, checks queued mail, - then registers the waiter as one runtime operation. -
-
- -
-
-

History modes do not consume

-

- check --peek, --all, and type-filtered history reads are - read-only debugging surfaces. Legacy consume-on-check behavior is deprecated. - Rename local formatting flag check --inject so it cannot imply delivery. -

-
-
-

Timeouts are typed checkpoints

-

- Wait returns timedOut, cancelled, or - connectionLost distinctly. None means worker failure, none consumes - mail, and transport keepalive output is not a worker heartbeat. -

-
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Lifecycle inputAtomic state effect at message acceptance
Authenticated active Dispatch reports worker_done outcome=succeededSet Dispatch settled/succeeded and Task completed.
Authenticated active Dispatch reports worker_done outcome=failedSet Dispatch settled/failed and Task failed. Coordinator chooses recovery.
Stale or foreign Dispatch reportPersist as stale history; do not change current Task or Dispatch state.
Malformed lifecycle reportReject the transition and return the missing or invalid field.
worker-start returns failedSet Dispatch and Task failed with an explicit recovery reason.
Confirmed worker-stopSet Dispatch stopped and Task blocked; never claim task completion.
worker-stop accepted, termination unknown - Fence lifecycle authority, set Dispatch stop_unknown and Task - blocked, and retain the process/terminal in residualResources as - possibly live. A new start remains unsafe until inspection confirms termination - or the coordinator explicitly abandons with the concurrent-worker warning. -
Confirmed worker-abandon - Set Dispatch abandoned and Task blocked, fence its later reports, and retain - possibly-live resource IDs; never claim process termination. -
-
- -

- Inbox acknowledgment confirms recipient consumption only. Lifecycle reconciliation - happens once, atomically, when the Run home imports an authenticated message. Every - terminal Dispatch transition is a home-side transactional compare-and-set: the first - committed completion, stop fence, or abandon wins; later conflicting input is retained - only as stale history. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Question stateAsk/reply behavior
pending - ask from an active Dispatch defaults to its owning Run mailbox. The - first reply from the current authenticated Run consumer generation records the - answer at the Run home, relays it to the exact worker server, and wakes the - local asking call. -
answered - The pending ask or an explicit resume by the original Dispatch - returns the recorded answer. Repeating the same reply is harmless; a later - different answer conflicts. -
Wait timed out or client disconnected - Return the original question message ID and an exact - ask --resume msg_question_7 command without closing or duplicating - the question. Resume is allowed only for the original Dispatch. There is no - separate Question ID or durable expiry/deadline state machine. -
Acceptance response was lost - Retry the identical ask with its returned transport retry receipt - to recover the original message ID. A changed question conflicts; a blind new - ask is never the recovery path. -
Dispatch stopped or abandoned - The Run home atomically closes its pending questions, wakes local or resumed - waits with dispatch_inactive, and rejects later replies. -
-
- - - -
-
# Worker: target defaults to the owning Run mailbox.
-orca orchestration ask --question "Should I preserve the legacy format?" --json
-# → question message msg_question_7
-# After a disconnect/timeout: orca orchestration ask --resume msg_question_7 --json
-
# Coordinator: reply to the returned message ID.
-orca orchestration reply --id msg_question_7 --body "Yes; preserve it." --json
-
- -

- Blocking is a coordinator decision. The runtime must make waiting race-free, but it - must not decide that the agent has exhausted all parallelizable work. -

-
- -
-
-

Worker observation

-

Read a supervised worker by Dispatch, regardless of server.

-

- worker-read --dispatch resolves the worker server and exact process from - the Dispatch receipt. Its default auto source returns the exact - hook-reported Codex, Claude, OpenClaude, or Grok transcript when Orca can prove that - association; - otherwise it returns bounded, explicitly labeled terminal output. Agents never - choose a server, provider session ID, or transcript path, and task authority never - comes from transcript prose. -

-
- -
-
-

Default path

-

Automatic exact selection

-

- --source auto uses a proven supported transcript and falls back to - the existing bounded terminal reader with a typed reason such as - session_not_reported or - remote_capability_unavailable. -

-
-
-

Explicit policy

-

Transcript or terminal

-

- --source transcript requires exact structured output and returns a - typed error rather than falling back. --source terminal always uses - the retained terminal snapshot. -

-
-
- -
orca orchestration worker-read --dispatch dispatch_7 --source auto --limit 100 --json
-
-# Exact transcript result for a worker on the saved environment named windows
-{
-  "dispatchId": "dispatch_7",
-  "source": "transcript",
-  "sourceIdentity": "opaque-source-fingerprint",
-  "provider": "codex",
-  "server": { "environmentId": "env_windows", "name": "windows" },
-  "remoteRuntimeEpoch": "runtime_epoch_2",
-  "transcript": {
-    "messages": [ ... ],
-    "nextCursor": "opaque-next-cursor",
-    "limited": false,
-    "returnedMessageCount": 12
-  },
-  "cursor": "opaque-next-cursor",
-  "status": { "worker": "ready", "terminal": "running" },
-  "fallbackReason": null,
-  "warnings": []
-}
-
-# Continue from the returned top-level opaque cursor.
-orca orchestration worker-read --dispatch dispatch_7 \
-  --cursor opaque-next-cursor --limit 100 --json
- -
-
-

Source-pinned continuation

-

- The returned cursor pins the Dispatch, process, source kind, and opaque source - identity. auto selects only on the first page. If the process or - provider session changes, Orca returns - worker_identity_changed or source_changed. -

-
-
-

Narrow provider readers

-

- Structured reading reuses the existing bounded native transcript decoders only - for exact Codex, Claude, OpenClaude, and Grok associations. Other providers and - mixed-version peers retain terminal fallback; no resume, live-stream control, or - universal transcript framework is added. -

-
-
- -

- Every response labels source, sourceIdentity, - cursor, status, fallback reason, and bounded warnings. Terminal fallback - preserves the existing terminal fields and accepts legacy numeric cursors; new cursors - are opaque and never expose a transcript path. The worker-owning server performs the - read, and neither local nor federated selection may guess “latest session in this - directory.” -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
Orca can sayOrca cannot infer
The active dispatch sent a completion report.The implementation is correct.
The managed process exited with a particular code.The reported tests actually passed unless Orca ran them itself.
A later exact-session adapter resolved these structured entries.The transcript describes the complete repository state.
The worker reported files, summary, and report path.Those files are exhaustive or the work has been integrated.
-
-
- -
-
-

Implementation appendix · agents may skip this section

-

Hidden guarantees for one Run home and many worker servers.

-

- A coordinator on one Orca server must be able to supervise workers on another—for - example, a Run on a Mac with Dispatches on both that Mac and a connected Windows - server. The minimum federation contract routes commands and durably relays messages; - it does not replicate the Run or add scheduling policy. -

-
- -

- Agent rule: choose a remote worker once with --on. After that, keep the - Dispatch ID and follow the returned inspection commands. Reuse - mutation.requestId only to recover the same request after a lost response. - Peer identity, sequencing, capabilities, and relay acknowledgments below are Orca - implementation details—not fields agents choose or copy. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Internal factV1 contractWhat agents see
Run home - The server where run-create executes owns the only authoritative - Run database: tasks, Dispatch state, inbox order, dedupe receipts, and consumer - generation. - The terminal binding routes ordinary commands home; no Run-home ID is typed.
Connected environment - The Run home stores its existing saved environment ID/name and authenticated - pairing for each worker server. Each Dispatch pins the authenticated peer - fingerprint captured at attachment, so re-pairing the saved environment to a - different server cannot retarget existing work. That relationship survives the - same remote server process restarting; the observed runtimeId - remains only an epoch. - --on windows when placement is explicit; receipts echo the name.
Remote Dispatch attachment - Before prompt injection, the worker server persists a verifier for the opaque - Dispatch capability, pinned Run-home peer identity, stable local pane and - process incarnation, effect receipts, and relay cursors. Credential material - is stored through current-user protected storage, not plaintext in a general - Run row. The worker server does not receive a copy of the Run DAG. - Nothing extra; the worker receives only Task and Dispatch IDs.
Caller pane - Each server's native, WSL, or SSH CLI bridge attaches the minted Dispatch - capability outside user parameters. The worker server verifies capability, - pane, and process incarnation; the Run home verifies the pinned authenticated - peer and Dispatch on relay import. - Orca authenticates lifecycle reports automatically.
-
- -
-
-

Home-bound coordination

-

- run-use/current, Task changes, check/ack, and replies go to the Run - home. Workers never choose or mutate the home, and there is no automatic home - failover. -

-
-
-

Server-owned resources

-

- The selected worker server owns its worktree, terminal, process, and any nested - native/WSL/SSH/relay host. The Run home stores opaque receipts and routes show, - read, stop, and retry back to that owner. -

-
-
-

Durable relay, not replication

-

- Worker-to-home lifecycle mail and home-to-worker replies remain queued at their - source until the destination imports and acknowledges them. Each item has a stable - ID, so reconnects are at-least-once on the wire and once in each inbox. -

-
-
-

Home-initiated connection

-

- The Run home uses the same saved, authenticated environment connection already - used for remote RPC. It subscribes or pulls by cursor; the Windows server does not - need a separate pairing back to the Mac or a publicly reachable callback. -

-
-
-

Dispatch generations

-

- Replacing a worker creates a new Dispatch ID; that ID is the generation. A report - from an older Dispatch or different pane remains history and cannot change current - Task state. There is no second agent-visible generation number. -

-
-
-

Idempotent control operations

-

- Before effects, both home and worker server durably record authenticated peer, - request ID, canonical payload hash, operation state, and receipt. Identical - concurrent or later attempts join or return that record; a changed payload returns - request_mismatch. Receipts expose the opaque retry ID needed after an - unknown outcome. -

-
-
-

Typed unknown outcome

-

- If a remote effect may have happened but Orca cannot prove it, return one - outcome_unknown shape with stage, mutation request ID, durable - effects/residuals, and exact inspection commands. -

-
-
-

Stop and replace

-

- Stop first commits one home-side compare-and-set that fences new lifecycle changes, - blocks the Task, and closes pending questions, then best-effort stops only the - supervised agent process/terminal. If completion already won, stop returns - already_settled; if stop won, later completion is stale history. Orca - never deletes the worktree, setup output, or unrelated configured tabs. -

-
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Federated relay ruleExact contract
Worker send acceptance - Success means the worker server durably stored the authenticated message in - that Dispatch's outbound relay. The worker may finish even while the Run home - is offline. -
Home import and lifecycle - The Run home stores the message and applies any valid lifecycle transition in - one transaction before acknowledging it to the worker server. Until import, - the authoritative Task honestly remains dispatched. -
Replies and control mail - The Run home durably queues them for the exact remote Dispatch; the worker - server stores them before acknowledging the home and waking a local waiter. -
Ordering and duplicates - Each direction uses a scoped key of pinned peer, Dispatch ID, direction, and a - monotonic source sequence, plus a 128-bit-or-stronger message ID. A receiver - imports only the next contiguous sequence, buffers gaps, and acknowledges only - the highest contiguous commit. The home assigns normal inbox order at import, - without pretending simultaneous servers have a global clock. -
Bounded storage - Enforce per-message byte limits and per-Dispatch pending item/byte quotas, - coalesce heartbeats, and reserve space for one terminal lifecycle report. A - full relay returns relay_quota_exceeded; V1 adds no dead-letter or - retention workflow. -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Remote edgeV1 behavior
Worker server unavailable before send - Return remote_runtime_unavailable before creating a Dispatch or any - remote effect. No remote Dispatch attachment exists, so the caller may retry - normally after reconnecting. -
Connection lost after send - Return outcome_unknown, mutation.requestId, last durable - stage, and exact inspection commands. Repeating the request ID returns the - worker server's original receipt or accepts it once; it never duplicates an - effect. -
Run-home restart - Run state, dedupe receipts, and unacknowledged mail survive. The process runtime - ID is only an epoch. Reconnect resumes relay cursors for every active remote - Dispatch; if the coordinator pane cannot be safely reminted, - run-use explicitly rebinds it. -
Saved environment re-paired or removed - If its authenticated peer fingerprint differs from the Dispatch attachment, - return peer_changed with no effect; never adopt the replacement - server. Removing an environment with a nonterminal Dispatch retains a routing - tombstone for inspection and abandon rather than erasing ownership evidence. -
Servers disconnected after start - The worker may continue. Its server retains lifecycle mail and questions while - replies remain queued at the home. Silence is not failure, no worker is - automatically replaced, and other servers continue delivering normally. -
Worker-server restart - Remote Dispatch attachments, effect receipts, and unacknowledged relay items - survive. The process runtime ID may change; the Run home routes by its saved - environment relationship and pinned peer. worker-show reports - running only when the stable pane and process incarnation match; it never - adopts a same-looking pane or newly launched process. -
Mixed server versions - Before effects, both servers must advertise one aggregate - orchestrationFederationV1 contract. Missing support returns - capability_unsupported; - it never silently degrades to prompt injection, terminal scraping, or - consume-on-read mail. The worker-side mutation revalidates the pinned peer and - advertised protocol recorded for the operation, closing the probe-to-effect - race. -
Structured worker output - The worker-owning server reads an exact hook-reported Codex, Claude, - OpenClaude, or Grok transcript when supported. If the additive federated read - method is absent, auto returns bounded terminal output labeled with - remote_capability_unavailable; - transcript returns transcript_required. -
-
- -

- Capability checks cover only the new federation contracts, not every platform or host - feature. Existing worktree, setup, terminal, Git, WSL, SSH, and relay primitives keep - their proven compatibility behavior; optional, truthfully labeled observation may - degrade. -

- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
MechanismWhy it staysWhy it is not a scheduler
Server ownershipPrevents commands from acting on a same-looking resource on the wrong host.The agent chooses --on; the owner only determines routing.
Dispatch identityPrevents stale workers from overwriting current task state.It does not retry, replace, or start anything automatically.
Unknown outcomePrevents duplicate remote effects after a disconnect. - The receipt gives last durable stage and inspection command; the coordinator - chooses inspect, reconcile, retry, or abandon. -
-
- -

- Safety may reject stale, wrong-pane, or wrong-server control-plane mutations. It does - not police worker filesystem access, invent new work, choose a worker, or decide that - waiting is the coordinator's best next action. -

- - -
- -
-
-

Agent ergonomics

-

The skill should be a cookbook, not a second help page.

-

- orca --help already owns syntax and exhaustive flags. The skill should - teach judgment: which topology to choose, how to preserve parallelism, what the - command returns, and what not to create afterward. -

-
- -
-
-

Parallel fan-out

-

Start every independent task first. Only then call check --wait.

-
-
-

Shared-worktree review

-

Use the current worktree when sharing its exact state is useful.

-
-
-

Independent writers

-

Create separate worktrees when a concrete checkout conflict calls for isolation.

-
-
-

Ask and reply

-

Use a threaded question; continue other work while only that worker is blocked.

-
-
-

Read a full-screen agent

-

Use worker-read --dispatch; Orca routes to the owning server.

-
-
-

Replace safely

-

Inspect the stop result before deciding where to start a replacement.

-
-
- -
-
-

Move out of the skill

-
    -
  • Complete command and flag catalogs
  • -
  • Internal database and delivery terminology
  • -
  • Provider-specific internals before the common path
  • -
  • Large decision trees for features Orca does not implement
  • -
-
-
-

Every recipe must say

-
    -
  • When the pattern is appropriate
  • -
  • What the command creates or reuses
  • -
  • Whether setup runs; if not, the concrete reason
  • -
  • How and when results return
  • -
  • The common misuse to avoid
  • -
-
-
- -
- Current Orca · parallel workers in the current worktree -
-

- Creates two fresh agent terminals; setup does not run. Create both tasks and both - terminals before waiting for readiness, then dispatch both before blocking. The - coordinator keeps checking until both expected Dispatches settle; one batch is not - assumed to contain both completions. Misuse: waiting for worker A before starting B. -

-
orca orchestration task-create --spec "Audit message semantics" --json
-orca orchestration task-create --spec "Audit transcript adapters" --json
-orca terminal create --worktree active --title message-audit --command codex --json
-orca terminal create --worktree active --title transcript-audit --command codex --json
-orca terminal wait --terminal term_a --for tui-idle --timeout-ms 60000 --json
-orca terminal wait --terminal term_b --for tui-idle --timeout-ms 60000 --json
-orca orchestration dispatch --task task_a --to term_a --inject --json
-orca orchestration dispatch --task task_b --to term_b --inject --json
-
-# Repeat until task_a and task_b are both settled; process every returned message.
-orca orchestration check --wait --timeout-ms 60000 --json
-
-
- -
- Proposed Orca · same fan-out with composed worker start -
-

- Creates one bound Run and two fresh terminals in the existing worktree. Setup is - not_applicable. Each start returns ready, failed, or - outcome_unknown; independent calls may be issued in parallel. Worker - results arrive through the inbox. Misuse: creating another terminal after start. -

-
orca orchestration run-create --objective "Improve orchestration ergonomics" --json
-orca orchestration task-create --spec "Audit message semantics" --json
-orca orchestration task-create --spec "Audit transcript adapters" --json
- -
-
# Concurrent tool call A
-orca orchestration worker-start --task task_a --worktree current --agent codex --json
-
# Concurrent tool call B
-orca orchestration worker-start --task task_b --worktree current --agent codex --json
-
- -
# After both start calls return, loop; do not assume two waits or one batch is enough.
-# Repeat process → ack → wait until both Dispatches are settled.
-orca orchestration check --wait --timeout-ms 60000 --json
-orca orchestration check --ack delivery_81 --wait --timeout-ms 60000 --json
-
-
- -
- Mac coordinator + Windows worker · one Run across connected servers -
-

- The Run and inbox stay on the Mac. The first worker uses the Mac's current - worktree; the second creates a top-level worktree on the saved - windows Orca environment. Both report into the same inbox, including - after a temporary disconnect. Misuse: creating a second Run on Windows or asking - the worker to carry server/Run-home IDs. -

-
# On the Mac coordinator
-orca orchestration run-create --objective "Audit both platforms" --json
-orca orchestration task-create --spec "Audit macOS behavior" --json
-orca orchestration task-create --spec "Audit Windows behavior" --json
-
-# Read-only discovery happens against Windows; copy the opaque repo/worktree IDs returned.
-orca --environment windows worktree list --json
-
-# Issue these as independent concurrent tool calls, not as one sequential shell script.
-orca orchestration worker-start --task task_mac --worktree current --agent codex --json
-orca orchestration worker-start --task task_windows --on windows --worktree new-top-level --repo id:<windows-repo-id> --name windows-audit --agent codex --setup run --json
-
-# One home wait fans in local and Windows messages. Repeat until both Dispatches settle.
-orca orchestration check --wait --timeout-ms 60000 --json
-orca orchestration check --ack delivery_81 --wait --timeout-ms 60000 --json
-
-# Attempt-specific guidance uses the stable Dispatch, never the remote terminal handle.
-orca orchestration send --to dispatch:dispatch_windows --subject "Follow-up" \
-  --body "Run the additional Windows-only check." --json
-
-
- -
- Worker completion · success and failure are explicit -
-

- The injected preamble supplies the only Task and Dispatch IDs a worker copies. - Orca supplies its Dispatch capability automatically; the worker never supplies Run, - runtime, host, terminal, capability, or attestation IDs. Misuse: reporting failure as a - successful completion or inventing IDs from terminal history. -

-
# Success
-orca orchestration send --type worker_done --subject "Review complete" \
-  --body "Audited the requested behavior. Found two issues and changed no files. Nothing remains." \
-  --task-id "<taskId from current preamble>" \
-  --dispatch-id "<dispatchId from current preamble>" \
-  --outcome succeeded --json
-
-# Failure
-orca orchestration send --type worker_done --subject "Review failed" \
-  --body "Could not read the required fixture. No findings are reliable. The fixture must be restored before retrying." \
-  --task-id "<taskId from current preamble>" \
-  --dispatch-id "<dispatchId from current preamble>" \
-  --outcome failed --json
-
-
- -
- Worker question · ask defaults to its Run -
-

- An active worker asks its owning coordinator without carrying a Run ID. The - coordinator replies to the returned message ID while other work continues. Misuse: - creating a task gate or a second Question ID for a simple reply. -

-
# Worker
-orca orchestration ask --question "Should I preserve the legacy format?" --json
-
-# If acceptance may have happened but the response was lost, repeat the identical command with
-# the orchestrationRequestId reported by the CLI as --retry-request.
-orca orchestration ask --question "Should I preserve the legacy format?" --retry-request req_ask7 --json
-
-# If a server disconnect timed out the wait, resume with the returned message ID.
-orca orchestration ask --resume msg_question_7 --json
-
-# Coordinator, after receiving msg_question_7
-orca orchestration reply --id msg_question_7 --body "Yes; preserve it." --json
-
-
- -
- New child or top-level worktree · reuse the returned agent terminal -
-

- New worktrees run configured setup by default. Agent-first creation returns the - only worker terminal; configured extra tabs remain intentional. Use - skip or inherit only for a concrete stated reason. Misuse: - adding a second agent terminal because the startup receipt was not inspected. -

-
orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run --json
-orca orchestration worker-show --dispatch dispatch_7 --json
-
-orca orchestration worker-start --task task_b --worktree new-top-level --name transcript-audit --agent claude --setup run --json
-
-# Escape hatch: this task audits the pristine fixture, and setup would mutate that fixture.
-orca orchestration worker-start --task task_fixture --worktree new-top-level --name fixture-only --agent codex --setup skip --json
-
-
- -
- Intentional existing-terminal reuse -
-

- Reuses exactly the selected terminal and runs no setup. The terminal must belong to - the chosen worktree. Misuse: treating “an idle terminal somewhere” as equivalent. -

-
# Local existing worktree; copy the exact opaque IDs from worktree/terminal list.
-orca orchestration worker-start --task task_a --worktree 'id:<full-worktree-id>' --terminal term_12 --json
-
-# Remote existing worktree; --on is mandatory because V1 IDs are server-scoped.
-orca --environment windows worktree list --json
-orca orchestration worker-start --task task_b --on windows --worktree 'id:<full-windows-worktree-id>' --agent codex --json
-
-
- -
- Remote uncertainty · branch on what inspection proves -
-

- An unknown start or stop is inspected by Dispatch ID; nothing is replayed merely - because a connection failed. Misuse: treating show → stop → abandon → retry as an - unconditional recovery sequence. -

-
# First recover the receipt for the exact request; this cannot create a second effect.
-orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run --retry-request req_7 --json
-
-# Then inspect the Dispatch if the outcome is still unknown.
-orca orchestration worker-show --dispatch dispatch_7 --json
-
-# If ready: keep the worker and wait for its result.
-# If failed or stopped: start an explicit replacement, repeating the intended placement.
-orca orchestration worker-start --task task_a --retry-of dispatch_7 --worktree current --agent codex --json
-
-# If still unknown: stop and inspect again, or explicitly accept the warning and abandon.
-orca orchestration worker-stop --dispatch dispatch_7 --json
-orca orchestration worker-show --dispatch dispatch_7 --json
-orca orchestration worker-abandon --dispatch dispatch_7 --json
-
-
- -
- Restart recovery · home and worker server are independent -
-

- A Run-home restart preserves the Run, retry receipts, and the same unacknowledged - Delivery; run-use is needed only when Orca cannot safely remint the - previous coordinator pane. A worker-server restart preserves its Dispatch - attachment and relay queue but does not pretend the agent process survived. - Misuse: starting a replacement merely because a runtime ID changed. -

-
# Home restart: rebind only if run-current says this pane is unbound.
-orca orchestration run-current --json
-orca orchestration run-use --id run_123 --json
-orca orchestration check --wait --timeout-ms 60000 --json
-# → returns the same outstanding delivery_81 until it is acknowledged
-
-# Worker-server restart: inspect the persisted attachment and exact process incarnation.
-orca orchestration worker-show --dispatch dispatch_7 --json
-# running/ready → keep waiting; gone/failed → explicit retry-of; unknown → inspect or stop/abandon.
-
-
- -

- The skill must say that check --wait returns a batch. Process every message - before acknowledging it. -

- -
-

Scenario tests matter more than keyword checks

-
-
    -
  • Reuse the terminal returned by worktree creation.
  • -
  • Start three independent workers before waiting.
  • -
  • Omit runId only when the coordinator terminal is explicitly bound.
  • -
  • Use current-worktree collaborators without unnecessary worktrees.
  • -
  • Fence an old consumer's acknowledgment after run-use rebinds.
  • -
  • Import duplicated and out-of-order relay frames only in contiguous order.
  • -
-
    -
  • Never treat a worker report as verified integration.
  • -
  • Never replay terminal input after unknown acceptance.
  • -
  • Page terminal output by Dispatch; use a session source only when exact.
  • -
  • Inspect an ambiguous remote stop before choosing the next action.
  • -
  • Let the first committed stop/completion transition win transactionally.
  • -
  • Reject a re-paired peer and never adopt a same-looking restarted process.
  • -
-
-
-
- -
-
-

Complexity audit

-

Keep only the machinery required by Orca's concrete failure modes.

-

- Every retained primitive below addresses a failure Orca can reproduce today. Broader - policy and product layers remain out of scope until a simpler primitive proves - insufficient in real use. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Observed Orca needKeepDeliberately defer
Lifecycle messages can be lost, injected into prompts, or consumed before a caller receives them.Durable typed mail, explicit acknowledgment, and race-free blocking waits.Dead-letter workflows, priority schedulers, watchdog policy, or a second queue product.
Terminal scrollback is not always the best available source, but session identity can be ambiguous.Terminal output as the baseline and an optional exact, source-pinned adapter.A universal session ontology, resume layer, or global provider exclusivity.
Remote acceptance, process exit, and worker claims provide different levels of evidence.Explicit lifecycle, fenced replacement, and typed unknown-outcome handling.Automatic retry, inferred success, rollback, or generalized recovery policy.
A Run home must coordinate workers across restarts and connected Orca servers.Stable Run, Task, and Dispatch identity with authenticated server relay.Role simulation, worker scoring, organization models, or integration queues.
-
- -
-
-

Tests prove the contract

-

- Scenario tests must demonstrate durable messages, authenticated relay, lifecycle - fencing, and truthful recovery. Design analogy is never a substitute for an - Orca-local executable contract. -

-
-
-

Complexity requires local evidence

-

- A generalized scheduler, fairness policy, dead-letter workflow, or integration - system should be proposed only after Orca users demonstrate that the simpler - primitives cannot solve a recurring problem. -

-
-
- - -
- -
-
-

Implementation order

-

Each phase should remove one concrete source of agent confusion.

-

- There is no UI phase. Each runtime change ships with a version-matched example and a - misuse test so the skill and behavior cannot drift apart. -

-
- -
-
-
Phase 0
-
-

Rewrite the orchestration skill as recipes

-

- Teach correct fan-out, current versus new worktree selection, startup terminal - reuse, --setup run for new worktrees unless the agent states a - concrete reason to skip or inherit, while preserving the existing - start-immediately default, current batch behavior, and blocking only - after useful parallel work is exhausted. Correct the one-message claim and - document today's delivery limits. - Rename or remove the existing scheduler-like orchestration run --spec - command before the lightweight run-* vocabulary can ship. -

-
-
-
-
Phase 1
-
-

Run mailbox, truthful completion, and crash-safe consumption

-

- Add explicit Run-home binding, stable logical recipients, strict - inbox-only structured mail, succeeded/failed worker outcomes, one outstanding - FIFO batch, explicit acknowledgment, typed timeout results, ask/reply state, - a narrow runtime-minted Dispatch capability carried by the CLI bridge, and - resume-by-message-ID after a disconnected ask. Migrate existing global rows to - one unbound inspect-only legacy Run; do not infer bindings. Do not add separate - Question IDs or expiry policy. -

-
-
-
-
Phase 2
-
-

Local synchronous worker start and control

-

- On the Run home, compose existing worktree, setup, terminal, and dispatch - primitives with setup-run as the new-worktree default, a durable request/stage - receipt, side-by-side setup/agent startup by default, startup-terminal reuse, - exact readiness, and Dispatch-routed - show/read/stop/abandon. Prove current, existing, - child, top-level, failure, restart, and unknown-outcome behavior before adding a - network boundary. Do not add a background provisioning executor. -

-
-
-
-
Phase 3
-
-

Connected-server Dispatch and relay

-

- Extend the same primitives with saved-environment placement, pinned peer identity, - remote Dispatch attachments, paired-server calls, and bounded bidirectional relay - with contiguous cursors and idempotent acknowledgment. Validate Mac-home/Windows- - worker and Windows-home/Mac-worker completion, question/reply, read, stop, - either-side restart, re-pairing, disconnect, and mixed versions—without Run - replication, failover, or scheduling. -

-
-
-
-
Phase 4
-
-

Exact structured worker output

-

- Reuse Orca's existing pane-scoped hook association and bounded - Codex/Claude/OpenClaude/Grok transcript decoders. Add - auto|transcript|terminal selection, - path-free source identity, opaque source-pinned paging, and labeled terminal - fallback without adding provider control or a universal transcript layer. -

-
-
-
- -
-
-

Success looks like

-
    -
  • Agents start all independent work before waiting.
  • -
  • Lifecycle messages never flood editable coordinator input.
  • -
  • One Run can supervise Mac and Windows workers through one home inbox.
  • -
  • Remote completion and replies survive either server temporarily disconnecting.
  • -
  • No delivery is consumed before explicit acknowledgment.
  • -
  • Failed worker reports set failed—not completed—and remain worker assertions.
  • -
  • Worker start never reports a resource created before it exists.
  • -
  • Full-screen agent output remains readable when an adapter supports it.
  • -
  • Stale, wrong-pane, or wrong-server workers cannot mutate current task state.
  • -
-
-
-

Complexity budget

-
    -
  • No new concept without a common recipe that needs it.
  • -
  • No automatic action whose trigger an agent cannot explain.
  • -
  • No provider field that an adapter cannot actually observe.
  • -
  • No hidden default omitted from the operation receipt.
  • -
  • No control-plane fact derived from untrusted transcript prose.
  • -
  • No future feature included merely to keep the architecture open-ended.
  • -
-
-
-
- -
-
-

Explicit boundaries

-

What this proposal intentionally does not build.

-

- These are not hidden later phases. They require separate evidence and a separate - proposal if Orca eventually needs them. -

-
- -
-
-

No product UI

-
    -
  • No dashboard or run/task view
  • -
  • No global inbox, badges, or queue screen
  • -
  • No coordinator chat surface
  • -
  • No task DAG visualization
  • -
  • No changes to existing Orca UI behavior
  • -
-
-
-

No scheduler

-
    -
  • No automatic task dispatch or placement
  • -
  • No capacity vectors or resource classes
  • -
  • No fairness, priority aging, or global queue
  • -
  • No pause, resume, or drain controls
  • -
  • No automatic retry based on silence
  • -
-
-
-

No integration subsystem

-
    -
  • No commit or branch tracking
  • -
  • No automatic merge or landing
  • -
  • No target-ref locking
  • -
  • No independent verification of worker claims
  • -
  • No cross-run work lineage model
  • -
-
-
-

No speculative framework

-
    -
  • No organization charts, roles, or worker profiles
  • -
  • No universal provider transcript schema
  • -
  • No dead-letter or poison-message workflow
  • -
  • No generalized continuation/checkpoint protocol
  • -
  • No project hierarchy above lightweight runs
  • -
  • No replicated Run database, leader election, or automatic home failover
  • -
-
-
- -
- Could Orca add these things later? -
- Yes, but strong primitives do not need speculative abstractions for them today. A - future feature should compose the same start, message, wait, read, stop, identity, - and ownership contracts. It should justify its own concepts from observed Orca use. -
-
- -
- Does removing commit tracking make worker results less trustworthy? -
- It makes the contract more honest. Today Orca verifies who is authorized to report a - result, not that every claim inside the result is true. A coordinator may explicitly - ask another worker to review or run validation. That is agent-directed orchestration, - not an implicit integration subsystem. -
-
- -
- What about connecting multiple Orca runtime servers? -
- It is a core requirement. A Run stays authoritative on one home server while remote - Dispatches execute on saved connected environments such as a Windows machine. - Authenticated routing plus a small durable relay carries lifecycle mail and replies - across disconnects. This intentionally stops short of a global cluster: no Run - replication, leader election, automatic failover, distributed scheduler, or lease - manager is required. -
-
- - -
-
-
- - - - - diff --git a/docs/orchestration-reset-scope-validation.md b/docs/orchestration-reset-scope-validation.md deleted file mode 100644 index 71c02abae0c..00000000000 --- a/docs/orchestration-reset-scope-validation.md +++ /dev/null @@ -1,89 +0,0 @@ -# Orchestration Reset Scope Validation - -## Problem - -`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024). - -## Root Cause - -`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch. - -## Non-goals - -- Redesign orchestration storage or reset semantics. -- Add confirmation prompts to the RPC protocol. -- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior. -- Change unrelated orchestration commands or provider-specific behavior. - -## Design - -1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`. -2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state. -3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls. -4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method. -5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain. -6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation. - -## Data Flow - -- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`. -- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects. -- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects. - -## Edge Cases - -- `{}` rejects and leaves messages and tasks unchanged. -- `{ all: false }` rejects and leaves messages and tasks unchanged. -- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged. -- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged. -- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes. -- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present. -- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work. -- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state. -- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix. - -## Test Plan - -- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions. -- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior. -- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly. -- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI. -- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files. -- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots. - -## UI Quality Bar - -Not UI-visible. - -## Review Screenshots - -No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior. - -## Rollout - -1. Tighten `ResetParams` validation. -2. Simplify the reset handler to trust validated single-scope params. -3. Make CLI no-flag reset pass `all: true` explicitly. -4. Add RPC regression tests for invalid params preserving state. -5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough. -6. Run targeted tests, typecheck, and lint. - -## Lightweight Eng Review - -- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes. -- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`. -- Failure modes covered: - - Empty reset params cannot mutate state. - - Contradictory scopes cannot partially apply the first truthy branch. - - Failed validation happens before any database reset method runs. - - CLI shorthand cannot rely on a dangerous RPC fallback. - - CLI multi-flag input fails through the same RPC validation as any other caller. -- Test coverage required: - - `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state. - - `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing. - - `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented. -- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked. -- UI quality bar: Not UI-visible. -- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior. -- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller. -- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change. diff --git a/docs/persist-tree-view-source-control.md b/docs/persist-tree-view-source-control.md deleted file mode 100644 index c377c9b82ca..00000000000 --- a/docs/persist-tree-view-source-control.md +++ /dev/null @@ -1,93 +0,0 @@ -# Persist Source Control Tree View Choice - -## Problem or Goal - -The Source Control sidebar lets the user toggle changes between list and tree views, but the choice is session-local. After remount or app restart, it falls back to list view. Persist this as a per-user setting, not per-workspace state, so a user's preferred source-control layout follows them across repos and worktrees. - -## Current Behavior - -- `SourceControlViewMode` is a local union in `src/renderer/src/components/right-sidebar/SourceControl.tsx:127`. -- `SourceControlInner` already reads global settings from the Zustand store at `src/renderer/src/components/right-sidebar/SourceControl.tsx:329`. -- The source-control view mode is initialized with component-local React state at `src/renderer/src/components/right-sidebar/SourceControl.tsx:486`, hard-coded to `'list'`. -- The tree/list toggle only calls `setSourceControlViewMode` at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2454` and `src/renderer/src/components/right-sidebar/SourceControl.tsx:2457`. -- The chosen mode controls uncommitted entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2859` and branch comparison entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2967`. -- Settings are typed in `GlobalSettings` at `src/shared/types.ts:1271`, defaulted by `getDefaultSettings()` at `src/shared/constants.ts:154`, loaded with default merging at `src/main/persistence.ts:1217`, exposed through `settings:get` / `settings:set` at `src/main/ipc/settings.ts:29`, and updated in the renderer settings slice at `src/renderer/src/store/slices/settings.ts:236`. -- `PersistedUIState` exists at `src/shared/types.ts:1704`, but the request specifically asks for the user's setting rather than workspace-specific UI state. - -## Proposed Design - -Add a new global user setting: - -```ts -sourceControlViewMode: 'list' | 'tree' -``` - -Implementation details: - -- Add a shared `SourceControlViewMode = 'list' | 'tree'` type and the field to `GlobalSettings` in `src/shared/types.ts`. -- Add the default value to `getDefaultSettings()` in `src/shared/constants.ts`; use `'list'` to preserve existing behavior for new and upgraded users. -- Reuse the existing persistence path. `src/main/persistence.ts` already merges `defaults.settings` with `parsed.settings`, so older profiles automatically hydrate the new field without a bespoke migration. -- In `SourceControl.tsx`, stop using local `useState('list')` as the durable source of truth. Read the persisted value through a small guard such as `normalizeSourceControlViewMode(settings?.sourceControlViewMode)`, returning `'list'` for missing or invalid values. -- Keep a narrowly scoped optimistic mode in `SourceControlInner`: `optimisticSourceControlViewMode: SourceControlViewMode | null`. The rendered mode is `optimisticSourceControlViewMode ?? normalizedSettingsMode`. -- Select `updateSettings` from the store and update the toggle handler to compute the next value from the current rendered mode, set the optimistic mode immediately, and persist the next value through `updateSettings({ sourceControlViewMode: next })`. -- Track a monotonically increasing write sequence with a ref. Only the latest in-flight write may clear or revert optimistic state, so out-of-order `settings:set` responses cannot make an older click win over the user's latest intent. -- When `settings.sourceControlViewMode` changes from outside this component, clear the optimistic value if there is no newer in-flight write. This lets the authoritative settings snapshot take back over after hydration, Settings import, or another renderer path updates settings. -- Keep tree expansion/collapse state (`collapsedTreeDirs`) local and session-only. Directory expansion is path/worktree-content-specific, while the requested setting is only the global list/tree layout preference. -- Do not add a Settings pane control unless product wants one later. The existing toolbar icon remains the natural place where the user makes the choice; persisting that click is enough for this request. - -The optimistic state is local UI state only; it is not a second persistence channel. It exists because `updateSettings` crosses async renderer/main IPC, and the toolbar should still behave as a normal toggle under slow writes or rapid clicks. - -### Interaction and Data Flow - -```text -Toolbar click - -> derive next mode from current rendered mode - -> optimistic SourceControlInner state updates immediately - -> updateSettings({ sourceControlViewMode: next }) - -> settings:set persists user settings in main - -> renderer store receives authoritative GlobalSettings - -> SourceControlInner clears optimistic state when the latest write settles -``` - -- Happy path: the toolbar flips immediately, `settings:set` returns the saved settings object, and the optimistic value is cleared once the authoritative mode matches the latest requested mode. -- Missing setting: defaults merge in `'list'`; upgraded profiles do not need a migration. -- Invalid persisted value: the renderer guard treats it as `'list'` for display and for the next toggle write, which self-heals persistence on the next user action. -- Write failure: keep the previous authoritative settings object, clear only the latest optimistic value, and let the UI fall back to the last saved mode. Existing `updateSettings` logs the failure; the component does not need a new error surface for this preference toggle. -- Out-of-order writes: if write 1 sets `tree` and write 2 sets `list`, write 1 resolving after write 2 must not clear or overwrite the optimistic `list` intent in the component. - -## Edge Cases - -- Existing profiles with no `sourceControlViewMode` should behave exactly as before: list view. -- Settings may be `null` before hydration; render should not crash. Disable the toggle until settings hydrate so the fallback `'list'` value is never persisted over an existing saved `'tree'` preference. -- If the body renders before settings hydrate, use the guarded list fallback only as a temporary display value. Do not seed optimistic state until the real settings snapshot is available. -- Corrupt or unknown persisted values should normalize to `'list'` at the component boundary. A broader settings migration is unnecessary for this narrowly scoped string preference. -- A settings write failure should not corrupt local or persisted state. Existing `updateSettings` logs errors and leaves the previous settings object intact. -- Rapid toggles should be last-intent-wins from the user's perspective, even if individual `settings:set` IPC responses resolve out of order. -- The choice must apply globally across active worktree switches, repo switches, and local/SSH runtime targets. -- Directory collapse state should not persist globally because tree node keys are derived from file paths and sections; persisting them would leak one repo's shape into another. -- Source control can render both local and remote/SSH worktrees. This setting is renderer/user preference only and must not depend on filesystem paths, runtime target IDs, or workspace IDs. - -## Test Plan - -- Unit: add a focused test for `getDefaultSettings()` in `src/shared/constants.test.ts` asserting `sourceControlViewMode` defaults to `'list'`. -- Unit: add or extend a renderer test around `SourceControl` to verify clicking the tree/list toolbar calls `updateSettings({ sourceControlViewMode: 'tree' })` from the default list state, and then `updateSettings({ sourceControlViewMode: 'list' })` when currently tree. -- Unit: cover the optimistic write sequence with delayed mocked `updateSettings` promises. A rapid `list -> tree -> list` interaction should leave the rendered mode and latest requested update at `list`, even if the earlier `tree` write resolves last. -- Unit: cover `settings === null` hydration behavior: the toggle is disabled before hydration, then reflects a hydrated `'tree'` preference without persisting the fallback `'list'`. -- Unit: cover the normalization helper for invalid values, missing values, and both valid modes. -- Unit: if direct `SourceControl` rendering setup is too heavy, extract tiny pure helpers such as `getNextSourceControlViewMode(mode)` and `normalizeSourceControlViewMode(value)` near the component and test those helpers plus a shallow mocked component interaction. -- Integration-light: `src/renderer/src/store/slices/settings.test.ts` already verifies rebasing local settings to the authoritative `settings:set` response. No new store behavior is required unless implementation changes `updateSettings`. -- Manual/Electron: open Source Control, switch to tree view, switch worktrees and confirm the view remains tree, restart the app and confirm Source Control still opens in tree view, then switch back to list and confirm restart preserves list. - -Playwright coverage is optional for this change. The behavior crosses app restart and local user-data persistence, which is better covered by Electron validation unless there is already a reliable e2e fixture for persistent settings across relaunch. - -## Rollout Order - -1. Add the `GlobalSettings` field and default. -2. Wire `SourceControl` to read a normalized `settings.sourceControlViewMode` with a safe list fallback before hydration. -3. Add optimistic last-intent-wins toggle handling and persist toolbar toggles through `updateSettings`. -4. Add focused unit coverage for the default, normalization, hydration, and async toggle writes. -5. Run `pnpm typecheck`, `pnpm lint`, targeted tests, then manual Electron validation. - -## Ref-OSS - -Not used. The change follows Orca's existing per-user settings pipeline and does not need external editor behavior to resolve the design. diff --git a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md b/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md deleted file mode 100644 index abaa7d108cb..00000000000 --- a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md +++ /dev/null @@ -1,2949 +0,0 @@ -# SSH Relay PTY Backpressure - -Date: 2026-07-27 - -Status: architecture gate closed and exact SSH V1 wire/API implemented -always-on for new SSH sessions; integrated lifecycle fixes reconciled at -the current working tree; live topology gaps remain explicit below - -Historical unbounded baseline: `badf91101babf96fa09cb79a8294f7e23b9f081c` -(the implementation branch parent). The implementation was rebased onto -`origin/main@d9fec8fd61d0349ae8cdf0eea594de7425db94c9`; final GitHub CI remains -the merge gate. - -## Scope - -This implemented design bounds SSH relay PTY output from the native PTY through the relay -dispatcher, SSH channel or relay socket, Electron main process, and renderer -parser. It covers: - -- application-level PTY output credit; -- relay stdout and socket drain handling; -- per-client isolation, fairness, ordering, and cleanup; -- bounded frame decoding on both sides of the relay protocol; -- memory budgets, diagnostics, compatibility, tests, and deployment. - -The sidebar reconnect fix in `9d3ae3adc7` is out of scope. The non-ancestor -commit `1500a92904` is design input only; it must not be cherry-picked. This -work does not change PTY input semantics, terminal-model interpretation, or -file/Git payload semantics. It does change replay transport, SSH producer -pause wiring, and bulk frame admission where they share the dispatcher sink. - -## Implemented architecture and evidence boundary - -The architecture review approved one shared semantic -`PtyConsumerSession` state machine with an SSH-specific transport adapter, -not a universal wire protocol. The exact SSH adapter contract is now -implemented. In this PR: - -- `PtyConsumerSession` owns authenticated client/owner generations, lease - recovery, capability intersection, and grant publication commit/rollback. -- `SshPtyConsumerSessionAdapter` binds those semantics to the existing - authenticated relay connection and the first framed `pty.openClient` - request. The relay dispatcher remains the only sink-publication authority. -- The relay source ledger owns immutable source spans, outstanding source - credit, token rotation, sealed exit, and cancellation proof. The dispatcher - writer owns byte admission, priorities, `write(false)`, callbacks, and drain. -- Spawn and attach responses carry immutable `sourceActivation` metadata. The - main mux installs a provisional receive lease synchronously in - `beforeResolve`; adjacent source notifications remain private to that lease - until spawn validation commits or a validated reconnect contract transfers - them into its private recovery quarantine. Rollback drops untransferred data - and waits for exact token-cancellation proof. -- `SshRelaySession` owns grant/reconnect state, bounded recovery quarantine, - exact recovery fencing, and cumulative ACK publication through the main SSH - mux. Failed recovery-cancellation publication or proof closes the exact - provider generation and its publishers once. -- The main SSH intake owns one atomic admission across the model, desktop - projection, and required remote consumers. Model completion and consumer - terminality make ACKs eligible; only the mux write callback publishes them. -- Desktop and remote replacement transactions carry immutable span identity. - Reserve happens before publication, commit requires the exact - generation/sequence fence, and failure, stale publication, or already - reclaimed spans reject commit and roll back idempotently without credit. -- Relay `restoreRequired` cancellation retains its exact delivery record until - the metadata response settles, then retires only that record so a retry can - mint a fresh token. Main exit timeout obtains cancellation proof before - transferring projections and committing reclamation, with generation checks - around every finalization step. -- The main-to-relay writer has three FIFO lanes: liveness, control, and - ordinary. Drain reselects liveness/control ahead of queued ordinary traffic, - while one ordinary frame is guaranteed after four control writes. - -The implementation is intentionally scoped: - -| Surface | State in this PR | Evidence | -| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Same-build direct SSH/deployed relay, V1 | implemented; every session offers and relay advertises V1 | current-working-tree deterministic contracts plus provenance-bound macOS-hosted Docker OpenSSH | -| Capability/method-not-found legacy fallback | implemented bounded legacy compatibility | deterministic negotiation and writer contracts | -| Reconnect/owner recovery | implemented | exact contiguous recovery, fail-closed cancellation, eight-wide reattach tests, Docker reconnect | -| Headed/headless remote consumers | source-range rotation/replacement implemented | deterministic runtime/main seam only; no live paired-runtime claim | -| WSL stdio and Windows named pipe/ConPTY | shared transport paths preserved | deterministic/common-contract evidence only; no physical run | -| Local provider and local daemon | unchanged | outside SSH V1 negotiation; existing behavior only | -| Folder workspace | no `.git` dependency added | code-path review only; no dedicated live fixture | -| Prior-version/mixed-version peers | fail-closed or version-scoped legacy behavior preserved | deterministic negotiation/deploy contracts; no live old binary | -| Ubuntu 20.04/glibc 2.31 | no native dependency added | cross-target relay build only; no physical packaging run | - -Docker SSH proves the Linux SSH provider and deployed-relay topology. It does -not prove headed or headless paired-runtime behavior, WSL, Windows ConPTY or -named pipes, local daemon/provider behavior, folder workspaces, prior-version -processes, mixed-version clients, or the Ubuntu 20.04 packaging floor. - -The final exact-code Docker rerun rebuilt the E2E main bundle and all relay -targets from the current-main merge head. It is bound to implementation commit -`adba3410fe4427ceb7525f3fdce2ec58973263a7`, tree -`e43d87340666ca3a733bdb38007c24f95be3f219`, main bundle SHA-256 -`3c304ffc0618520e42bede9a52f72d4b7bb68cbfe543974f89e82a3c477c44b3`, -and deployed Linux-x64 relay SHA-256 -`366cb7ccf2e4b388cc6f81a8f6055ab9fde57d2d91381767005c15908832e776`. -No additional live topology proof is inferred from that run. - -## Verified baseline and migration boundary - -On the historical baseline, the SSH path was not protected by the main pending-data bound: - -1. `node-pty` calls `PtyHandler`'s `onData`. `PtyHandler` keeps the existing - 100 Ki-source-unit replay tail. The first ordinary flush waits 8 ms, a - sustained flush continues after 1 ms, and interactive or transformed output - may publish immediately; a turn publishes at most two 16 Ki-unit chunks. -2. `RelayDispatcher.notify('pty.data')` broadcasts each publication. The - ordinary notification path ignores `process.stdout.write()` and - `Socket.write()` returning `false`, so every later frame can extend a Node - writable queue after the first saturated write. -3. Main's `FrameDecoder.feed()` drains all complete frames synchronously. - `MultiplexerTransport` exposes neither read pause/resume nor write drain. -4. `SshRelaySession.wireUpPtyEvents` is the production SSH delivery owner. It - calls `runtime.onPtyData()` and then sends `pty:data` directly to the - renderer. It does not enter the `PtyPendingDataDrainQueue` installed by - `src/main/ipc/pty.ts` for local and daemon providers. -5. Main therefore has no SSH queue record against which to calculate useful - upstream progress. The current cumulative renderer ACK is converted to a - delta, the SSH path commonly emits zero, and the relay's - `PtyHandler` ignores `pty.ackData` in any case. -6. Production POSIX and Windows deployment launches the relay detached, then - attaches the real desktop bridge through a Unix socket or named pipe. - Dispatcher construction therefore cannot identify the usable session owner. -7. `OrcaRuntime.onPtyData` returns synchronously while headless-emulator writes - append captured strings to an unmetered asynchronous `writeChain`. -8. Relay fs/Git chunks use `notifyBulk`/producer chains outside any stated PTY - reserve; they can occupy the same ordered sink ahead of cancel, ACK, and - interactive output. -9. `computeRemoteRelayDir` content-hash-scopes both daemon files and endpoints. - Mixed deployed binaries are fenced, while a prior-version daemon can remain - alive in its old directory after upgrade. -10. `SshPtyProvider` omits the existing producer-pause hooks, and production - constructs `MultiplexerTransport` in both SSH sentinel handling and the WSL - hook path. -11. Lossless remote terminal streams ACK encoded byte deltas today and retain - only aggregate in-flight bytes; no existing record maps those bytes back to - immutable SSH source intervals. `694363805` extracted their UTF-16 - code-unit-preserving chunker to - `src/main/runtime/rpc/terminal-output-frame-chunks.ts`; it improves - performance and equivalence coverage but adds no provider/token identity. -12. `1fd0f731f` routes SSH folder-workspace automation launches to their owning - host. It does not change PTY output delivery or make folder workspaces Git - worktrees. -13. `077561f89` moved remote terminal stream UTF-8 measurement into - `terminal-stream-byte-length.ts`, preserving legacy flush boundaries and - partial over-limit counts. Those encoded-byte counters remain transport - budgets, not SSH source credit. -14. `2dac0741b` preserves ordered DEC mode 2031 subscribe/withdraw decisions - when the current main pending-data queue drops or salvages renderer output. - Its bounded cross-chunk scan state is projection metadata that a unified - SSH intake must preserve; it does not settle source spans. -15. `d547e278f` adds epoch-scoped, post-delivery watermarks for mobile - notification catch-up. That notification replay protocol is distinct from - mobile terminal streaming; its sequence and epoch cannot identify or settle - PTY source ranges. - -This project first moves ownership of every SSH provider data event out of the -direct `wireUpPtyEvents` send and into one main delivery intake. That intake -performs runtime ingestion, desktop delivery policy, remote-consumer -fan-out, and upstream span settlement exactly once. Adding another listener is -forbidden because it would double-ingest and double-render output. - -The initial 8 ms/continuation 1 ms cadence and two-write limit are scheduling -controls, not bounds. A continuous producer can currently grow the relay -writable queue, SSH buffers, decoder input, and Electron heap. The frame-header -`ack` remains transport liveness bookkeeping; it is not PTY credit and never -enters this ledger. - -The falsifiable invariant is: accepted SSH relay PTY output remains bounded -across relay writable queues, SSH/socket transport, main decoding and model -admission, renderer projection, and remote consumers while every source span -settles exactly once in order. The observable failure on this baseline is that -ordinary relay PTY notifications continue after `write(false)`, main drains all -complete frames synchronously, SSH bypasses `PtyPendingDataDrainQueue`, and -headless emulator writes enter an unmetered Promise chain. A sustained SSH -producer can therefore increase relay, transport, and main memory without a -finite upstream credit owner. - -Authority and delivery boundaries on this baseline are: - -```text -renderer/viewer - -> IPC or runtime RPC - -> Electron main / OrcaRuntime - -> SshRelaySession + SshPtyProvider - -> SshChannelMultiplexer - -> SSH channel / --connect bridge - -> RelayDispatcher - -> PtyHandler - -> node-pty / ConPTY -``` - -`PtyHandler` owns native output and replay; relay dispatcher/adapters own -client writes; `SshRelaySession.wireUpPtyEvents` owns production SSH ingestion; -`OrcaRuntime` owns the headless model; `src/main/ipc/pty.ts` owns -main-to-renderer accounting; and `terminal.multiplex` owns remote-consumer -delivery. The current bug is fragmentation across those owners, not a missing -renderer-only cap. - -## Required invariants - -1. Protocol credit uses one canonical unit: UTF-16 code units in the - pre-transform source span, called source units (`su`). Retained memory and - wire queues use exact bytes and never masquerade as source credit. -2. For a negotiated token, - `0 <= sentEnd - creditedEnd <= windowSu`; each admitted slice fits the - remaining window, so no frame overshoot is permitted. ACKs are cumulative, - monotonic, client/PTY/token scoped, and never exceed `sentEnd`. -3. Source-credit spans are immutable and stored in a cumulative ledger. - Queue data may merge, split, salvage, thin, or coalesce without moving, - copying, or destroying ledger boundaries. -4. A sink `write(false)` accepts its frame exactly once. No ordinary frame is - written again before drain. PTY admission stops before a reserved - control/liveness capacity is consumed. -5. A spawn/attach token remains `activating` until its metadata-only response - crosses the relay sink fence. Main may receive source frames after its - synchronous provisional install, but they remain lease-private and cannot - project. A validated reconnect contract transfers that lease into the - attempt's private recovery quarantine; final commit, model, and desktop - admission remain fenced until all recovery `pty.data` and the exact - completion fence agree. -6. Data is ordered within a token. `pty.exit` follows every accepted data frame - and cannot bypass the sink gate or activation fence. Publishing exit seals - new data but does not retire an uncredited suffix. -7. Every open token-owned span and consumer obligation ends exactly once in - `settled`, `transferred`, or `canceled`; a transfer may pass through the - non-terminal `transferring` state only while its named replacement fence is - outstanding. A canceled `restoreRequired` delivery remains addressable until - its exact response settles and retires only if the map still names that - record. Records are never merely abandoned while their relay token can live. -8. Desktop, mobile, web, and agent-session consumers follow the explicit - settlement policy below. A stalled recoverable desktop projection cannot - freeze a healthy lossless remote view. -9. One slow additional subscriber cannot retain unbounded data or stop a - healthy subscriber. The negotiated session owner is not torn down for one - PTY's backlog; constructor position never grants that role. -10. When every delivery for a PTY is blocked, pause the native PTY. Resume only - when both local and relay-wide low-water predicates hold. -11. An unexpected client loss transfers outstanding output to a bounded - reconnect-grace owner. Normal subscriber absence retains only the existing - replay tail and no live queue. -12. Decoder work, decoded bytes, model admission, and activation-hold turns are - bounded. A self-imposed read or local-write pause rebases both liveness - clocks. Main-to-relay drain reselects liveness, then control, ahead of - ordinary PTY input; four consecutive control selections force one ordinary - selection when both lanes remain non-empty. -13. Disconnect, provider replacement, renderer reload, exit, disposal, and - workspace removal have explicit bounded cleanup. After their required - publication, transfer, cancellation, or generation-close proof, they leave - no open span, token, writer callback, drain waiter, timer, cursor, or paused - PTY. Recovery-cancellation publication failure or rejected proof fails - closed by closing only the owning provider generation and disposing its - mux, provider, publishers, transferred activation state, and registration - once. -14. All counters are finite safe integers. A transformed frame requires a - valid `rawLength`; malformed, excessive, stale, and cross-client values - cannot create credit or crash either process. -15. The session owner is elected by an authenticated session grant and - identified by an owner generation. It is never inferred from dispatcher - construction, stdout, socket order, a path, `.git`, or a worktree. -16. Recovery and serialization control responses contain metadata only. - Source-ranged recovery or snapshot bodies use bounded producer lanes and - explicit completion fences before live delivery. -17. The required main model has bounded asynchronous admission. Relay credit - cannot advance while data waits outside that charged admission or after an - emulator failure. -18. Negotiated V1 has one upstream ACK owner. Renderer projection progress and - remote encoded-byte ACKs settle ledger obligations but never emit legacy - SSH ACK deltas. -19. Required obligations becoming terminal, cumulative ACK queueing, and ACK - publication are three monotonic states. A write callback publishes already - eligible credit; it never creates eligibility. -20. Desktop admission carries immutable span identity through model reserve, - projection queue, renderer send, ACK, salvage, reload, and replacement. - Failure either rolls back an uncommitted transaction or transfers a - committed obligation with proof. Exit timeout acquires cancellation proof - without mutation, transfers published projections, commits proof - reclamation, prepares/finalizes exit once, and rechecks the provider - generation before each post-proof mutation. -21. Projection drop, thinning, salvage, restore, and replay preserve current - main's ordered terminal side-effect facts and bounded scanner state, - including DEC mode 2031 subscribe/withdraw decisions. Pre-commit rollback - restores the prior scanner snapshot, committed transfer moves projection - state exactly once, and an explicit source gap resets cross-chunk state; - none of these facts creates source credit. -22. The immutable delivery identity is exactly `(id, providerGeneration, -clientGeneration, ownerGeneration, ptyIncarnation, deliveryToken)`. Each - admitted source span additionally fixes `spanId`, source/display half-open - ranges, transform metadata, and model-sequence end where required. -23. Receive activation begins at `checkpointSourceEndSu`. Every recovery - `pty.data` frame is contiguous from that cursor through `recoveryEndSu`; - only the exact `pty.recoveryComplete` writer settlement opens live output. -24. A reentrant cumulative ACK may reserve only the exact pending send - boundary for the same delivery identity. Failed send settlement retains - that boundary and permits only an exact same-token retry. -25. A fixed `fs.streamChunk` keeps the protocol's 256 KiB payload and offset - semantics. It may use the fixed-frame lane only from an empty, - unsaturated producer epoch; ordinary PTY and reshapable bulk frames still - obey the non-reserved capacity. -26. A lossless remote stream may advance its cumulative encoded-byte ACK to an - interior byte offset. Exact byte budget is released immediately, while the - covering immutable source frame remains unsettled until its complete - encoded boundary is crossed. -27. A lossless remote stream accepts an in-place source-token rotation only - when PTY ID, provider generation, and incarnation match; both client and - owner generations strictly increase; the token changes; and source/display - coordinates remain contiguous. Old or partially increasing identities are - rejected. -28. Snapshot replacement commits are idempotent and never synthesize byte or - source credit. Duplicate/closed ledger commits are no-ops; if cancellation - proof already reclaimed a reserved span, source replacement commit rejects, - rollback skips the absent span, and settle/detach prune cached mappings. -29. Exact exit seals a provisional or private-recovery receive lease before - any later same-token frame can reserve or project. Exit publication waits - for the held contiguous prefix to admit or for cancellation proof whose - sent end covers every privately observed exact-token frame. -30. Transfer does not consume rollback authority until the ledger proves the - lease is still current and installs the private recovery sink. A stale - transfer can still retire and cancel only its own token. -31. Canceled-frame suppression retains at most one latest ordered delivery - identity per relay PTY. Transport ordering makes earlier token records - obsolete when a later cancellation is observed; exact next activation, - PTY exit, or provider teardown clears the remaining record. - -## Protocol - -### Architecture decision record - -Decision: `PtyConsumerSession` is a shared semantic state machine, not a -universal transport protocol. The architecture gate is closed and is no -longer waiting for exact wire/API names: the SSH adapter implements -`sourceActivation`, source-bearing `pty.data`, `pty.recoveryComplete`, -cumulative `pty.ackData`, and token-scoped cancellation. Current-working-tree -deterministic validation is an engineering gate; unexecuted live topologies -remain separate promotion gates. A relay-only -`pty.getCapabilities` followed by `pty.negotiateClient` remains rejected -because it would create a second readiness authority beside the existing -authenticated handshakes. - -The 2026-07-28 architecture/terminal review closed its three blocking -findings as follows: - -| Finding | Implemented resolution | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Exited delivery removed while uncredited spans remain | Native exit seals the token; logical state survives until cumulative ACK, exact transfer, token-scoped cancellation proof, or generation close. | -| Terminal obligations depended circularly on ACK publication | Obligation terminality advances `obligationsTerminalEndSu`; the coalescer independently queues `ackQueuedEndSu`; only mux write settlement advances `ackPublishedEndSu`. | -| Desktop projection admission lacked immutable range identity and rollback | Model, source span, projection range, and scanner snapshot reserve atomically; pre-commit failure rolls back, while post-commit replacement transfers with proof. | - -Later adversarial review also required exact recovery continuity, stale-attempt -isolation, session-grant fallback semantics, and idempotent remote detach -after token reclamation. Those are incorporated in the activation, lifecycle, -cleanup, tests, and compatibility sections below. - -The final 2026-07-28 architecture/terminal re-review found and closed three -additional interleavings: - -| Finding | Implemented resolution | -| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Failed exit publication could discard a sealed delivery before its suffix was credited | Failed publication retains the sealed delivery; late cumulative ACK and exact owner recovery can still settle it, and exit is republished without reopening source admission. | -| Recovery-response cancellation authority ended at enqueue rather than writer settlement | The request remains cancellation-authoritative until its response settles; retry reuses the same provisional replacement identity instead of creating an unfenced owner. | -| Snapshot replacement transferred every pending remote span at one sequence fence | Each immutable span retains its model-sequence end; replacement transfers only spans covered by the authoritative `SnapshotEnd`, while trailing spans remain live and replay in order. | - -Current-working-tree adversarial reconciliation closed these additional integrated -failures without widening the approved topology: - -| Finding | Implemented resolution | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Invalid-checkpoint `restoreRequired` left a canceled delivery blocking retry | Keep the exact canceled record through response settlement, then delete it only if it is still current; retry mints a fresh activation token. | -| Recovery cancellation publication/proof rejection left ambiguous authority | Raise a typed recovery-cancellation failure and close the exact provider generation, disposing its mux, provider, publishers, lease, and registration exactly once. | -| Remote partial ACK or higher-generation token rotation detached the stream | Release exact cumulative byte credit at any valid interior offset without source settlement; admit only contiguous strictly higher client/owner generations with a new token. | -| Snapshot cleanup raced cancellation-proof reclamation | Make ledger replacement commit idempotent and make remote replacement commit reject reclaimed spans while rollback, settle, and detach prune them without dereference or new credit. | -| Drain resumed the main mux's old ordinary FIFO ahead of control | Select liveness, control, and ordinary from separate FIFO lanes; prioritize control after drain and force one ordinary write after four controls. | -| Same-turn source data escaped a provisional activation | Queue data privately under the provisional lease; commit publishes in order, while rollback drops it, restores only the active predecessor, and awaits exact cancellation proof. | -| Exit cancellation proof reclaimed spans before projection transfer | Acquire proof first, transfer projections, commit proof, prepare/finalize once, and fence every post-proof step against provider-generation close. | -| Exit during private recovery could publish before held frames settled | Seal the private lease immediately, wake the exact recovery fence, then either admit the ordered prefix before exit or retire it only after exact token-cancellation proof. | -| A superseded transfer lost its cancellation authority | Keep the outer lease unsettled until transfer ownership is verified; a stale transfer can still roll back and request cancellation for its exact token. | -| Recovery proof watermark skipped rejected or post-restore frames | Observe every exact-token private frame before retention or restore checks and reject any cancellation proof whose sent end is below that immutable high-water. | -| Canceled-token tombstones grew once per token | Retain only the latest ordered canceled token per relay PTY, replace it on later cancellation, and clear it at exact next-activation, PTY-exit, or provider-teardown boundaries. | -| Reconnect checkpoint raced a running raw model callback | Freeze the exact provider-generation/PTy admission, cancel only queued work, and await the bounded running callback before exporting a checkpoint. | -| Migration-owned callback failure closed the shared provider | Contain failure to the exact migrating PTY, reset only its model, preserve siblings and transport, and retain the ordinary generation-fatal policy outside migration. | -| Closed-generation telemetry omitted active gaps | Compact exact closed ranges and expose the count of allocated but unclosed provider generations below the closed high-water. | - -The final exact-implementation-head architecture/lifecycle review was clean -across 27 files and 296 tests. The transport/topology review confirmed the -exact-PTY containment fix and found no remaining code blocker; its sole -evidence finding was the stale artifact record replaced by the -provenance-bound build and Docker run above. - -The common state machine accepts only: - -- an authenticated principal and owner-eligibility decision from the adapter; -- a consumer generation and optional capability offer/grant; -- a subscription/delivery identity and close reason; -- an adapter-provided publication fence. - -It does not authenticate sockets, parse frames, own sentinel/residue bytes, -wait for stream drain, prove reconnect credentials, or define remote-runtime -encoded-byte ACKs. Those remain adapter responsibilities. The semantic input -and output may be represented as: - -```ts -type PtyConsumerSessionHello = { - clientInstanceId: string - requestedRole: 'session-owner' | 'subscriber' - resume?: { - ownerGeneration: number - ownerLease: string - } - capabilities?: { - outputFlowControl?: { versions: [1]; requestedWindowSu: number } - } -} - -type PtyConsumerSessionGrant = { - serverBuildId: string - clientGeneration: number - role: 'session-owner' | 'subscriber' - ownerGeneration?: number - ownerLease?: string - capabilities?: { - outputFlowControl?: { version: 1; windowSu: number } - } -} -``` - -These types are not wire schemas. Authentication produces a transport-bound -principal and `allowSessionOwner` before the state machine sees the offer. A -request field cannot self-promote a client. The state machine performs -generation allocation, owner replacement, capability intersection, -publication fencing, and close cleanup; the adapter proves identity, carries -the semantic fields, and calls the fence. - -The reviewed decision preserves the existing connection machinery: - -| Path | Authentication and identity | Binding in this PR | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Local in-process provider | trusted main-process construction | unchanged; it does not negotiate SSH V1 | -| Local daemon | token-authenticated `HelloMessage`, stable `clientId`, paired control/stream sockets, and `daemonIdentity` | unchanged; an exact-version daemon remains outside the SSH wire contract | -| SSH relay socket/named pipe | existing authenticated relay endpoint and dispatcher client identity | first framed `pty.openClient`; response settlement is the readiness fence | -| Primary relay stdio and WSL child stdio | no authenticated dispatcher principal in this PR | bounded legacy transport only; an unproved client cannot become session owner | -| Remote-runtime server | paired-device/E2EE identity and connection ID | source-range reserve/commit/rollback only; never receives an SSH owner lease | - -The implemented owner path is the authenticated detached relay endpoint used -by the SSH provider. After extracting the exact sentinel and residue, main -sends `pty.openClient` before spawn, attach, or ordinary dispatch. The relay -adapter queues later decoded frames behind the session response's write -callback, so an eager next request cannot race activation. Main marks the -provider ready only after receiving the valid response. - -Detached `--connect` first reads the version-scoped endpoint credential and -authenticates its Unix-socket/named-pipe bridge before emitting its sentinel. -The adapter converts that proof into a principal and -`allowSessionOwner = true`; `pty.openClient` carries no secret to the generic -RPC handler. The primary relay stdin/stdout dispatcher identity remains -explicitly unproved and therefore subscriber-only; there is no launch nonce in -this implementation. WSL child stdio shares the bounded decoder/writer -transport changes but does not negotiate an SSH V1 owner in this PR. A manual -relay without the version-scoped endpoint credential is also subscriber-only. - -POSIX hosts may expose different shell and SFTP home namespaces. While the -install lock is held, a per-launch marker maps the canonical shell credential -path to the matching SFTP-relative path; the credential write uses that -mapping before the lock is released. If the marker is unavailable or its -creation fails with confirmed termination, main generates the credential -remotely with the selected Node binary at the canonical shell path. System SSH -keeps its shell-path writer, Windows keeps its native secure writer, and the -remote CLI launcher receives the same credential-file path. An unconfirmed -marker teardown retains the lock rather than authorizing a competing writer. - -The review compared sentinel extension and the single request against one -readiness authority, no first-spawn race, authenticated owner replacement, -response publication fencing, reconnect idempotency, exact residue transfer, -and deterministic testability. The single request wins for the authenticated -deployed-relay bridge. A separate capability probe, two-step negotiation, and -a generic cross-transport wire API are not approved. - -The local daemon adapter is deliberately not changed in this PR. Its existing -sequential control/stream hello, authentication, exact-version fencing, and -prior-version isolation remain authoritative. Reusing the semantic state -machine there is a later design decision and is not required to ship the SSH -bound. - -Every new SSH session offers V1, and every relay from the same build advertises -V1. The server intersects the offer with its supported versions and clamps -`windowSu`; an older client that omits the capability receives bounded legacy -delivery while still establishing the role and generation. Main accepts only -a returned version it offered and a finite safe integer satisfying -`0 < windowSu <= requestedWindowSu`. An absent or invalid V1 grant after -`pty.openClient` succeeds closes that connection attempt rather than silently -downgrading it. - -For compatibility with a legacy relay that does not implement -`pty.openClient`, main permits token-free bounded legacy delivery only for the -narrow same-build-validated JSON-RPC method-not-found result. Other errors, -missing grants after a successful method call, and unproved version skew fail -closed. This fallback does not change the normal same-build contract: the -session offers V1 and the relay advertises and grants it. - -“Bounded legacy” is transport backpressure, not a hidden source-credit mode. -It creates no delivery token, source window, or cumulative ACK obligation, and -the relay continues to ignore legacy `pty.ackData`. Slice 3 retains at most -2 MiB of ordinary publications per client and 32 MiB across the relay, with -1 MiB/24 MiB low waters, plus at most one 128 KiB producer-held frame per PTY. -At a cap, `PtyHandler` pauses the PTY; accepted write callbacks/drain remove -transport publications, and crossing both low waters resumes it. Slice 2/4 -read pauses propagate through SSH until the relay writer saturates, so stalled -model admission remains bounded without pretending renderer ACKs reached the -relay. - -Legacy exit waits only for preceding transport publications and the exit write -callback; main's receive-exit barrier below still waits for its admitted model -and projection work. Transport close cancels the connection's retained -publications and reconnect uses the existing replay/restore behavior. It may -pause all legacy subscribers behind one slow connection because legacy mode -has no authenticated source-owner role, but it neither drops output nor grows -without bound. These mechanics remain available to negotiated legacy peers. - -The session hello creates no PTY token. A later V1 spawn/attach returns a fresh -`deliveryToken` and creates a subscription only for the authenticated -transport client and installed generation. `pty.attach` must receive request -context just as `pty.spawn` does. Spawn failure, identity mismatch, stale -context, and response cancellation create no active V1 subscription. A legacy -spawn/attach returns the current identity/replay shape and creates only the -bounded transport subscription above—no token or source coordinate. A live V1 -token never rotates into legacy service; disconnect closes it before any -separately negotiated legacy fallback connection can open. - -Session ownership is granted by the authenticated session hello, not -constructor-assigned: - -- fresh relay deploy writes one endpoint credential beside the versioned - endpoint, reusing the local daemon's token-authenticated-hello pattern rather - than inventing a second claim protocol. It has mode `0600` on POSIX or a - current-user ACL on Windows. `--connect` proves it; a plain dispatcher socket - without the credential is subscriber-only. Primary relay stdio and WSL - child stdio remain unproved bounded-legacy transports. The adapter converts - proof into `allowSessionOwner = true` and does not expose the credential to - RPC handlers; -- the main creates one opaque `clientInstanceId` for its relay-session - lifetime. The first authenticated owner hello when no owner exists binds - that identity, returns a lease, and starts owner generation 1; -- until that first grant crosses its write fence, a retry with the same - authenticated identity is idempotent and any competing identity is rejected. - Once fenced, replacement requires both the current lease and expected - generation; -- an unexpected owner disconnect retains that lease and its PTY grace cursors - for 30 seconds. A reconnect presenting the lease and expected owner - generation atomically increments the generation, rotates the lease, replaces - the old client, and transfers grace ownership before any new token activates; -- a same-generation retry is idempotent only for the same live client. - Competing, stale, lease-less, or unauthenticated identities cannot replace - the owner and may connect only as ordinary subscribers; -- after grace expiry, the old owner lease is invalidated and the next - owner-authorized session hello can elect a new owner at the daemon's next - monotonic owner generation; generations are never reused. Ordinary - subscribers never inherit owner teardown protection merely by arriving first. - -Source activation, `pty.data`, recovery completion, ACK, and cancellation -carry client/owner generations and the delivery token. `pty.exit` retains its -existing PTY ID plus incarnation shape and resolves against the already -installed delivery identity; it cannot create or rotate one. Main rejects a -generation other than the one installed by its synchronous response hook. The -constructor stdout client is never implicitly a session owner. Production -launches detach on POSIX and Windows, invalidate stdout, and connect the -desktop bridge through `attachClient` over the versioned Unix socket or named -pipe. Unproved direct stdio remains subscriber-only. - -Capability support is negotiated on every SSH session. `SshRelaySession` -always offers V1 during initial connection and automatic reconnect, and POSIX -and Windows relays always advertise it as an unconditional capability. Sink -drain, writer ordering, decoder bounds, and header-ACK hardening remain -unconditional correctness fixes. - -Production deployment does not form arbitrary mixed-build main/relay pairs. -`computeRemoteRelayDir` content-hash-scopes the install directory and its -socket/named-pipe endpoint, and the `.version` handshake is a second fence, so -the desktop bridge and daemon reached at that endpoint share a build. The -normal same-build mode is session-granted V1. A missing mandatory session grant -fails readiness except for the narrow method-not-found compatibility fallback -above. Protocol tolerance for unknown fields remains for direct/manual relay -launches, but an absent session contract outside that fallback is diagnostic -`unsupported-version-skew`, not a deployment cohort. - -The reachable upgrade skew is an orphaned prior-version daemon in its old -version directory with live PTYs while the new main connects to a new endpoint. -It retains its old behavior until its own grace/cleanup completes and is not -reachable by a new same-build session. Upgrade diagnostics enumerate -these versioned orphan processes; V1 neither adopts their PTYs nor claims to -bound their memory. - -Unknown fields are never capability proof. Place the transport-neutral state -types with the narrowest shared session package, while relay/daemon/runtime -wire types remain in their current protocol packages. Do not make the local -provider depend on relay framing for code reuse. - -### Data and ACK schema - -Flow-controlled output is: - -```ts -// relay -> one subscribed client -{ - jsonrpc: '2.0', - method: 'pty.data', - params: { - id: string, - ptyIncarnation: string, - data: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - sourceEndSu: number, - sourceLengthSu: number, - seq?: number, - rawLength?: number, // required and equals sourceLengthSu when transformed - transformed?: true - } -} -``` - -For untransformed frames, `sourceLengthSu = data.length` and `rawLength` is -absent or equal. For `transformed: true`, `rawLength` is required, finite, -safe, non-negative, and equals `sourceLengthSu`; display length is never used -as source credit. A violation cancels the token as malformed. `sourceEndSu` is -the monotonic cumulative coordinate within one opaque `ptyIncarnation`, not a -display offset. A new token starts with `sentEndSu == creditedEndSu` at its -declared checkpoint, so its absolute coordinate still satisfies the window -equation. The source interval is -`[sourceEndSu - sourceLengthSu, sourceEndSu)`. It is independent of `seq`, -which remains the terminal-model source sequence. - -The wire does not send `providerGeneration` or `spanId`. Main supplies its -locally allocated provider generation and derives -`spanId = deliveryToken:sourceStartSu:sourceEndSu`; together with `id`, -client/owner generations, incarnation, token, source/display ranges, and -transform metadata, that forms the immutable admission identity. No later -queue, snapshot, ACK, detach, or replacement path may reconstruct identity -from PTY ID, byte count, or display length alone. - -ACKs are cumulative and coalescible. Main holds the latest settled end per -token and emits at most one batched notification per SSH session every 8 ms, -or immediately when any token frees at least 64 Ki su: - -```ts -{ - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: Array<{ - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - creditedEndSu: number - }> - } -} -``` - -One frame contains at most 64 latest-value entries; another turn handles the -remainder. Replacing a queued entry for the same token is lossless because the -value is cumulative. ACK frames use the reserved control lane and never one -frame per data frame. - -The relay normally accepts an entry only from the owning dispatcher client and -only when `creditedEndSu` is a finite safe integer satisfying -`previousCreditedEnd <= creditedEndSu <= sentEnd`: - -```text -if creditedEndSu > previousCreditedEnd: - creditedEnd = creditedEndSu -``` - -Negative, non-finite, unsafe, fractional, over-credit, wrong-client, wrong-PTY, -unknown-token, and stale-token values are rejected and counted diagnostically; -they never clamp into valid credit. Duplicate and regressing values are -no-ops. Token generation, not PTY ID reuse, defines the credit lifetime. - -The writer callback is not guaranteed to run before main processes and ACKs an -accepted frame. If an ACK reaches the relay while that exact same-token send -reservation is pending, the ledger records only its exact source-end boundary; -`sentEndSu` and `creditedEndSu` do not advance yet. Successful send settlement -commits the boundary and immediately applies the reservation. Failed -settlement rolls back the send but retains the reserved boundary, so the next -send must reproduce that exact boundary and cannot admit a shorter or later -slice. Cross-token, interior-boundary, and excessive early ACKs remain errors. - -Explicit cancellation is a request so main receives proof: - -```ts -// main -> relay -{ - method: 'pty.cancelDelivery', - params: { - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number - } -} -// result -{ canceled: true, sentEndSu: number, creditedEndSu: number } -``` - -The relay validates client/PTY/token ownership, changes the token to `closing`, -removes its cursor and activation/exit fences, then responds through the control -lane. Duplicate cancellation of the same recently closed token is idempotent. -Main may discard open obligations only after this response drains back through -the mux or the client-generation close proves equivalent cleanup. - -Every relay-initiated token close emits this metadata-only control -notification before the token record is forgotten: - -```ts -{ - method: 'pty.deliveryCanceled', - params: { - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - reason: string, - sentEndSu: number, - creditedEndSu: number, - remainingStartSu: number, - remainingEndSu: number, - replacementDeliveryToken?: string - } -} -``` - -It covers supersession, activation/exit timeout, reconnect-grace expiry, and -explicit delivery cancellation. `remainingStartSu == creditedEndSu` and -`remainingEndSu == sentEndSu` state the exact source interval still unsettled -at the relay. If the sink cannot drain the proof, its generation close is the -proof. Without a replacement, main cancels the matching remaining obligations -and schedules restore/reattach. With a replacement, it keeps those obligations -in `transferring` until replacement `pty.data` and its fence prove exact contiguous -coverage, then atomically transfers the covered suffix. Any prefix already -included in the proved model checkpoint is canceled as superseded without -re-ingestion; an uncovered or mismatched remainder cancels with -`restoreRequired` instead of being credited. Every source subrange therefore -settles once. Stale generations are ignored. - -### Activation, replay, and idempotent spawn - -Token creation and response publication are one fenced operation. The exact -activation metadata carried by V1 spawn and attach responses is: - -```ts -type PtySourceReceivingActivation = Readonly<{ - status: 'pending' - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - checkpointSourceEndSu: number - recoveryEndSu: number -}> - -type SpawnResult = { - id: string - incarnationId: string - sourceActivation?: PtySourceReceivingActivation -} - -type AttachResult = { - incarnationId: string - replay?: string - sourceActivation?: PtySourceReceivingActivation - sourceRecovery?: - | PtySourceReceivingActivation - | Readonly<{ status: 'restoreRequired'; reason: string }> -} -``` - -The dispatcher places the metadata-only response on the response lane and -invokes the request's `onResponseSettled` callbacks only from the exact writer -settlement. Success clears relay activation; failure cancels a fresh token or -leaves the exact recovery attempt available to its proved replacement. -Enqueue, `write(true)`, and `write(false)` alone are not activation proof. - -The main mux supplies the receive fence. Spawn and attach requests register a -synchronous `beforeResolve(result)` hook in their pending-request record. -`handleResponse` parses the whole response and provisionally installs -`sourceActivation` before resolving the Promise. The lease captures the exact -prior receive state, candidate token, and a private ordered list of same-turn -source frames. Spawn and non-recovery attach commit only after operation-ID -result, incarnation, claimed-owner, attach identity, stale-attempt, and exit -validation. Reconnect first validates the owner, incarnation, token, -checkpoint, and recovery end, installs the exact private quarantine, then -transfers the lease to its private sink before awaiting the body/completion -fence. That transfer cannot update ordinary provider listeners or -`livePtyIds`, and it cannot reach the model, runtime, desktop, or remote -consumers. Final commit switches future exact-token frames to ordinary -publication only after the body, fence, model admission, and activation -validation succeed. Any failure before transfer -retires the candidate and drops its held data, restores only the nearest -non-retired predecessor, then waits for a valid `pty.cancelDelivery` response -from the originating mux. After transfer, recovery cancellation or -provider-generation close owns cleanup; rollback is no longer authoritative. -It cannot erase a newer revision or cancel a newer token. An identical -already-committed activation is an idempotent no-op; an identical provisional -activation is stale. Fresh-spawn rollback that cannot prove cancellation -surfaces `execution_owner_unavailable` rather than claiming the old token -retired. - -The receive cursor starts at `checkpointSourceEndSu`, not -`recoveryEndSu`. Source-bearing notifications for the installed identity may -then advance only contiguously. Frames through `recoveryEndSu` stay -quarantined as recovery; later frames stay held until the exact completion -fence commits. Unknown or mismatched source identities are rejected before -`livePtyIds`, runtime, renderer, or source obligations change. Exact -source-bearing frames carry their immutable `ptyIncarnation` directly and do -not invoke or mutate the legacy incarnation resolver. - -Every exact-token private frame advances a monotonic observed-source high-water -before quarantine retention or `restoreRequired` checks. If exact exit arrives, -main wakes the recovery fence and seals the private lease immediately. It may -publish exit only after the held contiguous prefix is admitted, or after -`pty.cancelDelivery` proves `sentEndSu` covers that high-water and the accepted -checkpoint remains the credited end. Capacity-rejected and post-restore frames -therefore cannot disappear below the cancellation proof. - -Output produced while a token is activating is retained by its bounded shared -cursor and can pause the native PTY; it is never sent early. Output arriving -after main's synchronous install but before authority commit is bounded by the -provisional lease and is not visible to runtime, desktop, remote consumers, or -`livePtyIds`. Main drops unknown-token notifications before any side effect. - -Before issuing attach or any same-client replacement, main serializes each -`(providerGeneration, appPtyId)` through a migration fence: freeze new -old-generation intake, cancel queued-but-unstarted model entries, await the one -in-flight emulator callback, then record the last completed receipt. A later -overlapping reconnect preserves and awaits an earlier fence for the same PTY; -an empty newer generation cannot expose the earlier checkpoint. Before the -deadline, every old callback remains owned by its admission and generation -until raw completion settles. Stale-owner retry invalidates the checkpoint -value but retains the same fence, then requests restore after it settles. If -the 10-second migration deadline expires or the callback fails, admission -checks the exact active migration before its generation-fatal default. Main -cancels only that admission, detaches that PTY's old headless model, prefers -provider/renderer snapshot restore, and requests `checkpointUnavailable`; -recovery reports `restoreRequired` instead of replaying a guessed gap. The -shared mux, sibling PTYs, filesystem, and Git traffic remain live. Outside an -active exact-PTY migration, the same callback failure still closes its provider -generation. Late raw completion stays on the detached model and cannot advance -the canceled source checkpoint. - -Attach sends `sourceRecovery: { status: 'checkpoint', clientGeneration, -ownerGeneration, ptyIncarnation, deliveryToken, acceptedSourceEndSu }`. A -queued, in-flight, or merely rendered span is not a checkpoint. Without a -proved fence it sends `{ status: 'checkpointUnavailable' }`. - -The relay accepts the checkpoint only for the same incarnation and within -`[oldCreditedEndSu, retainedLiveEndSu]`. It never clamps a value forward, -trusts a value beyond sent/retained data, or replays freed pre-credit source; -an invalid or uncovered checkpoint cancels the attempted delivery and returns -the token-free `restoreRequired` arm. The canceled record remains installed and -cancellation-authoritative until that metadata response settles. Settlement -deletes it only if the delivery map still points to the exact record and wakes -capacity; a retry can then mint a fresh token. It does not activate a -subscription or stream a partial gap; main either restores an authoritative -model generation before retry or surfaces the gap. - -The reconnect coordinator consumes `restoreRequired` and owns snapshot-backed -replacement. A generic existing-session provider reattach has no such recovery -owner, so it maps `restoreRequired` to `SSH_SESSION_EXPIRED` instead of -returning an outputless live PTY; the existing renderer fallback may then -replace that stale session. - -Recovery bodies use the ordinary source-bearing `pty.data` schema. The new -token initializes `sentEndSu = creditedEndSu = checkpointSourceEndSu`, and -the retained gap covers exactly -`[checkpointSourceEndSu, recoveryEndSu)`. Each frame consumes the normal -window and bounded model admission; there is no separate recovery body method, -stream ID, or replacement-only snapshot kind on this SSH wire. - -Only after the last recovery `pty.data` writer callback succeeds does the relay -attempt this metadata-only control notification: - -```ts -{ - method: 'pty.recoveryComplete', - params: { - id: string, - clientGeneration: number, - ownerGeneration: number, - ptyIncarnation: string, - deliveryToken: string, - checkpointSourceEndSu: number, - recoveryEndSu: number - } -} -``` - -`recoveryCompletionPending` permits one admitted attempt at a time. If control -capacity rejects it before admission, the record retains both range ends and -the capacity callback retries; repeated capacity signals cannot enqueue -duplicates. Only the completion notification's successful writer settlement -clears recovery state and permits buffered live `pty.data`. A failed -settlement leaves recovery fenced for exact owner replacement or cleanup. - -Notification-style `pty.replay` remains the legacy attach compatibility path. -`pty.serialize` remains its existing bounded metadata response; neither is a -second V1 source-recovery body channel. - -Main quarantines the entire candidate recovery transaction until it can prove -the fence. The first source-bearing recovery range must start at the accepted -checkpoint, every later range must be exactly contiguous, and the final end -must equal `recoveryEndSu`. Empty recovery is valid only when the checkpoint -already equals `recoveryEndSu`; main retains that end as the required start of -the first later live frame even after activation. The first live frame and -every successor must also be exactly contiguous. Gap, overlap, incomplete -suffix, missing body, bad empty recovery, or bad live handoff reaches neither -the model nor desktop projection. - -A failed or stale recovery attempt cancels only its own replacement token. -It never sends cancellation on a newer mux, clears a newer checkpoint, shuts -down the physical PTY/process, deletes PTY ownership, or expires the owner -lease. A valid matching token-scoped cancellation proof is applied locally and -leaves the lease detached and retryable. If the current attempt cannot publish -cancellation or local proof application rejects, the session fails closed: -it closes the exact provider generation with -`ssh_source_recovery_cancellation_failed`, disposes that generation's mux, -provider, ACK/cancellation publishers, provisional lease, and registration -once when still provisional, or its transferred activation state after -quarantine ownership begins, and reconnects without clearing physical PTY -ownership. If the attempt is already stale, it performs no cleanup against the -replacement. A fresh -relay that no longer retains the cached owner returns typed error `-32041`; -main clears only the cached owner/checkpoints and retries `pty.openClient` -exactly once without `resume`. - -Idempotent agent-session spawn has two layers. The cached -`agentSessionCreateOperationId` promise returns only the physical PTY -identity/outcome. After that promise resolves, every current, non-stale outer -`pty.spawn` request creates its own requesting-client subscription and -activation fence. Creating a token for an existing -`(clientId, clientGeneration, ptyIncarnation)` is one atomic supersession -transaction: create the replacement cursor first, move the old token to -`closing`, cancel its outstanding activation/exit work, emit -`pty.deliveryCanceled(reason='superseded')`, and return the old token in the -new response. The cancellation names the replacement token and exact remaining -old span. Main transfers that span only after matching recovery completes; -otherwise it cancels it and restores. The pair therefore has at most one -active token and no range is both canceled and replayed as new model input. A -retry served from the cache receives one fresh token, never a duplicate live -subscription. If the client becomes stale after physical commit, no token -survives; the next retry can subscribe to the retained PTY. - -## Relay output architecture - -Split policy from lifecycle and transport: - -- `pty-source-credit-ledger.ts`, `pty-source-credit-record.ts`, and - `pty-source-replay-index.ts` own cumulative token state, immutable retained - source records, exact replay ranges, rotation, and reclamation. -- `relay-pty-source-publication.ts`, - `relay-pty-source-activation.ts`, - `relay-pty-source-send-scheduler.ts`, and - `relay-pty-source-exit-publication.ts` own activation/restore settlement, - 16 Ki-su publication, recovery, exit barriers, budgets, and subscriber - scheduling. -- `dispatcher-client-writer.ts` owns the one writer per sink, activation - fences, lane queues, drain callbacks, and close settlement. - -`PtyHandler` retains PTY lifecycle, the 8 ms initial/1 ms continuation cadence, -immediate interactive/transformed publication, replay tail, streaming -transform state, and idempotent native `pause()`/`resume()`. It publishes -immutable source spans rather than broadcasting notifications. - -### Cumulative span ledger - -Each PTY ledger is append-only until every cursor has passed a span: - -```ts -type RelaySourceSpan = { - spanId: number - sourceStartSu: number - sourceEndSu: number - data: string - splittable: boolean - retainedBytes: number -} - -type PtyDelivery = { - state: 'activating' | 'recovering' | 'active' | 'sealed-unsettled' | 'closing' | 'closed' - clientId: number - clientGeneration: number - ownerGeneration?: number - deliveryToken: string - cursor: { spanId: number; displayOffset: number; sourceOffsetSu: number } - sentEndSu: number - creditedEndSu: number -} -``` - -Different subscribers may stop at different offsets in one span. A cursor -contains both display and source offsets, so no global chunk split can lose or -repeat a suffix. Data storage may coalesce adjacent splittable spans for a -frame or slice one span per cursor; the immutable source boundaries and -cumulative ends do not change. Reclamation requires every live, activating, -sealed-unsettled, closing, or reconnect-grace cursor to pass the complete -span. Native process exit seals admission but does not weaken reclamation. - -The transform publisher cuts source input into scalar-safe pieces, never -between a surrogate pair, and records each display result against its source -interval. An untransformed span may be 16 Ki su and is sliced per sink. -A transformed span is indivisible after publication, so its publisher reduces -the source slice until the encoded span is at most 8 KiB, below the token -window, and no larger than the sink's empty non-reserved capacity. An -implementation that cannot stream a transform must pause before the hard limit -and request model restore; it may not over-credit, truncate, or send an -oversized or permanently inadmissible frame. - -The existing 100 Ki-su replay-tail policy and the chunked -`RecentPtyOutputBuffer` representation from `79ec57d04` remain. -`PtySourceReplayIndex` now provides the bounded parallel source-range index -without restoring the former rolling-string re-slice. Legacy replay still -materializes the same display tail. V1 restore additionally preserves -incarnation, exact source ranges, and transform metadata. Index eviction -follows the buffer's exact retained prefix and never fabricates a mapping; the -existing append-path equivalence/performance contracts remain in force. - -### Scheduling and fairness - -The relay scheduler uses deficit round-robin over PTYs, then rotates subscribed -clients for the selected PTY. One scheduling turn admits at most: - -- two PTY frames; -- 32 Ki su; -- 2 ms of scheduler work. - -Requeue work with `setImmediate` after any limit. Input and control requests can -therefore interleave with output. A recent-input PTY may move to the front once -per round, but repeated interactive classification cannot consume another -PTY's quantum. Preserve the existing bounded interactive fast path: after any -already-queued liveness/control frame, if its token is active, has no earlier -backlog, remaining window covers the exact slice, and the writer admits it -without reserve use, publish one echo frame of at most 4 Ki su immediately -instead of waiting for the 8 ms timer or DRR. Allow at most one such frame per -input epoch; it advances the same cursor once and then yields to normal -priority selection. - -For each live/interactive candidate, all gates must be open: - -```text -frameSourceLengthSu <= windowSu - (sentEndSu - creditedEndSu) -client writer admits PTY bytes without consuming its control reserve -token state == active -``` - -For a splittable span, the admitted slice is at most -`min(16 Ki su, remainingWindowSu)`. An indivisible transformed span is admitted -only when its entire source length fits. There is no one-frame window -overshoot. Dispatcher admission creates one pending send reservation. A -`write(false)` still gives Node that frame exactly once, but the cursor and -`sentEndSu` commit only on successful writer settlement; failure rolls back -the reservation. No later ordinary frame is admitted meanwhile. Unsent frames -remain ledger-owned. Liveness, control, interactive PTY, ordinary source PTY, -and bulk are reconsidered on every writer turn. Recovery and live output use -the same source-bearing `pty.data` producer; recovery additionally requires -`token state == recovering`, exact continuity, byte capacity, and -remaining-window capacity. - -The V1 source owner and additional subscriber projections use independent -publication transactions: -`projectPtyDataToMatchingClients`/`projectPtyExitToMatchingClients` exclude -the `source-owner` and reserve each remaining client separately. If one -additional subscriber cannot reserve bounded writer/publication capacity, -close only that client and continue the healthy subscriber and source-owner -sends; never pause or tear down the native PTY solely for that projection. A -legacy-fallback primary is different: it remains a required bounded-legacy -backpressure participant and is not evicted for saturation. Constructor -position still grants no session-owner role. - -Pause when every remaining delivery is blocked or either retained-byte hard -cap is reached. Resume only when: - -```text -perPtyRetainedBytes <= 1 MiB -relayRetainedBytes <= 48 MiB -at least one delivery can advance, or no delivery remains -``` - -Both predicates are required; the per-PTY low water cannot immediately resume -while the relay-wide budget remains exceeded. Calls are idempotent because a -last `node-pty` callback may re-enter pause/exit handling. - -### Exit - -On native exit, seal the PTY's output stream after publishing the last ingress -emissions. The source-owner delivery changes to `sealed-unsettled`; additional -subscriber exits use their independent bounded projection transactions: - -1. write all preceding data within normal window and drain rules; -2. once that data is accepted by the sink, write `pty.exit` without waiting for - the final data ACK; -3. record the exit write callback as `exitPublished` only on explicit success; - on error, generation-close/cancellation proof owns cleanup. Retain the - delivery, token ledger, cursor, cumulative ACK state, and timeout while any - `[creditedEndSu, sentEndSu)` suffix remains; -4. close only when the outstanding suffix becomes terminal through cumulative - ACK application at the relay, exact transfer to a replacement, explicit - cancellation, or client-generation close proof. - -Never force a final tail past the credit window. An additional subscriber that -cannot admit its projected `pty.exit` is detached without changing the source -owner. For the session owner, cancel only this PTY token after its bounded -deadline, keep the sealed record until ACK, transfer, cancellation, or -generation-close proof, and do not tear down unrelated PTYs. A late valid ACK -against a sealed token remains valid and may complete cleanup. Sealed records -are excluded from the 50-live-native-PTY spawn admission count but remain -charged to retained-data budgets. Relay disposal generation-closes all -records and clears their scheduled work exactly once. Exit listeners and -native PTY disposal run at physical exit; logical delivery cleanup waits for -proof. - -Main has a matching receive-exit barrier. Receiving `pty.exit` seals the token -against later data but does not immediately call `runtime.onPtyExit`, retire -the headless emulator, clear desktop ranges, close remote subscriptions, or -send renderer exit. It retains every preceding model receipt, projection ID, -remote mapping, and ACK publication record. In order, it: - -1. waits for preceding model receipts; -2. publishes or exactly transfers committed desktop ranges and required - remote mappings; -3. advances/queues the terminal cumulative source ACK; -4. then invokes runtime and renderer exit cleanup while retaining ACK - publication state until success or generation-close proof. - -The barrier has the same charged 30-second deadline. At expiry, main first -sends token-scoped `pty.cancelDelivery` and waits up to 10 seconds for its -proof without applying it. If the exact barrier/generation still owns the -exit, main transfers published desktop projections to -`ssh-exit-delivery-canceled`, commits the proof to reclaim source spans, -prepares exit once, rechecks generation ownership, finalizes exit, rechecks -again, then closes the projection. This order prevents proof commit from -reclaiming a span before its published projection transfers. - -If provider-generation close wins while proof is pending, it rejects and -removes the barrier plus its preparation ownership; cancellation ownership is -operation-local rather than retained in a process-lifetime tombstone. The late -proof performs no transfer, commit, preparation, final exit, or projection -close. If cancellation publication or proof fails while the barrier remains -current, main closes the provider so generation cleanup proves cancellation; -unrelated PTYs are not torn down on the ordinary successful timeout path. A -late emulator callback cannot mutate the reset generation. Thus relay sink -ordering and main asynchronous model ordering are both proved. - -Provider generations come from one process-wide monotonic allocator and are -never reused. Main rejects closed generations through sorted, merged inclusive -ranges rather than one retained entry per reconnect. Sequential closes compact -to one range; out-of-order closes preserve exact live-generation gaps until -those generations close, then merge. This keeps stale-generation rejection -exact while retained range count follows live gaps instead of process uptime. - -## Dispatcher and transport drain - -Every dispatcher client is constructed with one bidirectional transport: - -```ts -type SinkWriteResult = 'accepted' | 'saturated' | 'closed' -type SinkWriteSettlement = { ok: true } | { ok: false; error: Error } - -type RelayClientTransport = { - write(data: Buffer, onSettled: (result: SinkWriteSettlement) => void): SinkWriteResult - writableLength(): number - writableHighWaterMark(): number - onDrain(cb: () => void): () => void - pauseReads(): void - resumeReads(): void - close(): void -} -``` - -Adapters map Node `true` to accepted, `false` to saturated, known-dead to -closed, and map the `stream.write(data, callback)` callback's optional error -to explicit success/failure settlement. Only `{ ok: true }` is a publication -fence. A callback error or thrown error closes the client, cancels the -generation, and cannot activate a token, publish ACK state, or release a source -obligation. A saturated or closed transport is never called again for ordinary -traffic before drain/replacement. -One 13-byte keepalive may bypass a saturated epoch; no second liveness bypass -is allowed until that write callback or drain, so the exemption is constant -space rather than an unbounded queue. - -`DispatcherClientWriter` is the only encoder/writer for a client. It owns: - -1. a coalesced liveness lane, at most two 13-byte frames; -2. a FIFO control lane, at most 256 frames and 1 MiB encoded; -3. producer-scheduled interactive PTY, ordinary source PTY, and bulk lanes, - whose frames remain with their producers until the writer admits them. - -It reserves -`min(64 KiB, max(1 KiB, floor(highWaterMark / 4)))` below the stream's -high-water mark for liveness/control. PTY, recovery, and bulk admission all -require -`writableLength + frameBytes <= highWaterMark - effectiveReserve`; the -scheduler reduces source slices and reshapable bulk before publish. The -`fs.streamChunk` wire contract is the exception: its fixed 256 KiB raw chunk -and sequence-offset math are preserved, and the `fixed-bulk` lane admits one -encoded frame only when the producer queue and writable sink are empty and -unsaturated. It then obeys ordinary callback/drain settlement before another -producer frame. No producer writes around these gates. -Every transport accepted for a PTY subscription must expose at least 8 KiB of -empty non-reserved capacity. A lower-capacity subscriber is rejected before -token creation, so an already-published indivisible transformed span cannot -become permanently inadmissible. -Liveness is selected first, then control FIFO, interactive PTY, ordinary source -PTY, and bulk; after one producer frame the writer re-runs selection. The -physical stream is still FIFO—V1 does not claim a second SSH channel—but the -finite burst and byte reserve bound head-of-line delay. Control overflow closes -an additional subscriber; overflow on the negotiated session owner is a -transport failure and reconnects that owner generation. - -Drain registration is one-shot per saturated epoch. `close`, `error`, -`attachClient`/`detachClient`, owner-generation replacement, and dispose -cancel every outstanding write callback and producer fence exactly once. -`setWrite` remains a test/compatibility seam; production reconnect ownership is -the socket/stdin construction plus `attachClient` and `detachClient`. - -In detached relay startup, immediately invalidate the synthetic stdout client -when `stdoutAlive` becomes false. A no-op writer must not look like successful -delivery. The first valid session-owner grant on an `attachClient` -socket/named-pipe client elects the owner; reconnect resume replaces its -generation atomically. Constructor identity and connection order confer no -role. - -One `DrainAwareStdoutWriter` owns every byte written to `process.stdout`, -including the initial relay sentinel, handshake residue, dispatcher frames, -and connect-mode forwarding. `runConnectMode` performs -`sentinel -> residue -> socket data` through that writer and pauses the source -socket after stdout saturation; it never mixes ad-hoc `stdout.write()` with -`sock.pipe(stdout)`. The same state contract wraps Unix sockets, Windows named -pipes, and the initial stdio client. - -Keepalive frames are independent of PTY credit, use reserved capacity, and have -the single-frame saturated-epoch exemption above. A decoder self-pause or -local writer-saturation epoch suspends dead-link evaluation. Resume calls the -same `rebaseHealthClocks(now)` used after a wake gap: set `lastReceivedAt` and -every existing `unackedTimestamps` entry to `now`, then allow a full timeout -window. Rebasing only received-data age is insufficient because the -outstanding-header-ACK conjunct would remain stale. During a suppressed -interval, keepalive intent coalesces behind one outstanding probe and the -header-ACK timestamp map has a hard entry cap; at 4095 entries it stops -ordinary main-to-relay admission and reserves the final coalesced entry for -cancel/liveness. -No pause fabricates an ACK. Tests sustain both states past 20 seconds and prove -zero reconnect oscillation or timestamp growth. - -## Main-process credit ownership - -`SshRelaySession.wireUpPtyEvents` remains the provider listener but stops -calling `runtime.onPtyData` and `webContents.send` itself. It validates the -provider generation/token and hands each notification once to a main-only -`SshPtyOutputDelivery` intake installed by `src/main/ipc/pty.ts`. This intake is -the only SSH owner allowed to ingest runtime output, mutate delivery state, or -send `pty:data`. - -### Main cumulative ledger - -The intake appends immutable wire spans: - -```ts -type PtySourceDeliveryIdentity = Readonly<{ - id: string - providerGeneration: number - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string -}> - -type PtySourceSpan = PtySourceDeliveryIdentity & - Readonly<{ - spanId: string - sourceStartSu: number - sourceEndSu: number - displayStart: number - displayEnd: number - data: string - splittable?: boolean - indivisible?: boolean - transform: { - transformed: boolean - rawLengthSu: number - scalarSafe: boolean - } - }> - -type SourceSpanRecord = { - span: PtySourceSpan - obligations: Map -} - -type SpanObligation = - | { state: 'open' } - | { state: 'transferring'; to: ConsumerId; reason: string } - | { state: 'settled'; reason: string } - | { state: 'transferred'; to: ConsumerId; reason: string } - | { state: 'canceled'; reason: string } - -type TokenAckPublication = { - obligationsTerminalEndSu: number - ackQueuedEndSu: number - ackPublishedEndSu: number -} - -type DesktopProjectionSpan = Readonly< - Omit & { - splittable: boolean - projectionSemanticsId: string - } -> -``` - -The ledger is separate from `PendingPtyData`. Queue merge, split, remainder, -drop sentinel, query salvage, thinning, and interactive bypass receive no -delivery token and cannot rewrite source spans. At SSH intake, -`SshPtyLegacyProjectionLedger` keeps per-PTY cursors and admits the complete -immutable `DesktopProjectionSpan`; it never receives only `data` plus source -length. -`PendingPtyData` continues to store display batching fields plus an opaque -projection admission ID, not mutable source accounting. - -`projectionSemanticsId` addresses an immutable per-admission record containing -the accepted chunk's ordered main-authoritative terminal facts and the -projection queue's before/after bounded scanner snapshots. This preserves -current main's dropped-output DEC mode 2031 subscribe/withdraw salvage without -putting mutable scanner state in the source ledger. A source gap resets the -recorded cross-chunk state before later bytes are admitted. - -When the data queue sends a display prefix, drops a pending entry, or replaces -it with salvage, it asks the range queue to consume the same operation. The -range queue—not mutable `rawLength` fields—returns the exact source length and -transform metadata for the renderer payload and consumer obligation. -Admission is transactional: - -1. reserve model bytes and create the source span; -2. stage the main terminal facts and before/after projection scanner snapshots; -3. reserve the exact desktop range by `spanId` and `projectionSemanticsId`; -4. enqueue display data and publish only after every reservation succeeds; -5. commit model ownership, main facts, and projection admission together. - -If model reservation fails, remove every uncommitted reservation. If -projection admission or `webContents.send` fails, roll back the uncommitted -range selection and restores the prior projection scanner snapshot without -publishing staged facts. After model commit, main facts remain published once -and a send failure atomically transfers the desktop obligation plus committed -projection state to a model-restore marker. Queue merge/split preserves the -ordered admission IDs, salvage/drop transfers exact superseded ranges and DEC -mode 2031 decisions, renderer reload transfers committed desktop obligations -before clearing, and token replacement rejects stale IDs by generation while -exact replacement coverage transfers their ranges. Untransformed source may -split only at a recorded display/source offset; a transformed range stays -indivisible. No rollback fabricates an ACK or destroys a live relay -obligation. Renderer ACK retirement reserves both range and ledger mutations, -validates both, then performs one no-throw commit; neither side becomes -terminal before that commit. - -Development assertions enforce: - -```text -receivedSu = openSu + transferringSu + settledSu + transferredSu + canceledSu -obligationsTerminalEndSu = - largest contiguous end whose required obligations are terminal -ackPublishedEndSu <= ackQueuedEndSu <= obligationsTerminalEndSu -``` - -Only `SshPtyOutputDelivery` may call the V1 cumulative ACK coalescer. The -generic `IPtyProvider.acknowledgeDataEvent(id, delta)` API remains for local -and daemon behavior. Legacy-fallback SSH may still emit its current delta, but -the relay ignores it and no legacy bound depends on it; it is a hard no-op for -negotiated V1 PTYs. At every shared `pty:ackData`, resync, heal, -write-off, drop, salvage, and reload call site, V1 routes renderer display -progress through -`SshPtyLegacyProjectionLedger`: parsing settles exact mapped source ranges and -heal/write-off atomically transfers them. These projection transitions may -advance ledger eligibility already earned by the model, but never emit legacy -`{ id, charCount }` wire traffic or manufacture source progress from a display -count. - -ACK eligibility and ACK publication are separate transitions. Terminal -obligations advance `obligationsTerminalEndSu` without waiting for a write -callback. The coalescer independently queues the latest cumulative eligible -end and advances `ackQueuedEndSu`. The mux write callback advances -`ackPublishedEndSu` and permits cleanup only on explicit success; it never -creates eligibility. A synchronous throw, callback error, or close leaves the -cumulative value queued for a generation-aware retry or reaches cancellation -proof on provider close. Coalescing replaces only with a greater cumulative -value, so retries cannot lose an eligible prefix. - -### Bounded asynchronous model admission - -The charged FIFO per PTY and global scheduler replace the unmetered -headless-emulator `writeChain` contract. `acceptPtyData` returns a Promise -receipt: - -1. before capturing the frame, reserve its charged retained bytes against the - per-PTY and global model-admission budgets; -2. the per-PTY budget covers one full token window and the global budget - matches the relay retained-data cap, so a conforming owner reaches token - backpressure before routine admission denial. If a transient frame still - cannot fit, keep that current decoded frame in a separately charged intake - slot and leave its model obligation open. Enter selective pressure mode: - quarantine at most 1 MiB or 64 later PTY data frames in wire order while - continuing to apply transport liveness, unrelated RPC control, and - cancellation proofs. Keep `pty.exit`, `pty.recoveryComplete`, activation - fences, and other source-ordering lifecycle frames behind all preceding - quarantined data for their token. A same-token cancellation proof may bypass - only by atomically canceling those quarantined obligations first. Do not - admit quarantined data out of order. If the reserve fills before capacity - returns, pause reads and start a 10-second provider-close deadline; -3. enqueue only after capacity is owned. Snapshot all consumer memberships and - allow bounded desktop/remote fan-out at that point; neither projection - progress nor queue ownership settles the required model obligation. - Preserve per-PTY order while allowing fair turns across PTYs; -4. resolve the receipt only from the emulator write callback. Then release the - queue charge, settle the model obligation, and advance eligible credit; -5. resume admission only after both low waters hold. Rejection cancels the - token and schedules restore; it never passes through the current - best-effort swallowed-error path. - -The relay writer rechecks control priority after every PTY frame, so the -receive reserve needs to cross only a bounded already-written PTY burst, not an -unbounded producer stream. If no control can be reached within the reserve and -deadline, provider close supplies generation cleanup proof. Selective pressure -never settles, drops, or reorders a data frame and never lets a model stall -silence cancellation indefinitely. - -Provider close, token supersession, PTY exit, and runtime disposal cancel -queued-but-unstarted entries and reject their receipts exactly once. An -in-flight emulator callback owns its entry until completion or failure and -must pass its captured token/generation check before committing. Reconnect and -supersession use the migration fence above rather than letting that callback -race a replacement checkpoint. Failure dispatch consults the exact -`(providerGeneration, ptyId)` migration owner before invoking generation close; -this does not suppress the ordinary non-migrating failure policy. The old -Promise chain may remain as the per-PTY execution primitive only after each -link is charged by this scheduler; it is no longer an unbounded owner. - -### Desktop, mobile, web, and agent policy - -Consumer membership is snapshotted when each span arrives: - -- The main terminal model is always required. - `runtime.acceptPtyDataBounded` returns the asynchronous receipt Promise - above; it resolves only after the emulator accepts the span. - Status and agent-session observers derived from that model add no duplicate - obligation. -- The desktop renderer is a recoverable projection. Parsing settles its - obligation. Hidden thinning, reload, destroyed-window, send failure, - pending-cap replacement, or delivery heal atomically transfers the - obligation to the already-accepted main model and emits a model-restore - marker before settlement. A transfer requested before the model receipt - remains pending and commits only when that receipt succeeds. Once the model - receipt exists, desktop obligations - remain tracked for main-to-renderer bounds but are not required for upstream - ACK eligibility. -- A mobile/web/raw agent terminal subscriber that negotiated lossless ACKs is - required while attached and settles independently from its bounded delivery - cursor. At its ACK cap it stops sending and remains required; a stall cannot - auto-transfer itself out of upstream backpressure. On explicit detach or - replacement, transfer to a snapshot/resubscribe marker if supported; - otherwise cancel that consumer and close only its stream. -- A legacy or observational remote subscriber is best-effort and creates no - upstream obligation. It receives bounded fan-out and is dropped/resynced on - overflow. - -Current mobile notification replay remains a separate runtime method and -identity space. Its notification epoch and post-local-delivery watermark prove -notification catch-up only; terminal obligations require the stream generation -and immutable encoded-byte/source-range mapping below. - -The token ACK advances when the main model and every currently required -lossless consumer for the contiguous prefix have terminal obligations. A -desktop stall therefore cannot freeze a healthy mobile/web consumer, while an -actually lossless remote subscriber still participates explicitly. - -Each lossless remote stream owns an immutable encoded-byte/source-range ledger: - -```ts -type LosslessRemoteSendRange = { - streamGeneration: string - encodedStartByte: number - encodedEndByte: number - providerGeneration: number - ptyIncarnation: string - deliveryToken: string - sourceRanges: ReadonlyArray<{ - spanId: string - sourceStartSu: number - sourceEndSu: number - indivisible: boolean - }> -} -``` - -Existing `terminal.multiplex` `ackOutput: 1` remains a byte-delta flow-control -mechanism and creates no SSH source obligation. A required source-mapped stream -must separately negotiate `ackOutputSourceRanges: 1`. The server allocates an -opaque `streamGeneration` in the subscribed control frame; every cumulative -ACK carries that generation and `ackedEndByte`. A reused `streamId`, old -client, legacy `{bytes}` ACK, or stale generation cannot settle the new stream. - -The mapping is recorded only when the remote writer accepts that exact encoded -output payload, in the same byte unit used by its ACK window. Batching flushes -on provider generation, PTY incarnation, or delivery-token change. When one -encoded frame contains multiple same-identity source spans, it records their -ordered composite list rather than collapsing them into a proportional range. -An ACK must be a finite safe integer in -`previousAckedEndByte <= ackedEndByte <= lastAcceptedByte`; excessive values -are rejected and counted, never clamped into full settlement. An in-range -partial-frame ACK advances the byte watermark and releases exactly that byte -budget, but retains the covering frame's immutable source mapping and settles -no source units. Only complete recorded frame boundaries settle their exact -ordered source ranges. UTF-8 width, JSON escaping, `rawLength`, and display -transforms are never converted proportionally. - -The same attached stream may cross a source-token rotation without transferring -its older unsettled frame. Admission requires the same PTY ID, provider -generation, and incarnation; strictly greater client and owner generations; a -different delivery token; and exact source/display continuity. The ledger then -rebinds future frames to the new identity while older frames retain their own -immutable identity and settle at their encoded boundaries. Equal, lower, -partially advanced, cross-provider, cross-incarnation, or same-token rotations -are rejected. Before stream replacement, snapshot recovery, or detach, create -the replacement owner and atomically transfer every remaining mapping; -otherwise cancel that consumer and its bounded stream. Old stream generations -cannot settle new mappings. - -The main registry stores the immutable source identity with each admitted -remote mapping, not only its `spanId`. A replacement reservation moves the -named consumer obligations to `transferring`; only an accepted headed or -headless `SnapshotEnd` publication at or beyond the reservation's required -sequence commits them. Stale generation, failed publication, disconnect, or -explicit rollback restores the original live-stream obligation. If -token-scoped cancellation proof has already reclaimed the authoritative span, -late ACK/settle prunes the cached mapping, detach skips absent spans, and a -replacement commit returns false without dereferencing the reclaimed record. -Rollback then deletes the exact reservation and skips already-absent spans. -These paths are idempotent and create no credit. - -ACK-pending output overflow uses the same transaction. For a source-mapped -stream, serialize the bounded snapshot, reserve only immutable spans whose -model-sequence end is covered by its `seq` through -`reserveRemoteTerminalSourceRangeReplacement`, publish every snapshot frame, -then call `commitRemoteTerminalSourceRangeReplacement` against the exact -current stream generation, snapshot source identity, and sequence. Only after -commit may the server trim queued chunks with -`chunk.seq <= snapshot.seq` and clear the overflow marker. Serialization, -publication, or commit failure calls -`rollbackRemoteTerminalSourceRangeReplacement`, preserves the live -obligations, reports the stream error, and detaches that stream. One -`ackRecoverySnapshotInFlight` flag bounds the operation to single-flight. -At the encoded-byte ledger, snapshot replacement commit is itself idempotent: -duplicate commits and commits after close are no-ops, preserve cumulative byte -coordinates, and remove only source mappings covered by the authoritative -snapshot sequence. - -The stall policy is deliberately split. Desktop-only parse failure reaches its -bounded projection cap, transfers to model restore, and lets upstream credit -continue. A stalled model receipt or attached lossless remote has no automatic -projection transfer: its required obligation stays open, the token window -exhausts, and the relay pauses the native PTY when every remaining delivery is -blocked. Only an explicit, capability-proven snapshot/resubscribe transition -can transfer a lossless remote obligation. - -### Exactly-once lifecycle - -`settle`, `beginTransfer`, and `cancel` are compare-and-set operations from -`open` for both the upstream token span and each consumer obligation. -`transferring` becomes `transferred` only after the named replacement fence or -becomes `canceled` on mismatch/failure; duplicates are no-ops with diagnostics. -Required obligations becoming terminal advances -`obligationsTerminalEndSu`. That transition makes the cumulative ACK eligible; -it does not wait for ACK publication. The coalescer advances `ackQueuedEndSu`, -and only the later mux write callback advances `ackPublishedEndSu` and allows -record reclamation. A token span begins transfer only when the relay atomically -moves reconnect-grace ownership to a new token and the attach response -declares the new cumulative boundary, or cancels after -explicit/connection-close proof. The transaction creates the replacement -owner before marking source `transferring`; only exact recovery-complete -coverage terminally transfers it. - -| Event | Mandatory transition | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| spawn/attach response decoded | install provisional `sourceActivation` in synchronous `beforeResolve`; hold same-turn data privately; spawn commits after full operation validation, while reconnect transfers into an exact private recovery quarantine after contract validation and commits after fence/admission | -| invalid-checkpoint `restoreRequired` | keep the canceled relay record through response settlement; retire only the still-current record, wake capacity, and permit a fresh-token retry | -| model accepts; desktop/mobile parses | settle that consumer | -| hidden thinning, empty transform, pending-cap salvage | preserve ordered terminal facts/scanner state, then transfer desktop to model and emit restore marker | -| renderer reload/destroy/send failure/heal | transfer all desktop obligations to model before clearing queue/accounting | -| pane closes while provider/token remains live | transfer recoverable views, then request token cancel if no required consumer remains | -| PTY exit | relay seals token; main retains prior receipts/projections/remotes; runtime/renderer exit only after terminality or bounded cancellation proof | -| provider replacement/reconnect | close old client generation; transfer only exact ranges proven by replacement recovery, otherwise cancel and restore | -| relay `pty.deliveryCanceled` | without replacement, cancel matching remainder and restore; with replacement, enter `transferring` pending coverage | -| same-client token supersession | create replacement first, transfer exact covered remainder after recovery, cancel any uncovered range | -| empty or non-empty recovery completion | require checkpoint-to-`recoveryEndSu` continuity, retain that live-start anchor after activation, then admit only an exactly contiguous live frame | -| exact exit during private recovery | wake the fence, reject later same-token admission, and publish exit only after the held prefix is admitted or exact cancellation proof retires it | -| recovery completion lacks writer capacity | retain range/fence state and retry on capacity; keep at most one admitted completion attempt | -| stale/overlapping recovery attempt | cancel only that attempt's token; never mutate the replacement mux, checkpoint, PTY ownership, physical process, or owner lease | -| current recovery cancellation publication/proof fails | after quarantine ownership transfer, close the exact provider generation and release its mux, provider, publishers, and registration once; retain physical PTY ownership for reconnect | -| partial remote cumulative byte ACK | advance/release exact byte credit; retain the covering source frame and obligation until a complete recorded boundary | -| contiguous higher-generation source token | keep the stream attached, bind later frames to the new identity, and preserve unsettled older-token frame mappings | -| remote snapshot replacement | reserve immutable span IDs/ranges, commit only after current-generation `SnapshotEnd` coverage, otherwise roll back | -| snapshot commit after proof reclaimed a span | reject commit without dereference; rollback removes the exact reservation and skips absent spans; later settle/detach prunes cached mappings | -| remote ACK-pending overflow snapshot | reserve covered spans, publish, commit exact snapshot identity/sequence, then trim; rollback and detach on failure | -| late remote detach after token cancellation | prune already-reclaimed mappings and detach idempotently without dereference or new credit | -| exit timeout receives cancellation proof | while generation-current, transfer projections, commit proof, prepare/finalize once, and close projection with generation checks between mutations | -| provider generation closes during exit proof | reject/remove barrier ownership; a late proof cannot commit, prepare, finalize, or publish exit | -| provider generations close over process lifetime | merge exact closed ranges; preserve out-of-order live gaps and reject every closed generation without one tombstone per reconnect | -| source tokens cancel repeatedly for one PTY | retain only the latest ordered canceled token for that PTY; replace at the next cancellation and clear at the next activation, PTY exit, or provider teardown | -| explicit live-token reset | `pty.cancelDelivery` response proves relay cancellation before local discard; failure closes the provider transport | -| relay/client dispose | relay cancels token/cursors; main cancels only after close-generation proof | - -There is no “abandon live token” transition. A main-side path that cannot prove -settlement, transfer, or relay cancellation must keep the ledger or close the -provider. Delayed renderer/mobile ACKs carry the consumer generation and cannot -settle a new provider generation or reused PTY ID. - -### State-machine pseudocode - -```text -session owner: - none --authenticated claim--> electing(next monotonic generation) - electing --session-grant write success--> active - electing --session-grant write error--> none(close generation) - electing --client close/cancel--> none - active --proved live replacement/tokens to grace--> - active(generation + 1, rotated lease) - active --unexpected client close--> grace - grace --proved resume--> active(generation + 1, rotated lease) - grace --30-second expiry--> none(invalidate lease, cancel grace cursors) - -relay token: - create -> activating - activating --response write success/no recovery--> active - activating --response write success/recovery--> recovering - activating --response write error--> closed(cancel generation) - recovering --completion not admitted--> recovering(retry on capacity) - recovering --completion admitted--> completion-in-flight(single) - completion-in-flight --write success--> active - completion-in-flight --write error--> recovering(exact replacement/cleanup) - recovering --invalid checkpoint--> restore-canceled-awaiting-response - restore-canceled-awaiting-response --exact response settles/current record--> - closed(delete record, wake capacity) - activating --stale/close--> closed(cancel) - activating|recovering|active --same-client replacement--> closing(superseded) - active --native exit--> sealed-unsettled - sealed-unsettled --exit write success--> sealed-unsettled(exit-published) - sealed-unsettled --exit write error--> closing(generation proof) - sealed-unsettled --relay applies suffix ACK/exact transfer--> closed - sealed-unsettled --timeout--> closing(exit-timeout cancellation proof) - active --cancel request--> closing - closing --cancel response/client close proof--> closed - -writer: - writable --write(false)--> saturated --drain--> writable - writable|saturated --close/error/detach--> closed - -main mux writer selection: - any queued liveness --> select liveness - control queued and (ordinary empty or controlsSinceOrdinary < 4) --> select control - ordinary queued --> select ordinary(reset controlsSinceOrdinary) - control queued after ordinary empty --> select control - drain --> writable(re-run the same selection; preserve FIFO inside each lane) - -main obligation: - open --consumer receipt--> settled - open --replacement created atomically--> transferring - transferring --exact replacement coverage fence--> transferred - transferring --missing coverage/replacement close--> canceled - open --relay cancel/close proof--> canceled - -main token span: - open --required consumers terminal--> obligations-terminal - obligations-terminal --coalescer queues cumulative end--> ack-queued - ack-queued --ACK write success--> ack-published(cleanup eligible) - ack-queued --ACK write error--> ack-queued(close/retry) - open|obligations-terminal|ack-queued - --attach boundary + replacement created--> transferring - transferring --exact recovery-complete fence--> transferred - transferring --gap/replacement failure--> canceled - open|obligations-terminal|ack-queued - --cancel response/client-generation close--> canceled - -main receive token: - unseen --beforeResolve--> provisional(cursor = checkpointSourceEndSu, heldData = []) - provisional --same-turn contiguous data--> provisional(append heldData; no projection) - provisional --all spawn/non-recovery attach/exit validation--> - active(commit lease, publish heldData in order) - provisional --validated recovery contract + private quarantine installed--> - receiving-activation(transfer lease to private sink, route heldData by range) - provisional --pre-transfer validation failure--> - rollback(drop heldData, restore active predecessor, request exact cancellation) - rollback --valid cancellation response--> prior exact state - rollback --publication/proof rejects--> fail operation(execution owner unavailable) - receiving-activation --quarantine exact checkpoint..recoveryEnd + complete fence--> - active(final commit, expectedLiveStart = recoveryEnd) - receiving-activation --exact exit--> - exit-sealed(admit held contiguous prefix or prove exact cancellation first) - active --first/next live starts at expectedLiveStart--> active(advance expectedLiveStart) - receiving-activation|active --gap/overlap/bad fence--> canceling-only-this-token - active --pty.exit--> exit-sealed - exit-sealed --prior model/projection/remote terminal--> exit-ack-queued - exit-ack-queued --runtime + renderer exit cleanup--> exited-awaiting-ack-publication - exited-awaiting-ack-publication --ACK success/generation close--> closed - exit-sealed --deadline--> acquiring-exit-cancellation-proof - acquiring-exit-cancellation-proof --proof + generation current--> - transfer-projection -> commit-proof -> prepare-once -> finalize -> close-projection - acquiring-exit-cancellation-proof --generation closes--> - closed(reject barrier; late proof has no authority) - acquiring-exit-cancellation-proof --proof cannot publish while current--> closing-provider - receiving-activation|active --overflow/cancel/close--> closed - -main provider-generation guard: - open(g) --close--> closed-ranges(add [g, g], merge adjacent/overlapping) - event(g in closed-ranges) --> reject(stale generation) - event(g in an unclosed gap) --> validate PTY incarnation normally - diagnostics --> closed range count + allocated-unclosed IDs below high-water - -main canceled-source retirement: - none(pty) --cancel token T--> latest(pty, T) - latest(pty, T) --later ordered cancel U--> latest(pty, U) - latest(pty, T) --frame T--> drop - latest(pty, T) --next activation/PTY exit/provider teardown--> none(pty) - -remote source ledger: - frame-open --partial cumulative byte ACK--> - frame-open(release exact byte delta; retain source mapping) - frame-open --ACK reaches encoded frame end--> frame-settled(settle exact source ranges) - old-token frame-open --contiguous strictly higher client+owner generation/new token--> - old-token frame-open + new-token receiving - receiving --stale/partial generation advance or identity discontinuity--> reject - -remote projection replacement: - live --reserve immutable spans + required SnapshotEnd seq--> transferring - transferring --current generation SnapshotEnd covers required seq--> transferred - transferring --stale/failure/disconnect--> live(rollback) - transferring --authoritative span already reclaimed--> - commit-rejected -> rollback(skip absent span, delete reservation) - live|transferring --authoritative token already canceled--> detached(prune cached spans) - -remote ACK-overflow recovery: - overflowed --reserve snapshot-covered spans--> transferring(single-flight) - transferring --snapshot publish + exact commit--> live(trim through snapshot seq) - transferring --serialize/publish/commit failure--> detached(rollback first) -``` - -```ts -function reserveSshAdmissionAtomically( - token: DeliveryToken, - frame: TokenizedPtyData -): SshAdmissionReservation { - const model = runtime.reservePtyData(frame) - let span - let projection - try { - span = token.ledger.reserveContiguous(frame, model) - span.require('model') - addConsumerObligationsFromCurrentPolicy(span) - projection = projectionLedger.reserve(toDesktopProjectionSpan(span)) - return { model, span, projection } - } catch (error) { - projectionLedger.rollbackIfReserved(projection) - token.ledger.rollbackIfUncommitted(span) - runtime.rollbackPtyData(model) - throw error - } -} - -function acceptSshFrame(frame: TokenizedPtyData): void { - const token = validateActiveProviderToken(frame) - let reservation - try { - reservation = reserveSshAdmissionAtomically(token, frame) - } catch (error) { - cancelTokenAfterAdmissionFailure(token, error) - return - } - const { span, modelReceipt, projectionSpan } = commitSshAdmission(reservation) - try { - projectionLedger.publish(projectionSpan, frame.data) - } catch (error) { - transferDesktopToModelRestore(span, 'renderer-send-failed', error) - } - fanOutToCurrentRemoteConsumers(span) - void modelReceipt.then( - (receipt) => { - span.settle('model', receipt) - commitPendingProjectionTransfers(span) - advanceObligationsTerminalEnd(token) - queueCumulativeAck(token) - }, - (error) => cancelTokenAfterModelFailure(token, span, error) - ) -} - -function retireDesktopDisplayPrefix(id: string, processedDisplayChars: number): void { - const selection = projectionLedger.reserveDisplayPrefix(id, processedDisplayChars) - const transaction = ledger.reserveDesktopSettlement(selection.ranges) - try { - validateDesktopSettlement(selection, transaction) - commitDesktopRangeAndLedgerAtomically(selection, transaction, 'renderer-parse') - } catch (error) { - ledger.rollbackDesktopSettlement(transaction) - projectionLedger.rollback(selection) - throw error - } - advanceObligationsTerminalEndForPty(id) - queueCumulativeAckForPty(id) -} - -function replaceDesktopProjection(id: string, reason: string): void { - for (const obligation of ledger.openDesktopObligations(id)) { - obligation.beginTransferTo('model-snapshot', reason) - } - scheduleModelRestoreAfterReceipts(id, reason) - commitTransfersWhoseModelReceiptsExist(id) - advanceObligationsTerminalEndForPty(id) // pending transfers remain ineligible - queueCumulativeAckForPty(id) -} - -function onAckWriteSettled(token: DeliveryToken, endSu: number, result: SinkWriteSettlement): void { - if (!result.ok) { - closeProviderAfterAckWriteError(token, result.error) - return - } - token.ackPublishedEndSu = Math.max(token.ackPublishedEndSu, endSu) - reclaimPublishedPrefix(token) - maybeCloseSealedDelivery(token) -} - -function onRelaySourceAck(record: RelayDelivery, ack: SourceAck): void { - requireExactDeliveryIdentity(record.identity, ack) - const pendingEnd = record.pendingSend?.span.sourceEndSu - if (ack.creditedEndSu > record.sentEndSu) { - require(ack.creditedEndSu === pendingEnd) - record.reservedAckEndSu = pendingEnd - return - } - applyCommittedBoundaryAck(record, ack) -} - -function retryRelaySourceSend(record: RelayDelivery): SendReservation | null { - const requiredEnd = record.reservedAckEndSu - return requiredEnd === null - ? reserveNextWindowSlice(record) - : reserveExactSameTokenBoundary(record, requiredEnd) -} - -function finishRecovery(candidate: RecoveryCandidate, fence: RecoveryComplete): void { - const expectedStart = candidate.checkpointSourceEndSu - const recoveredEnd = validateExactContiguousQuarantine(candidate.frames, expectedStart) - if (recoveredEnd !== fence.recoveryEndSu) { - cancelReplacementTokenOnly(candidate.identity, 'recovery-coverage-mismatch') - return - } - commitQuarantinedRecovery(candidate) - installLiveStartAnchor(candidate.identity, fence.recoveryEndSu) -} - -async function rollbackProvisionalActivation(lease: ReceiveLease): Promise { - lease.dropHeldDataAndRestoreActivePredecessor() - const proved = await cancelExactTokenThroughOriginatingMux(lease.identity) - if (!proved) throw executionOwnerUnavailable() -} - -function acknowledgeRemoteBytes(stream: RemoteStream, ackedEndByte: number): void { - const delta = stream.validateAndAdvanceCumulativeByteAck(ackedEndByte) - stream.byteBudget.release(delta) - for (const frame of stream.completeFramesThrough(ackedEndByte)) { - settleExactSourceRanges(frame.sourceRanges) - } -} - -function replaceRemoteConsumer(stream: RemoteStream, requiredSeq: number): void { - const reservation = remoteRanges.reserveImmutableSpans(stream, requiredSeq) - publishSnapshotEnd(stream).then( - (publication) => { - if (!remoteRanges.commitIfCurrentAndCovered(reservation, publication)) { - remoteRanges.rollbackSkippingReclaimed(reservation) - } - }, - (error) => remoteRanges.rollback(reservation, error) - ) -} - -async function cancelTimedOutExit(exit: ExitBarrier): Promise { - const proof = await requestCancellationProofWithoutMutation(exit.identity) - if (!exit.isCurrentGeneration()) return - transferPublishedProjection(exit.identity) - commitCancellationProof(proof) - prepareExitOnce(exit.identity) - if (!exit.isCurrentGeneration()) return - finalizeExit(exit.identity) - if (!exit.isCurrentGeneration()) return - closeProjection(exit.identity) -} - -async function recoverAckOverflow(stream: RemoteStream): Promise { - const snapshot = await serializeBudgetedSnapshot(stream) - const reservation = reserveCoveredRemoteSpans(stream, snapshot.seq) - try { - require(publishSnapshotFrames(stream, snapshot).published) - require(commitExactSnapshotReplacement(reservation, snapshot.source, snapshot.seq)) - trimQueuedOutputThrough(stream, snapshot.seq) - } catch (error) { - rollbackSnapshotReplacement(reservation, error) - detachRemoteStream(stream) - } -} -``` - -Token/span validation, charged admission, and every membership obligation occur -before asynchronous model execution. Desktop/remote fan-out may begin after -the admission budget owns the frame, but the model obligation settles only -after the emulator callback. Admission wait leaves it open and pauses reads at -the charged limit; rejection triggers token cancel rather than acknowledging -data the model did not own. Renderer progress is always display-side input to -the range queue and is never interpreted directly as source units. - -## Incremental frame decoding - -The actual main transport contract is extended as follows: - -```ts -type MultiplexerTransport = { - write(data: Buffer, onSettled: (result: SinkWriteSettlement) => void): SinkWriteResult - writableLength(): number - writableHighWaterMark(): number - onWriteDrain(cb: () => void): () => void - onData(cb: (data: Buffer) => void): () => void - onClose(cb: () => void): () => void - pauseReads(): void - resumeReads(): void - close(): void -} -``` - -`waitForSentinel` constructs these hooks from the real `ClientChannel`: -`channel.stdin.write(buf, callback)`, writable length/high-water mark, and -`on('drain')` for output, plus -`channel.pause()/resume()` for relay stdout. This covers ssh2 and -`SystemSshCommandChannel`, whose stdout-facing channel implements the same -Readable contract. WSL child stdin/stdout exposes the same metrics, -callback/drain, and pause/resume adapter. The startup residue is delivered once -before later data. - -`SshChannelMultiplexer` also owns one main-to-relay writer with exactly three -bounded FIFO lanes: - -1. `liveness` for the coalesced probe, including the one saturated-epoch - bypass; -2. `control` for source ACK, cancellation, exit, RPC request, and response - frames; -3. `ordinary` for `pty.data`, including PTY input/paste traffic. - -The scheduler rechecks priority after every frame and whenever drain reopens -the writer. Liveness wins first. Control wins over queued ordinary traffic -until four control frames have been selected while ordinary remains queued; -the next selection must be one ordinary frame and resets the counter. FIFO is -preserved within each lane. All non-bypass writes still wait for their -per-write callback and `onWriteDrain` after saturation. Producer-side bulk -shaping and fixed filesystem-frame admission remain with their existing -owners; they do not create a fourth mux-writer lane. Thus control cannot remain -behind an old ordinary backlog after drain, ordinary input cannot starve under -a sustained control stream, and V1 ACK settlement has a concrete write fence. - -On the relay, the initial client uses `process.stdin.pause()/resume()` and each -accepted Unix-socket/named-pipe client uses `socket.pause()/resume()`. -`attachClient` accepts those hooks; `detachClient` releases them. Handshake -decoders retain synchronous first-frame behavior and transfer both exact -residue and pause ownership to the dispatcher. - -Both protocol copies then use this state machine: - -1. `feed()` appends buffer views without copying and charges bytes. -2. Drain at most 64 frames or 4 ms. -3. If another complete frame remains, acquire one idempotent decoder pause - epoch and continue with `setImmediate`. -4. Release the pause only when complete-frame backlog and queued bytes fall - below low water. -5. `reset`, handshake `drain`, transport close, and dispose cancel the - continuation and release exactly the epoch they own. - -Main model-pressure mode is the one bounded exception to immediate read pause. -It may classify complete frames into the charged 1 MiB/64-frame data -quarantine while dispatching only eligible control/liveness frames, then -either drains data and token-ordering fences in original order or closes the -provider at the deadline. Exit, recovery completion, and activation cannot -overtake a preceding quarantined frame for the same token. It does not scan -past an incomplete or oversized frame and cannot expand the ordinary decoder -input cap. - -Handshake consumers require the first complete frame synchronously. Preserve -that behavior, then yield before later frames. `drain()` must cancel a scheduled -continuation and return all unread handshake residue in exact byte order so the -next consumer receives it once. - -The decoder permits one advertised valid 16 MiB frame plus 1 MiB input slack. -A partial valid frame keeps reads enabled until that cap; exceeding it closes -the offending transport rather than retaining back-to-back maximum frames. -Oversized payloads are discarded incrementally without full retention. -Sender-side finite PTY bursts and the reserved control lane bound how far a -keepalive/control frame can sit behind PTY data. While paused by this decoder, -the mux suspends both dead-link conjuncts. Resume rebases `lastReceivedAt` and -all outstanding header-ACK timestamps exactly like the existing wake-gap -handler; outgoing keepalives still run. - -In `SshChannelMultiplexer.handleFrame`, clamp header `ack` to -`nextOutgoingSeq - 1` and delete only keys already present in -`unackedTimestamps`. Never iterate a dense integer range toward an untrusted -32-bit value. This remains independent from PTY credit. - -## Cleanup and reconnect - -Every dispatcher client has a generation, and the negotiated session owner has -a separate transferable owner generation. Socket close/error, -`attachClient`/`detachClient`, and dispose invalidate or advance the client -generation atomically. Unexpected disconnect retains the owner generation and -lease through grace; only a proved replacement increments it, and grace expiry -invalidates the lease so a later election starts a new generation. Each -transition: - -- cancel read-pause epochs, writer callbacks, and scheduled work; -- reject queued liveness, control, and ordinary mux frames, clear lane heads, - and reset the four-control fairness counter; -- roll back uncommitted receive-activation leases to their exact prior - active predecessor, drop lease-private data, and await cancellation of only - the provisional token through its originating mux; -- after a validated recovery contract transfers the lease, retain held frames - only in that attempt's bounded quarantine and let cancellation proof or - provider-generation close own failure cleanup; -- on exact exit, seal the private lease, wake its fence, and either admit the - held contiguous prefix or validate cancellation against the highest exact - frame observed before retention/restore decisions; -- fail closed on the exact provider generation when a current recovery cannot - publish cancellation or validate its proof; provisional spawn rollback - failure instead surfaces execution ownership as unavailable; -- retain canceled `restoreRequired` relay deliveries until their response - settlement callback, then delete only the exact still-current record; -- clear or transfer one recovery-completion attempt and its capacity listener; -- move active PTY cursors to reconnect grace or cancel them; -- preserve sealed-unsettled suffixes until the relay applies their ACK, exact - transfer, or cancellation/generation-close proof; -- retry or cancel queued cumulative ACK state without collapsing - `obligationsTerminalEndSu`, `ackQueuedEndSu`, and `ackPublishedEndSu`; -- retain or cancel an exact early-ACK send boundary with its owning token; -- roll back uncommitted desktop reservations and transfer committed - projection IDs before clearing renderer queues; -- cancel uncommitted terminal-fact publications, restore their prior scanner - snapshots, and transfer committed projection scanner state exactly once; -- roll back any remote snapshot replacement before clearing ACK-overflow state; -- preserve partial remote byte watermarks while retaining covering source - frames, and prune cached mappings whose authoritative spans were reclaimed; -- reject and remove exit barriers plus cancellation/preparation ownership on - provider-generation close so late proofs cannot publish final exit; -- close each per-generation model-migration gate after its settlement, remove - its per-PTY fence and timer exactly once, and retain any earlier outstanding - per-PTY fence across an overlapping reconnect; -- on migration-owned callback failure, release only that PTY's admission - charge, timer, and tracked completion before reset/restore; do not close the - shared provider or sibling admissions; -- replace the latest canceled-token record per PTY on ordered cancellation and - clear it on exact next activation, PTY exit, or provider teardown; -- release shared spans no longer referenced; -- recompute native PTY pause state; -- expire pending RPC ownership. - -Unexpected loss of the session-owner generation with active PTY tokens creates -one `reconnect-grace` cursor per PTY for 30 seconds. It retains at most -512 Ki su and 2 MiB charged bytes per PTY; reaching either cap pauses that PTY -instead of dropping more output. A valid owner resume atomically installs the -new owner generation, transfers each cursor to a new token, and returns only -activation and recovery-range metadata. Source-ranged `pty.data` then drains -before the completion fence and live activation. Expiry invalidates the owner -lease, -emits `pty.deliveryCanceled` when possible, releases the gap, resumes the PTY, -and records restore-required/data-gap telemetry. Ordinary subscriber loss or -no-subscriber state outside owner grace retains only the existing replay tail. - -`SshRelaySession.reattachKnownPtys` waits for session-grant readiness and each -PTY's model-migration fence, then runs at most eight attaches concurrently. -Each PTY has its own 10-second attempt deadline and `try/catch`; one failure -cannot abort later PTYs. `notFound` and identity mismatch keep their existing -stale-lease behavior. Other errors are recorded, retried once with bounded -jitter while the same reconnect attempt is current, then surfaced per PTY -without tearing down successful siblings. Time-to-last-reattach is therefore -bounded by waves rather than `N × RTT`. - -Reconnect and replay always create new tokens even when IDs are reused. Old -ACKs and callbacks fail generation/token checks. The attach response declares -the exact checkpoint and recovery end; ordinary source-bearing `pty.data` -proves the gap and the completion fence establishes the live boundary, so main -never double-ingests old obligations. Replace -`SshRelaySession.forwardReattachReplay` with this tokenized intake and delete -`RECONNECT_REPLAY_DUPLICATE_WINDOW_MS`/`shouldForwardReattachReplay`: a -wall-clock fingerprint can suppress legitimate identical output, while the -source checkpoint is authoritative. Exit before reattach may still synthesize -`code: -1` after `notFound`; retaining remote exit tombstones is a separate -behavior change and is not required for the memory bound. - -Recovery receipt is transactional. Main retains quarantined recovery `pty.data`, -the candidate mux, token, checkpoint, and `recoveryEndSu` until the exact -completion fence validates. It commits no partial body to the model. On -failure it requests cancellation only through the candidate mux and accepts -only matching proof; a stale attempt may not touch current mux state. A -quarantine-only attempt may not yet have a main-intake token identity, so -`SshRelaySession` first requires `canceled: true`, finite safe cumulative -ends, `creditedEndSu` equal to the recovery checkpoint, and `sentEndSu` -covering the highest exact-token source range observed privately. Only then is -the intake's no-identity application an intentional no-op; malformed, -under-covering, or checkpoint-mismatched proof fails closed. A -current attempt whose cancellation publication or proof rejects closes the -exact provider generation and its registered resources once, rather than -leaving ambiguous authority. After a successful empty recovery it keeps -`recoveryEndSu` as a live continuity anchor until the first live frame arrives. -A proved recovery failure marks the existing PTY lease detached and retryable -rather than deleting ownership or terminating the physical process. - -For an operation-ID spawn replay, physical result lookup completes first; the -current request then creates and fences a new subscription as specified above. -Disconnect after physical commit/before response leaves the PTY retained but -no stale token. - -PTY shutdown and natural exit cancel native pause state before disposing -node-pty. Natural exit may still leave a charged sealed-unsettled delivery -record; only its native process is gone. Relay disposal generation-closes those -records and clears scheduler/exit timers before walking PTYs. A folder -workspace removal follows the same PTY teardown path; no design step assumes a -Git worktree. - -### SSH producer-pause intent - -Negotiated V1 implements `SshPtyProvider.pauseProducer` and `resumeProducer` -with a token/generation-scoped `pty.setDeliveryPaused` request; window size is -not renegotiated to zero. Relay marks that owner delivery ineligible and pauses -the native PTY only when all required deliveries are ineligible, while another -healthy subscriber may continue. Resume restores eligibility under the -original window. `setPtyBackgrounded` is a separate token-scoped scheduling -hint; it does not itself drop source data or silently convert a lossless model -obligation into keep-tail behavior. - -For V1, desktop pending-cap pressure transfers the recoverable projection and -does not call producer pause. Model-admission or required-lossless pressure and -explicit background policy may call it. Local, daemon, and legacy provider -behavior remains unchanged. Update the provider-interface and IPC comments -that currently assume SSH has an independently bounded pending queue. - -## Budgets - -Every limit has one unit. Source-flow limits use `su`; heap/transport limits use -bytes measured at the point that owns the memory. - -| Resource | High limit | Low/flush point | Action | -| ------------------------------- | -----------------------------------------: | ------------------------: | ------------------------------------------- | -| Legacy publications per client | 2 MiB | 1 MiB | pause affected PTYs; release on drain | -| Legacy publications per relay | 32 MiB | 24 MiB | pause affected PTYs | -| Legacy producer-held PTY frame | 128 KiB | next admission | hold one; pause before another | -| V1 data frame | 16 Ki su target | — | scalar-safe slice/coalesce | -| Encoded PTY frame | 128 KiB | — | reduce source slice before publish | -| Token outstanding credit | 256 Ki su | 64 Ki su newly freed | stop send / eager ACK | -| Sealed-unsettled suffix | token window; 30 s timer per subscriber | ACK/transfer/cancel proof | retain ledger; cancel stalled token | -| Recovery `pty.data` | 16 Ki su target | next turn | normal source lane before completion | -| Reconnect-grace source | 512 Ki su per PTY | — | pause at cap | -| Retained live data per PTY | 2 MiB | 1 MiB | pause source owner | -| Retained live data per relay | 64 MiB | 48 MiB | pause affected source owners | -| Replay tail | existing 100 Ki su × 50 PTYs | — | source-range trim; bytes charged globally | -| Liveness/control writer reserve | 25% of high water, capped at 64 KiB | drain | producer lanes cannot consume | -| Minimum PTY sink capacity | 8 KiB non-reserved empty | — | reject PTY subscription | -| Metadata control response | 64 KiB | — | body must use producer stream | -| Control queue | 256 frames / 1 MiB | drain | close subscriber; owner reconnect | -| Reshapable bulk producer frame | current non-reserved sink capacity | next frame | slice, admit, recheck priority | -| Fixed filesystem frame | existing 256 KiB raw payload | empty producer/sink epoch | admit one unchanged, then await settlement | -| Decoder | 16 MiB frame + 1 MiB slack | no complete backlog | pause/close at hard cap | -| Decoder turn | 64 frames or 4 ms | next turn | `setImmediate` | -| Header-ACK timestamp entries | 4095 + 1 reserved | ACK/resume | stop ordinary writes; coalesce liveness | -| Main receive-activation hold | 256 Ki su / 2 MiB per token; 64 MiB total | recovery completes | pause aggregate; close provider on cap | -| Main model admission per PTY | 256 Ki su / 2 MiB | 128 Ki su / 1 MiB | pause mux admission | -| Main model admission global | 12.5 Mi su / 64 MiB | 8 Mi su / 48 MiB | pause mux admission | -| Main blocked-intake slot | one 128 KiB frame | model low water | hold blocked frame | -| Main pressure control reserve | 1 MiB / 64 frames | 10-second close deadline | quarantine data; service control | -| Main model migration fence | 10 seconds | — | reset generation / restore required | -| Main desktop in-flight | existing 512 Ki su per PTY / 8 Mi su total | existing lows | transfer/restore policy | -| Lossless remote send ledger | 2 MiB/stream, 16 MiB global | 1 MiB/12 MiB bytes | stop send; explicit detach transfers/closes | -| Activation/exit/owner grace | 30 seconds | — | token cancel/gap policy | - -Retained strings are charged once as -`max(Buffer.byteLength(value, 'utf8'), 2 * value.length) + 128`; encoded buffers -use exact `Buffer.byteLength`; ledger/cursor entries charge 128 bytes. Shared -strings are not multiplied by subscriber count, but each cursor is charged. -Sent-but-uncredited source units are not described as retained memory. - -The table is arithmetically reachable: - -- 256 Ki ASCII su charges at most about 512 KiB plus records. -- 256 Ki BMP/CJK su at three UTF-8 bytes each charges about 768 KiB. -- 256 Ki su of surrogate-pair characters has 128 Ki scalar values and about - 512 KiB UTF-8/UTF-16 storage. -- A 512 Ki-su ordinary reconnect backlog is at most about 1.5 MiB for valid JS - strings; 32 16-Ki-su span records keep it below the 2 MiB per-PTY cap. - Highly fragmented transformed records bind the charged 2 MiB cap earlier. - Recovery is streamed and never compared with the 1 MiB control queue. -- JSON control-character escaping can reach six encoded bytes per su, so a - 16 Ki-su frame is below 96 KiB plus envelope and the separate 128 KiB cap. -- A transformed span with `rawLength !== data.length` consumes source credit - by `rawLength` and heap/wire budgets by its actual retained/encoded bytes; - neither value is converted into the other. -- Fifty PTYs cannot each reach 2 MiB because the 64 MiB relay cap binds first; - the independent 48 MiB global low water prevents immediate resume thrash. -- Model admission charges captured strings and queue records before a Promise - link owns them. Its 64 MiB global high water matches the relay retained-data - cap and its 12.5 Mi-su high equals 50 token windows, while each - 256 Ki-su/2 MiB per-PTY high covers one full token window; the separate - 128 KiB blocked slot covers the decoder frame that triggered pause without - hiding it in the model budget. -- Encoded lossless-remote bytes remain charged until full frame-boundary ACK or - atomic transfer; the ledger's source units are never estimated from bytes. - -One re-entrant native callback after `pause()` is a charged transient overshoot. -The callback may cross a retained-memory high cap by at most one charged native -chunk, but it is never admitted past token credit: splittable source is trimmed -to remaining window and an indivisible transform waits. If retained memory -crosses a hard cap, stop publication, mark restore-required, and retain only -the already-owned bounded state. Module constants allow test overrides; Linux -Docker heap/RSS plateaus validate allocator slack before release. - -## Telemetry - -Expose aggregate counters in existing relay/main diagnostic snapshots without -logging terminal contents or raw PTY IDs: - -- active/activating/recovering/closing tokens, owner generation/election/resume - outcomes, and PTY-subscribing clients; -- paused and reconnect-grace PTYs, grace bytes, recovery/fence latency, exact - gap ranges, completion retries, expiries, and gap outcomes; -- client writer state, lane depths, reserved-byte denials, and activation-fence - latency/cancellation, split bulk bytes, and per-priority wait; -- current/peak retained bytes and outstanding su by redacted client/PTY; -- pause count and total paused milliseconds; -- sink saturation count and duration by stdout/socket; -- slow-client detach and control-overflow counts; -- ACK accepted, duplicate, regression, over-credit, malformed, and stale-token - counts, early-boundary reservations/retries, plus ACK frames/second, - entries/frame, and encoded bytes/second; -- span obligations opened, transferring, settled, transferred, canceled, - duplicate-terminal, and oldest-open/oldest-transferring age by consumer - class; -- per-token obligations-terminal, ACK-queued, and ACK-published ends plus their - deltas; sealed-unsettled count, suffix size/age, exit-published state, and - timeout cancellation proof; -- desktop projection reservations, commits, rollbacks, model transfers, stale - identity rejects, and outstanding source/display ranges; -- model-admission current/peak bytes per PTY/global, blocked duration, - emulator completion/failure/cancellation, migration-fence latency/timeout, - late-generation rejects, and low-water resumes; -- lossless-remote encoded bytes, partial/frame-boundary ACKs, mapped source - ranges, generation rejects, ACK-overflow snapshot reserve/publish/commit/ - rollback, and transfer/cancel outcomes; -- decoder queued bytes, yielded turns, maximum frames/turn, and maximum - callback duration, read-pause duration, liveness-timeout suppression, - header-ACK timestamp depth/cap denials, and keepalive coalescing; -- exit-barrier timeout count; -- closed provider-generation range count and active gaps; -- reconnect attach wave latency, per-PTY retry/failure, token supersession, - relay-initiated cancellation proof, and time-to-last-success; -- same-build V1 grants, capability omissions from legacy clients, - method-not-found fallbacks, unsupported manual version skew, and discovered - orphan prior-version daemons, keyed by relay build/version rather than - legacy ACK traffic. - -Rate-limit warnings by connection and reason. Log thresholds and state -transitions, never data. Add an E2E-only snapshot request so tests can assert -plateaus, obligation conservation, pause/resume, and zero false reconnects -without parsing logs. - -## Incremental implementation map - -This PR implemented the design as the independently testable layers below, -without replacing local/daemon protocols or claiming live evidence for every -deployment topology. The slices remain useful review boundaries even though -they ship in one PR. - -### Slice 1: hostile-header and accounting hardening — implemented - -- In `src/main/ssh/ssh-channel-multiplexer.ts`, clamp header ACKs and delete - only present timestamp keys; cap retained timestamps. -- Add focused unit tests for `ack=0xffffffff`, concurrent ACK/timeout cleanup, - and liveness rebasing. -- No relay, daemon, runtime, or provider protocol change. - -### Slice 2: bounded decoder turns — implemented - -- In both relay protocol decoders, bound frames and time per turn, preserve a - synchronous first handshake frame, pause reads during continuation, and - transfer exact residue on reset/drain. -- Adapt ssh2, system SSH, WSL child stdio, relay stdin, Unix sockets, and - Windows named pipes behind their existing transport constructors. -- No PTY credit or session negotiation; prior-version peers keep the same - frame format. - -### Slice 3: one drain-aware legacy writer — implemented - -- Add a concretely named dispatcher writer owning ordinary, bulk, control, - sentinel, residue, and `--connect` bridge bytes. -- Route existing `RelayDispatcher.notify`, `notifyBulk`, fs/Git producers, and - `runConnectMode` through its bounded lane admission; stop after - `write(false)` until callback/drain. -- Preserve fixed `fs.streamChunk` size/offset compatibility in an exclusive - empty-sink `fixed-bulk` lane; slice only reshapable bulk and PTY output. -- Because legacy `notify` is synchronous and broadcast, add a bounded - transport-only publication record containing one encoded frame and the set - of client generations that have not accepted it. A client returning - `write(false)` leaves that set because Node accepted the frame; a saturated - client is retried only for that client, and close cancels its membership. - This is delivery bookkeeping, not source credit. -- Return producer admission to `PtyHandler`. Until a publication is admitted, - retain it in the existing PTY pending-output owner; at its hard byte cap - pause node-pty, then resume below low water. Cap per-client and aggregate - publication bytes. Before Slice 5a establishes authenticated roles, treat - every subscribed connection equally: any retained subscriber may pause that - PTY, and only transport close cancels its membership. Do not infer or detach - an “additional” client by constructor or connection order. Authenticated - slow-subscriber eviction begins only after Slice 5a. Never silently drop a - legacy PTY frame. -- Preserve the current 8 ms/1 ms PTY cadence and current replay semantics. The - replay tail stays the chunked `RecentPtyOutputBuffer` added in - `79ec57d04`; `PtySourceReplayIndex` now indexes it without replacing it with - the former per-append rolling string. -- This slice bounds relay writable queues for legacy main, WSL, direct SSH, - and detached relay socket/named-pipe clients without changing the wire. The - separate local terminal daemon is not exercised or changed. - -### Slice 4: bounded main SSH intake — implemented - -- Make `SshRelaySession.wireUpPtyEvents` hand each event exactly once to a - main-only intake under `src/main/ipc/`; SSH no longer bypasses the existing - pending-output authority. -- Meter model admission and its emulator callback chain, reserve before - capture, pause decoder reads at the cap, and return completion/failure - receipts. -- Introduce transactional projection admission with a legacy immutable - admission ID, provider generation, PTY incarnation, display interval, and - existing sequence/`rawLength`; it has no V1 token or source-credit - coordinate. Slice 5b extends that record to `DesktopProjectionSpan`. Keep - local and daemon providers on their existing intake until a shared migration - is separately justified. -- Preserve current main's ordered terminal side-effect facts and dropped-output - DEC mode 2031 scanner snapshots across admission, salvage, rollback, transfer, - and explicit gaps. Preserve `terminal-stream-byte-length.ts` UTF-8 accounting - and flush boundaries for remote streams; never reuse it as source credit. -- Extend the existing Docker ACK-stall test only for behavior this slice - implements: direct SSH/deployed relay/desktop intake and active typing. - -### Slice 5a: SSH session semantics — implemented for SSH - -- Add the transport-neutral `PtyConsumerSession` state machine and only the SSH - readiness adapter decision above. -- Implement authentication, generation, capability intersection, activation - fence, close cleanup, exact sentinel/residue transfer, and legacy fallback. -- Carry the endpoint credential through split-SFTP per-launch namespace - mapping, with secure canonical shell-path generation when no marker exists. -- Local in-process, daemon hello, and remote-runtime adapters reuse semantics - only when their own changes need it; they are not prerequisites for SSH V1. - -### Slice 5b: direct SSH source credit — implemented - -- Add the relay immutable span ledger/scheduler and tokenized cumulative ACKs - for one direct SSH desktop/model consumer path. -- Return exact `sourceActivation` metadata and install its provisional receive - lease synchronously in mux `beforeResolve`; commit only after provider and - exit-race validation. -- Reserve reentrant ACKs only at the exact pending same-token boundary and - retry that boundary after failed send settlement. -- Reuse Slice 4's projection identities and transactional admission. Separate - obligations terminal, ACK queued, and ACK published state from the first - implementation. -- Offer V1 on every new session while retaining bounded legacy compatibility - for capability-omitting clients and method-not-found relays. - -### Slice 5c: exit, reconnect, and replay — implemented - -- Implements sealed-unsettled exit, cancellation proofs, owner reconnect grace, - token replacement, and exact transfer. -- Carries recovery as contiguous source-bearing `pty.data` from checkpoint to - recovery end; fence live output with single-flight, capacity-retryable - `pty.recoveryComplete` writer settlement. -- Adds a bounded source-range index beside the current - `RecentPtyOutputBuffer`, preserving its append performance and legacy - output. -- `SshPtyOutputModelMigration` freezes exact generation/PTy admission, - `SshRelaySession` awaits the returned per-PTY Promise before constructing - `sourceRecovery`, and `OrcaRuntimeService` detaches only the failed PTY's old - headless model before snapshot-backed restore. -- `SshPtyModelAdmission` retains generation-fatal callback handling except when - the exact key is actively migrating; `SshPtyOutputIntake` uses the same key - to keep provider close from escaping that migration owner. -- Reconnect always re-offers V1; its late-ACK, timeout, supersession, and - generation-close deterministic oracles pass. - -### Slice 5d: required remote consumers — deterministic seam implemented - -- Map remote `terminal.multiplex` encoded-byte ACK frames to accepted source - ranges without changing its transport authentication or granting it an SSH - owner lease. -- Add `ackOutputSourceRanges: 1` and an opaque echoed stream generation; - preserve `ackOutput: 1` delta semantics for old clients and best-effort - streams. -- Make ACK-overflow snapshot recovery reserve, publish, commit, then trim; - rollback before detaching on any failure. -- Keep mobile notification replay epochs/watermarks outside terminal stream - identity and ACK mapping; neither can settle a PTY source range. -- Extend the extracted `terminal-output-frame-chunks.ts` seam with immutable - composite mapping input while preserving `694363805`'s code-unit scanner, - allocation profile, sequence rounding, and equivalence benchmark. -- Live headed paired-server and headless `orca serve` validation remains - required before claiming those topologies; neither is inferred from Docker - SSH. -- Keep best-effort remote streams outside upstream obligations. - -### Slice 5e: adversarial lifecycle reconciliation — implemented - -- Retain canceled invalid-checkpoint deliveries through exact - `restoreRequired` response settlement, then retire only the current record. -- Fail closed on the exact provider generation when a current recovery - cancellation cannot publish or its proof rejects; release generation-owned - resources once without clearing physical PTY ownership. -- Allow partial cumulative remote byte ACKs to release byte budget without - settling an incomplete source frame, and admit only contiguous strictly - higher client/owner generations with a new token. -- Make encoded snapshot replacement commit idempotent; reject reclaimed-span - commits and make rollback, settle, and detach prune absent spans safely. -- Split main-to-relay writes into liveness/control/ordinary FIFO lanes, select - control first after drain, and guarantee ordinary progress after four - controls. -- Hold provisional activation data until provider/claim/exit validation - commits for spawn, or until a validated reconnect contract installs its - private quarantine; rollback owns pre-transfer failure, while cancellation - proof or generation close owns post-transfer failure. -- Acquire exit cancellation proof before projection transfer and proof commit; - prepare/finalize once and fence every post-proof step by provider generation. -- Keep timeout cancellation authority operation-local and compact monotonic - closed provider generations into exact merged ranges without weakening stale - event rejection. -- Seal private recovery on exact exit, retain rollback authority through a - stale transfer, and include every exact-token private frame in the - cancellation-proof watermark. -- Replace process-lifetime per-token blocking state with one latest ordered - cancellation record per relay PTY, cleared only at an exact lifecycle - boundary. - -### Slice 5f: remaining topology validation and adapters — deliberately deferred - -- Local in-process and daemon hello remain unchanged; neither adopts SSH - framing or source credit in this PR. -- WSL, Windows ConPTY/named-pipe, local daemon/provider, folder workspace, - mixed-version, and prior-version-orphan live evidence remains separate. -- Promote reliability-gate provider coverage only for topologies with recorded - executable evidence. - -Keep new lifecycle ownership out of already-large modules where a concrete -domain file is clearer. Do not add max-lines disables, generic helper modules, -native dependencies, or a per-file lint exception. - -## Tests - -The checklist below is the normative matrix, not a claim that every physical -topology or the current shared-worktree diff ran. Implemented seams and prior -recorded evidence include: - -- shared session negotiation, grant publication, stale-owner recovery, source - ledger, ACK queue/publication separation, and cancellation proof contracts; -- relay writer admission/drain/liveness, zero-display source publication, - operation-ID retry activation, fixed-size filesystem compatibility, - capacity-aware Git/PTY slicing, decoder caps, and token rotation; -- main model admission, desktop identity/rollback, exit barriers, remote - reserve/commit/rollback, token cancellation, exact recovery continuity, - eight-wide reconnect, retention budgets, unconditional negotiation, and - deployment; -- current-working-tree restore-response retirement, fail-closed recovery cancellation, - partial remote ACK, higher-generation token rotation, reclaimed-span - idempotence, three-lane mux fairness, provisional claim gating, and - generation-fenced exit proof ordering plus bounded exact generation closure; -- the four-test Docker OpenSSH/deployed-relay suite for source-window plateau, - concurrent typing, fixed-size filesystem/Git churn, and owner reconnect. - -The earlier rebased checkpoint at -`38fbf3520741ce4ccd86410f7cea9ff8217a7834` passed: - -- frozen dependency setup, full lint, full typecheck, `git diff --check`, and - the 52-gate reliability manifest validation; -- all Linux, macOS, Windows, and WSL relay build targets plus - `electron-vite build --mode e2e`; -- 24 focused SSH/source-credit files with 354 tests; -- 14 watcher/daemon/package/glibc-adjacent files with 409 tests; -- a 57-file deterministic changed-surface sweep with 1,186 tests; -- the joined renderer/headless snapshot seam with 59 tests and its broader - focused slice with 180 tests; -- the relay recovery and fixed-size filesystem/Git slice with 105 tests. - -That checkpoint's Docker OpenSSH/deployed-relay run passed all four tests in 1.0 minute: -direct typing median/worst was 107.7/113.6 ms; ACK-stalled typing was -3.6/107.3 ms at an exact 262,144-source-unit plateau; fixed-size filesystem/Git -churn was 148.1/161.1 ms with 93 bulk reads; owner reconnect completed in -15.7 seconds. These measurements are direct SSH/deployed Linux relay evidence -only. - -The four-case Docker results predate the final adversarial lifecycle -reconciliation and are not current-head proof for those races. On the earlier -working tree, a deterministic test reproduced recovery completion becoming -visible at source unit 4 while lease-held recovery ended at 8. After the -validated contract began private quarantine ownership before awaiting the -body, seven provider/session files passed 94 tests and the isolated deployed -Linux relay/direct-SSH reconnect case passed in 17.7 seconds with 256 recovery -frames plus post-reconnect terminal/filesystem proof. Against the same -private-transfer artifact, the final four-case run passed in 1.1 minutes: -direct typing was 6.2/7.4 ms median/worst; ACK-stalled typing was 5.5/16.3 ms -at exactly 262,144 held source units; fixed-size filesystem/Git churn was -141.2/151.5 ms with 95 bulk reads; reconnect completed in 16.0 seconds. All -other live topologies remain separate. - -After the exact-PTY migration-failure containment fix and current-main merge, -`electron-vite build --mode e2e` and `pnpm run build:relay` produced the exact -artifact hashes recorded above. The four-case Docker command then passed in -56.7 seconds without a source-credit override: direct typing was 5.3/108.9 ms -median/worst; ACK-stalled typing was 4.8/107.6 ms at exactly 262,144 held source -units; fixed-size filesystem/Git churn was 152.0/173.3 ms with 84 bulk reads; -reconnect completed in 14.3 seconds. This is direct SSH/deployed Linux relay -evidence only. - -At the implementation commit above, the source/intake slice passed 12 files and 205 -tests, the provider/session slice passed 16 files and 192 tests, and this -complementary lifecycle slice passed 13 files and 150 tests: - -```bash -pnpm exec vitest run --config config/vitest.config.ts \ - src/relay/relay-pty-source-restore-retry.test.ts \ - src/main/ssh/ssh-relay-session-recovery-races.test.ts \ - src/main/runtime/rpc/terminal-source-range-ledger.test.ts \ - src/main/runtime/rpc/terminal-multiplex.test.ts \ - src/main/ssh/ssh-multiplexer-transport-writer.test.ts \ - src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts \ - src/main/providers/ssh-pty-notification-routing.test.ts \ - src/main/providers/ssh-pty-source-delivery-ledger.test.ts \ - src/main/ssh/ssh-pty-retired-source-deliveries.test.ts \ - src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts \ - src/main/providers/ssh-pty-provider-exit-race.test.ts \ - src/main/ipc/ssh-pty-output-exit-deadline.test.ts \ - src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot -``` - -The deterministic recovery seam specifically covers an empty recovery followed -by a gapped live frame, overlapping reconnect attempts, late frames after -cancel, exact exit during private recovery, stale-transfer rollback authority, -capacity-rejected and post-restore proof watermarks, typed stale-owner retry, -response-settled `restoreRequired` retirement, completion arriving before -lease-held body frames, and fail-closed exact-generation cleanup. A separate -10,000-rotation oracle proves canceled-token retirement remains one record per -PTY. Transferred recovery data remains private until body/fence validation, -and a proved token-local failure causes no physical PTY shutdown, ownership -delete, or lease expiry. -The model-migration failure oracle uses two PTYs in one provider generation: -one raw callback rejects under an active fence, returns -`checkpointUnavailable`, resets one model, and releases its charge and timer; -the sibling checkpoint, provider mux, filesystem/Git transport, and another -provider generation remain live. A paired admission oracle proves the same -failure without an active migration still closes the generation and rejects -its sibling receipt. -The remote-consumer seam covers partial cumulative ACK, safe higher-generation -token rotation, and token cancellation followed by late detach/replacement -commit, proving reclaimed span IDs reject commit, preserve rollback ownership, -and are then cleaned up idempotently. - -### Normative unit and property matrix - -- accept monotonic cumulative ACKs and reject duplicates, regressions, - over-credit, invalid numbers, wrong clients, wrong PTYs, and stale tokens; -- prove with generated span/ACK sequences that - `sentEndSu - creditedEndSu <= windowSu`; at `window - 1`, slice a - splittable frame to one source unit and hold an indivisible transform; -- generate merge, split, remainder, salvage, thinning, empty-transform, and - interactive-bypass queue operations and prove ledger conservation; -- split DEC mode 2031 subscribe and withdraw sequences across chunks, then - inject pending-cap salvage, rollback, transfer, reconnect, and source gaps; - prove fact order, exact scanner restoration/reset, no duplicate reply, and no - source settlement from projection metadata; -- put two clients at different display/source offsets in one shared span and - prove no resend, skip, or early reclamation; -- cover ASCII, BMP/CJK, surrogate pairs, unpaired surrogates, JSON-escaped - controls, and transformed spans where `rawLength !== data.length`; assert the - budget arithmetic and reject transformed frames without valid `rawLength`; -- run the shared semantic state-machine suite and the implemented SSH - socket/named-pipe adapter suite; local and daemon V1 adapters remain - unimplemented, direct-stdio/WSL has deterministic common-transport evidence - only, and remote-runtime source mapping is implemented at a deterministic - seam but unexecuted in a live paired topology; -- verify every new SSH session and reconnect offers V1 and every same-build - relay advertises and grants it; separately verify a capability-omitting old - client remains token-free and the narrow method-not-found relay fallback is - bounded; cover invalid/stale credential or lease rejection, 30-second lease - expiry, and atomic reconnect generation transfer through POSIX sockets and - Windows named pipes; prove constructor stdout, WSL child stdio, and an - unproved plain socket are never elected, and cover POSIX `0600` plus Windows - current-user ACLs; -- verify split shell/SFTP home discovery, per-launch marker creation under the - held install lock, credential write before lock release, canonical shell-path - fallback, system-SSH bypass, and unconfirmed teardown lock retention; -- verify token rotation on spawn, attach, reconnect, provider replacement, and - same-client duplicate attach/spawn; replacement must cancel the old token - once, report its exact remaining span, and transfer only matching recovery - coverage while canceling the already-checkpointed prefix without re-ingest, - duplicate output, or an open cursor; -- keep one lossless remote stream attached across an unsettled old-token frame - and a contiguous new-token frame; require strictly higher client and owner - generations, the same provider/incarnation, a new token, and reject every - stale or partially advanced identity; -- verify metadata response callbacks precede source-ranged recovery and live - data in V1, including saturation with older control; use multi-frame - all-control-character gaps to prove source bodies remain ordinary - producer-owned `pty.data` rather than control responses; -- feed response, recovery, completion fence, and first live data in one decoder - turn; prove synchronous `beforeResolve` installs a provisional cursor at - `checkpointSourceEndSu`; deliver completion before lease-held frames, then - prove validated reconnect transfers those frames only into private - quarantine, routes ranges through `recoveryEndSu`, and projects them once - only after exact body/fence validation. Pre-transfer rollback drops held - data, restores only the active predecessor, and awaits exact - token-cancellation proof; -- reject wrong-incarnation checkpoints and checkpoints below old credit or - beyond retained live end; require a token-free `restoreRequired` response - with no partial recovery or forward clamping; retain the canceled record - through response settlement, retire only the exact current record, then - prove retry mints a fresh token and publishes one live source frame; -- send identical recovery `pty.data` for distinct tokens within one second and - prove token/range identity, not a wall-clock fingerprint, controls admission; -- retry an operation-ID spawn after commit/before response from a new client; - prove it receives a fresh subscription and the stale client cannot ACK; -- assert `write(false)` admits exactly one frame, preserves the control reserve, - and admits no ordinary later frame before drain; -- queue ordinary PTY input before source ACK, cancellation, exit, request, and - response frames; after drain require liveness/control selection first, FIFO - within all three lanes, and one ordinary selection after four controls; -- deliver a cumulative ACK reentrantly before the source writer callback; - reserve only the exact same-token pending boundary, then prove successful - settlement applies it and failed settlement permits only an exact retry; -- inject asynchronous Node write-callback errors for session grants, token - activation, recovery completion, exit, and ACK publication; prove none - advance state and the client generation closes with exact cancellation; -- reject one `pty.recoveryComplete` admission, fire repeated capacity signals, - and prove one bounded retry is admitted; hold its callback and prove no live - output or duplicate completion passes before successful settlement; -- reject a PTY subscription whose empty non-reserved sink capacity is below - 8 KiB; prove every admitted transformed span remains writable; -- saturate relay fs/Git bulk; preserve fixed 256 KiB `fs.streamChunk` frames in - the exclusive empty-sink lane, slice only reshapable bulk/PTY data, and bound - cancellation, keepalive, ACK, immediate echo, and control latency; -- assert sentinel, handshake residue, and connect-mode socket data use one - stdout writer in FIFO order under saturation; -- assert close, error, detach, invalidate, replacement, reset, and dispose - settle/cancel each callback, token, and pause epoch exactly once; -- cover every lifecycle-table row and reject any - `received != open + transferring + settled + transferred + canceled` state; -- require `pty.deliveryCanceled` or generation-close proof for every - relay-initiated cancel and reject stale cancellation notifications; -- delay headless-emulator callbacks indefinitely; prove per-PTY/global model - queues and the one-frame intake slot plateau, upstream credit stops, low-water - resume is exact, and emulator rejection cancels rather than credits; -- while model admission is blocked, place cancellation/control behind a - bounded PTY burst; prove the 1 MiB/64-frame reserve services it without - reordering data, and prove reserve exhaustion pauses reads then closes the - provider at the deadline with cleanup proof; -- under the same pressure, inject `data -> pty.exit` and - `recovery pty.data -> pty.recoveryComplete`; prove both lifecycle fences - remain behind their token's quarantined data, while a same-token cancellation - proof bypasses only after atomically canceling that prefix; -- reject recovery cancellation publication and local proof application while - the attempt is current; prove the exact provider generation, mux, provider, - publishers, transferred activation, and registration close once, while - unrelated generations and physical PTY ownership remain untouched; -- prove renderer ACK/heal/write-off on V1 settles/transfers via display ranges - and emits no legacy `acknowledgeDataEvent` wire delta; -- admit interleaved spans from two token generations and prove every desktop - operation resolves the immutable span ID, PTY incarnation, delivery token, - source/display interval, and transform metadata; inject model-reservation, - projection-admission, send, merge/split, salvage, reload, and replacement - failure at each transaction boundary and prove rollback or exact transfer; -- generate remote UTF-8, JSON-escaped, transformed, split, and composite frames; - keep legacy `ackOutput: 1` delta ACKs outside source obligations; negotiate - `ackOutputSourceRanges: 1`, then apply partial, excessive, and stale-generation - cumulative ACKs and prove a partial ACK releases only its exact byte delta, - retains the covering source frame, and settles source only at recorded frame - boundaries, with send-stop without auto-transfer at the cap and atomic - mapping transfer on an explicit reconnect/detach; -- overflow ACK-pending remote output and inject snapshot serialization, - publication, exact-generation commit, and rollback failures; prove covered - chunks trim only after commit and recovery remains single-flight; after - cancellation proof reclaims a reserved span, require commit rejection, - idempotent rollback, and safe late settle/detach pruning; -- feed current mobile notification epochs and watermarks into terminal ACK - handlers and prove they are rejected without changing any source obligation; -- extend `terminal-output-frame-chunks-equivalence.test.ts` so composite source - identity preserves the current code-unit chunk text, encoded bytes, sequence - rounding, and allocation/performance contract; -- retain `terminal-stream-byte-length.test.ts` equivalence at its native-call - floor and over-limit boundary so transport byte accounting and batch flushes - cannot silently become source-unit accounting; -- hold desktop parse ACKs while a lossless mobile/web consumer advances; prove - desktop transfer to model restore allows upstream credit to continue; -- cover pause/resume hysteresis and re-entrant output/exit callbacks; -- drive `SshPtyProvider.pauseProducer`/`resumeProducer` through - `pty.setDeliveryPaused`; prove token/generation scoping, idempotency, native - pause when all required deliveries are ineligible, and continued progress - for a healthy additional subscriber; -- run one slow and one healthy client, verify only the slow client is detached; -- run no clients, verify no live pending data, bounded replay, and an unpaused - producer outside reconnect grace; -- verify exit follows data and never bypasses the window; after the exit write - callback, keep the sealed token and uncredited suffix live, accept a late - cumulative ACK, advance main `ackPublishedEndSu` only from its write callback, - and close the relay record only after it applies that ACK; separately prove - timeout publishes cancellation or uses generation-close proof and affects - only the stalled PTY token; -- receive exit with queued and in-flight emulator writes plus desktop and - remote mappings; prove main delays `runtime.onPtyExit`, renderer exit, model - disposal, and subscription cleanup until prior owners settle/transfer, and - prove deadline first acquires cancellation proof, transfers published - projections, commits proof, prepares/finalizes once, and rejects late - generation callbacks; close the generation while proof is pending and prove - no final exit publishes, then separately make proof fail while current and - prove only then the provider closes; -- close 2,048 sequential provider generations and prove one retained exact - closed range rejects early, middle, and latest stale events; close out of - order and prove an unclosed older generation remains admissible until its own - close merges the gap; -- verify round-robin progress across 50 continuously active PTYs; -- verify spans are freed only after all divergent cursors advance, cancel, or - transfer; -- test every source-unit and byte budget at limit minus one, limit, and plus - one, including global low-water hysteresis and legacy per-client/relay byte - publication caps plus their drain-owned low waters; -- verify decoder order, synchronous first frame, bounded yields, transport - pause/resume through ssh2, system SSH, WSL child stdio, stdin, Unix socket, - and named-pipe adapters, reset cancellation, partial 16 MiB frames, input-cap - close, oversized discard, and exact handshake residue; -- keep an unacked header across decoder/read self-pause and writer saturation; - prove resume rebases both health clocks, coalesces keepalives, caps timestamp - entries, and gives a full timeout window; -- feed hostile `ack=0xffffffff` and prove work is proportional to pending map - size, never the numeric ACK range; -- prove terminal obligations advance `obligationsTerminalEndSu` before any ACK - write, coalescing advances `ackQueuedEndSu`, callbacks alone advance - `ackPublishedEndSu`, and cleanup waits for the published end; cover failed - writes, cumulative retries, at most 64 entries, 8 ms/64 Ki-su flush rules, - and input fairness; -- fuzz frame boundaries, JSON sizes, reconnect timing, transport data during a - decoder continuation, and scalar-safe Unicode splits; -- exercise unconditional same-build V1 negotiation, old-client capability - omission, and method-not-found fallback; reject a manually mismatched - `.version`, and identify—but do not adopt or kill—an orphaned prior-version - daemon; -- verify the session grant rejects unrequested versions and zero, negative, - excessive, non-finite, or unsafe windows. - -Use a deterministic fake sink that records accepted frames separately from its -saturation signal. Use a fake native PTY whose `pause()` can synchronously emit -one last chunk to prove the transient overshoot bound. - -### Integration and E2E - -`tests/e2e/ssh-docker-relay-perf.spec.ts` now contains V1 evidence for the -implemented Linux SSH/deployed-relay path. The test harness exercises the -normal deployment and connection path with no override and: - -1. stalls desktop ACK and observes an exact 256 Ki-source-unit negotiated - plateau while a second SSH PTY remains responsive; -2. preserves fixed `STREAM_CHUNK_SIZE` filesystem semantics and completes Git - churn without corruption while active typing stays within budget; -3. reconnects the negotiated owner lease and proves the existing SSH - workspace terminal remains usable; -4. retains the direct typing latency control. - -Writer `write(false)`/drain, callback failure, liveness bypass, decoder -scheduling, sealed exit, stale ACK, exact recovery, and remote replacement are -proved at deterministic unit/service seams. The Docker suite does not force -the main-to-relay sink to remain saturated beyond the health deadline, does -not prove paired-runtime behavior, and must not be cited for those claims. - -Docker SSH proves the Linux SSH provider/relay path only. It must never be -reported as headed paired-server, headless `orca serve`, remote-runtime -lossless subscription, macOS node-pty, Windows ConPTY/named pipe, or WSL -evidence. - -The Slice 5d mapping is implemented, but live paired-runtime coverage remains -a separate promotion requirement: - -| Topology | Required oracle | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Headed paired desktop server + separate client | host-owned PTY identity, source mapping, client ACK/restore, reconnect, and cleanup | -| Headless `orca serve` + same client flow | identical source settlement plus headless model admission/startup ownership | -| Direct Docker SSH | deployed relay transport, main desktop intake, real remote process, writable/drain behavior | -| WSL child stdio | sentinel/residue, read pause, writer drain, no POSIX-path assumptions | -| Windows native | ConPTY pause/resume and named-pipe callbacks/drain | -| Local daemon | authenticated paired hello and exact-version legacy fallback | -| Local provider | direct semantic instantiation with no relay framing | - -Each topology also runs in a folder workspace fixture with no `.git`; source -identity and cleanup never depend on worktree metadata. Prior-version daemons -remain version-scoped legacy processes: report them, do not negotiate V1, -adopt their PTYs, or claim the new main bounds their memory. Keep any new -native artifact compatible with Ubuntu 20.04/glibc 2.31; this design itself -adds none. - -The -`terminal-performance.output-backpressure-budget` reliability gate remains -`experimental` and `partial`. Its `coveredProviders: ["ssh"]` means the -deterministic direct-SSH contracts plus the macOS-hosted Docker -OpenSSH/deployed-relay run described above. It does not cover every SSH host -platform and does not mark local, daemon, remote-runtime, headed, headless, -WSL, Windows, folder-workspace, prior-version, mixed-version, or Ubuntu 20.04 -packaging topologies as executed. The WSL stdin write/callback/drain contract -is deterministic adapter evidence only, not a physical WSL run. - -## Compatibility and release validation - -Every new SSH session and automatic reconnect offers V1. Every relay from the -same build advertises and grants V1, so the normal deployment path always uses -source credit. Sink drain, the reserved writer lane, stdout serialization, -bounded decoder work, bulk admission, and hostile header-ACK hardening are -also unconditional. - -Compatibility behavior is explicit: - -1. An old client that omits the V1 capability remains token-free and uses the - bounded legacy writer. -2. A new main may use bounded legacy delivery only when the same-build - compatibility check accepts JSON-RPC method-not-found for - `pty.openClient`. -3. A successful `pty.openClient` response without the offered V1 grant, an - invalid grant, or another request error fails closed rather than silently - downgrading. -4. Disconnect performs normal token-scoped cancellation and exit cleanup; it - never rotates a live V1 token into legacy mode in place. -5. A prior-version orphan remains governed by its own version-scoped binary and - cleanup. The new main reports but does not adopt, kill, or claim to bound it. - -Implementation and validation status: - -1. Slices 1-4 are implemented under legacy-compatible framing with narrow - reliability oracles. -2. Slices 5a-5c implement unconditional V1 offer/advertisement, source credit, - exit, and reconnect with unit/service contracts and direct Docker SSH - evidence. -3. Slices 5d-5e implement remote source-range transactions and the final - lifecycle reconciliation at deterministic main/runtime/provider/relay - seams. Live headed paired, headless, WSL, Windows, local-daemon, - local-provider, and folder-workspace evidence remains future work before - those topologies are marked covered. -4. Current-working-tree deterministic reconciliation and focused integrated-fix checks - are complete. The isolated reconnect and final four-case direct-SSH Docker - runs, full repository typecheck, and full lint/reliability suite are green; - final exact-implementation-head architecture and transport reviews found no - code blocker, and the transport review's stale-provenance finding is closed - by the exact artifact record above. - -Release criteria: - -- no unbounded increase across 30-minute desktop-only, slow-model, and required - lossless-consumer floods; -- relay and main stay within the documented budgets plus 25% allocator slack; -- active echo p95 remains below 100 ms under a background flood; -- exact logical output sequence after resume; -- no leaked obligations, writer callbacks, drain waiters, cursors, tokens, - decoder tasks, or paused PTYs; -- no false dead-link reconnect across a 30-minute saturated/self-paused run; -- bounded time-to-last-reattach with one injected per-PTY failure; -- unconditional same-build V1 negotiation, bounded legacy compatibility, - manual mismatch rejection, orphan reporting, and Linux/macOS/Windows/WSL - smoke tests pass. - -## Calibrated findings and non-goals - -- The liveness risk is real for transport saturation and self-paused decoding. - A renderer-only stall is absorbed by bounded projection transfer/restore - while required-model credit continues. A stalled model admission or - negotiated lossless remote consumer exhausts its window and pauses the - native PTY; tests keep these distinct. -- V1 uses logical priority lanes and reserved writable capacity, not a second - SSH channel. Adding a physical control channel would widen handshake, - reconnect, system-SSH, and legacy compatibility scope without being required - once PTY bursts and decoder pauses are bounded. -- There is no arbitrary 16-socket cap. Additional PTY projections are bounded - and isolated by their own writer/publication capacity; short-lived remote CLI - clients remain independent. -- A 60-second watchdog may warn about an old open obligation, but it cannot - force-credit data. Recovery must use a proven transfer or token cancellation. -- Reconnect-grace data is a bounded live-delivery owner, not an expansion of the - 100 Ki replay-tail contract. Expiry is an explicit data gap, never silent - truncation. -- Content-hashed endpoints make mixed production binaries unreachable. The - real upgrade skew is an orphaned prior-version daemon outside the new main's - startup-gated endpoint; V1 reports but cannot adopt, cancel, or retroactively - bound it. -- Preserving the real exit code for a PTY that exits before any new client - reattaches would require attach-visible tombstone retention. V1 documents and - tests the existing synthetic `-1` fallback instead of coupling that separate - product change to backpressure. - -## Reference-commit assessment - -Retain from `1500a92904` the useful concepts of generation tokens, client -isolation, pause/resume, targeted dispatcher sends, cleanup of stale credits, -and slow-client eviction. - -Do not reproduce these behaviors: - -- ordinary notifications continuing to write after `write(false)`; -- delta ACKs without cumulative token scope; -- automatic live broadcast to every connected client; -- force-flushing final PTY output beyond the credit window; -- per-client windows without aggregate retained-memory budgets; -- synchronous all-frame draining; -- assuming `256 Ki × 50 PTYs × N clients` is itself a safe memory bound. - -This design makes writable-drain state, application credit, retained memory, -and decoder CPU four explicit and independently enforced bounds. - -## Platform and workspace requirements - -The relay can run on a native macOS, Linux, Windows, or WSL host reached over -SSH. Use Node stream APIs and runtime platform checks; do not assume POSIX file -descriptors, path separators, signals, or Unix sockets. Windows uses ConPTY and -named pipes, while POSIX uses node-pty and Unix sockets, but both must expose -the same idempotent pause/resume and sink state contract. - -Network latency changes ACK cadence but not the window invariant. A -high-bandwidth/high-latency SSH link may need later window tuning based on -telemetry; it must not receive an unbounded adaptive window. SSH disconnects -are normal lifecycle events, not exceptional shortcuts around cleanup. - -No part of subscription identity or cleanup may depend on `.git`, a worktree -ID, or a Git provider. Folder workspaces use the same connection, PTY, and -client generations as Git worktrees. Bulk Git changes only slice and schedule -existing response frames; they add no Git command, provider-specific behavior, -or requirement beyond the Git 2.25 core-workflow baseline. diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md deleted file mode 100644 index 104f6409929..00000000000 --- a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md +++ /dev/null @@ -1,279 +0,0 @@ -# Daemon lifecycle retirement for issue #9138 - -## Status - -Implemented and locally validated for PR #9277. - -The earlier full ownership/audit prototype is preserved at: - -- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot` -- commit: `7c915909bd26670b8af36aa683cff85077395dd1` -- GitHub: - -That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup -audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future -legacy enforcement. None of those systems ship in this PR. - -Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was -in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation -alongside v22 and older versions. - -## Inputs and scope decision - -Issue: - -Reviewed design comment by AmethystLiang: - - -The reviewed comment correctly identifies two different problems: - -1. current-generation daemons have no daemon-owned empty lifecycle; -2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act. - -This PR solves only the first problem. The second problem is much larger, adds steady-state -persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement. -Keeping it out makes the user-visible fix small enough to review and benchmark independently. - -The narrow implementation retains these important rules from AmethystLiang's design: - -- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it; -- live sessions always win over cleanup; -- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence; -- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation; -- the daemon, not app startup, makes the atomic empty decision; -- pre-v24 daemons stay reattachable and are not automatically shut down; -- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged. - -The narrow implementation changes one policy from the original comment: it does not use a blanket -30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle -event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime -inactivity heuristics and periodic ownership work. - -## User-visible behavior - -```text -Before - -app disconnects ──> daemon stays forever - ├── live sessions stay (wanted) - └── zero sessions also stay (leak) - -After - -clean app detach ──> daemon atomically checks itself - ├── any live session/work/client ──> stay alive - └── exactly empty ──> exit immediately - -unexpected drop ──> daemon atomically checks itself - ├── live session/work/connection ──> stay alive - └── exactly empty ──> exit immediately - -v23 and older ──> existing reattach behavior; no automatic retirement -``` - -An end user with live terminals should notice no change. An end user who quits with no daemon-backed -terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket, -live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later -app restart launches a fresh daemon instead of reusing an empty process. - -## Protocol and lifecycle design - -### Endpoint identity - -Protocol v24 hello responses include: - -```ts -type DaemonEndpointIdentity = { - pid: number - startedAtMs: number - launchNonce: string -} -``` - -The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes -the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client -sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps -the previous identity-free handshake. - -PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a -write failure terminates the new child and fails launch instead of leaving an untracked daemon. - -### Clean detach - -At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a -v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared -250 ms budget. v23 and older adapters skip it. - -Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is -opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still -receive clean retirement on quit. If startup fallback has already won, the late daemon is not -installed; it instead receives the same bounded retirement attempt, which an adopted live session -will reject. - -The daemon accepts retirement only when, in one event-loop turn: - -- the requesting authenticated client has both control and stream sockets; -- it is the only authenticated client; -- every accepted transport belongs to that client; -- no `createOrAttach` operation is in flight; -- the terminal host has zero sessions. - -When all conditions hold, the daemon synchronously closes the listening server before replying. That -is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then -runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to -the same event-driven empty check when the authenticated sockets close. - -### Initial adoption watchdog - -A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated -client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the -normal launch handoff before the parent could connect; without a bound, a parent crash during that -handoff would orphan the new daemon forever. - -A complete pair permanently cancels this watchdog, and terminal admission requires that complete -pair. Raw and partial transports pause it without extending its original deadline. It is never -rearmed after adoption and is not a terminal inactivity or crash-reconnect timer. - -### Unexpected disconnect - -When the last client that completed both authenticated sockets loses its control connection, the -daemon records an event-driven retirement request with no wall-clock grace. - -- A complete authenticated reconnect cancels the request if existing work or a transport kept the - daemon alive long enough to reconnect. -- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request - before admission can begin. -- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but - cannot erase evidence that the last fully connected app left. -- Replacing a client ID first records the old full connection's loss; completing the replacement - stream cancels that evidence, while an incomplete replacement only blocks retirement. -- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately - rechecks every guard and retires only if it is then exactly empty. - -The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the -token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is -not broadened into destructive or respawn authority. - -### Artifact cleanup - -The daemon removes only artifacts it can claim as its own: - -- token contents must match the daemon's in-memory token; -- PID and launch nonce must match the daemon process and launch nonce; -- cleanup first renames the canonical entry to a unique claim, validates that claim, and never - overwrites or unlinks a replacement installed at the canonical path. - -Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24 -PID/socket artifacts. Legacy cleanup behavior is unchanged. - -## Performance design - -There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or -steady-state persistence write in this PR. - -The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one -two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its -connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection -attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect -bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one -unref'ed startup watchdog, which is canceled permanently on adoption. - -Validation compares current main and the branch for: - -- daemon connect plus two-socket hello latency; -- repeated `listSessions` RPC latency; -- terminal echo/stream throughput through a real socket daemon; -- clean empty-detach latency; -- event-loop delay under repeated RPC/stream work; -- idle CPU/RSS and timer count where observable. - -The acceptance target is no statistically meaningful terminal throughput regression and no new -steady-state disk writes. Results are recorded in the PR body. - -### Local performance regression screen - -The final local host was not quiet enough for publication-grade absolute numbers: load averages were -17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired -same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24 -branch samples; medians across sample medians were: - -| Measure | `main` v23 | branch v24 | -| -------------------- | ---------: | ---------: | -| two-socket connect | 1.38 ms | 1.32 ms | -| `listSessions` RPC | 0.0366 ms | 0.0374 ms | -| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s | - -All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and -event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression -screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work -runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY -byte and no steady-state persistence or polling. - -## Verification and validation - -### Focused unit and integration tests - -- v24 requires valid matching endpoint identity on both sockets. -- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols. -- production launch passes PID path and nonce and writes the exact readiness identity. -- incomplete readiness identity or failed exclusive PID publication kills and rejects the child. -- clean empty detach exits immediately. -- a never-used current adapter connects and retires cleanly on quit. -- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old - deadline. -- a live session, another client, raw transport, or in-flight admission rejects clean retirement. -- a control-only overlapping client blocks but cannot erase the last full-client retirement request. -- a same-client-ID control replacement cannot erase the prior full connection's retirement request. -- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed - request. -- startup fail-open performs bounded empty retirement without installing a late provider. -- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later. -- the synchronous listener fence rejects post-fence connections as retryable. -- an unexpected empty disconnect retires immediately without a runtime inactivity timer. -- a real reconnect cancels pending retirement while live work keeps the daemon available. -- raw and health probes block but cannot erase pending retirement. -- final session exit triggers an immediate guarded retirement check. -- token/PID cleanup preserves malformed, stale, and replacement artifacts. -- authenticated token disappearance performs one coalesced respawn; initial token absence does not. - -### Process/E2E tests - -- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts; -- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts - exit; -- prove a live session rejects retirement and remains reattachable; -- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable; -- never target a production runtime directory or signal a process not created by the fixture. - -### Repository validation - -- Node typecheck; -- oxlint and repository max-lines policy; -- focused daemon, adapter, launcher, restart, and legacy-routing suites; -- full daemon test suite; -- desktop and web production builds; -- `git diff --check` and review of every changed file against `origin/main`; -- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`; -- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication. - -Final local results on macOS arm64 after the event-driven policy revision: - -- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped; -- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned - artifacts retired immediately after its final authenticated client disconnected; -- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed; -- full desktop, web, and native production build passed with existing build warnings; -- three independent post-revision review tracks covering architecture/state machines, - ownership/adoption, and process lifecycle ended clean after actionable findings were fixed. - -The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed -successfully, and repository CI remains responsible for the supported Node/platform matrix. - -## Rollout and rollback - -This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves -v24 as another legacy generation and does not give an older app authority to shut it down. - -If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated -ownership/audit prototype remains recoverable from the archived branch and commit above; it should -return only as a separately reviewed, benchmarked follow-up. diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md deleted file mode 100644 index e7b13f0fb29..00000000000 --- a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md +++ /dev/null @@ -1,545 +0,0 @@ -# Windows ConPTY Startup Query and Focus Authority Design - -Date: 2026-07-19 - -Status: Implemented with the 2026-07-20 ownership amendment below - -## 2026-07-20 Ownership Amendment - -Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for -the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so -renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder. - -The PTY-owning process now classifies the backend from its own platform and the shell that actually -won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11 -queries before model, replay, or view delivery. During the bounded startup window it replies once -when validated theme colors are available; after the deadline, or without colors, it consumes the -query without replying. A consuming-view handshake does not transfer this authority. Split query -candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a -candidate that proves malformed is released unchanged. - -WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the -startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime -PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is -selected only by the authoritative owner backend, never by renderer or connection metadata. - -## Problem - -On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user -text: - -```text -]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\ -``` - -An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal -focus-in report `CSI I`. - -The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The -agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only -about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in -addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent -to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order -records that cooked echo in the authoritative runtime model before any renderer-bound filtering. - -The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits -DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native -PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was -consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol; -the failure requires an additional agent, timing, input-mode, or replay condition. - -## Root Cause - -OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY, -WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners -([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session` -advances sequence state, writes its emulator, persists pending output, and fans out data before -Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only -filter can therefore misclassify paired runtimes and hide the symptom without removing it from the -authoritative model, daemon history, snapshots, or remote delivery. - -The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer -downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met -that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness -without pre-approving a speculative focus workaround. - -## Data Flow - -```text -node-pty / remote relay PTY - -> shell-ready marker scan - -> source-owned serialized ingress transaction - -> consume an early OSC query and write one canonical reply - -> match or losslessly release the measured native-Windows echo projection - -> authoritative emulator + persistence/history - -> runtime side effects and mobile/remote stream - -> renderer delivery or hidden-drop decision - -> live terminal / snapshot restore -``` - -Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed -spans advance sequence and flow-control state without writing bytes into an emulator or view. - -## Rejected Prototype - -The current working-tree prototype is not the implementation of this design: - -- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus - semantics for every Windows PTY, including programs unrelated to agents. -- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a - hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage. -- Its echo state activates only after both color slots are answered. An echo of the first response - can escape if it arrives before the second query. -- Its partial-match buffer can be lost on timeout, and its substring search can remove a later - legitimate string that happens to equal a reply. - -Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked -echo filtering. Existing unrelated Windows focus-idle safeguards remain. - -## Existing Contracts Preserved - -This design extends, rather than replaces: - -- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query - authority, authoritative model restore, and raw sequence ordering; -- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus- - hidden-dropped ownership and replay silence; -- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY - bytes once in main before renderer delivery. - -The startup responder is a bounded exception needed before normal delivered/dropped ownership can be -established. It must join the same ingestion decision, not operate as an unrelated renderer scrub. - -## Decision 1: Source-Owned PTY Ingress Transaction - -Sanitization must run on the host that owns the PTY, before that host mutates any authoritative -model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced -its sequence, written its emulator, recorded pending output, and broadcast the data before main's -provider callback runs. - -The transaction therefore has three installations of the same shared state machine: - -- in `LocalPtyProvider`, before its configured runtime callback and data listeners; -- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and - attached-client fanout; -- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's - `SshPtyProvider` is only an RPC proxy and never sanitizes relay output. - -Electron main receives already-classified daemon data. A remote-runtime desktop client does not -reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual -provider owner but never enable the native-Windows compatibility projection. - -Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach` -receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed -color attributes. It first decides fresh versus reattach; only a fresh result constructs the -transaction before releasing the subprocess's already-buffered early output. A reattach discards -the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any -PTY id can be reused. - -Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn` -carries the same fresh-session intent and installs it before releasing relay PTY output; relay -`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is -not armed and normal query authority handles the query. - -Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold -predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported -legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a -separate versioned spawn/data capability because daemon protocol support says nothing about relay -support. - -An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots -or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive -the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached -legacy session remains explicitly outside it. - -### Composition with shell-ready preprocessing - -The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold -transport bytes and removes its private ready marker; marker bytes have never belonged to terminal -model or delivery sequence space and continue not to count. Its released non-marker bytes enter the -new ingress transaction in original order. - -Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its -non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model, -persistence, and views observe the resulting emissions. No later stage may introduce an unmetered -string transform. - -### Raw sequence and emission contract - -The state machine accepts source chunks with an explicit raw half-open range and returns zero or -more ordered emissions: - -```ts -type PtyIngressSourceChunk = { - data: string - rawStartSeq: number - rawEndSeq: number -} - -type PtyIngressEmission = { - data: string - rawStartSeq: number - rawEndSeq: number - transformed: boolean -} -``` - -The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when -`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without -overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once -from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the -buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it. - -The ingress raw high-water advances when a source chunk is accepted. Model-applied and -view-delivered high-waters advance only through ordered emissions, including empty transformed -emissions that consume a raw range. Runtime/mobile listener metadata carries -`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span -cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching -the existing `rawLength !== data.length` safety rule. - -This metadata is end-to-end, not local to the state machine. Implementation changes the complete -path: - -1. daemon `Session` or relay `PtyHandler` emission callback; -2. daemon/relay batching, coalescing, splitting, and wire notification; -3. daemon adapter or `SshPtyProvider` decode; -4. `OrcaRuntimeService` model and runtime/mobile listeners; -5. main renderer batching, pending/drop accounting, and preload payload; -6. renderer reconciliation and remote-runtime binary/live frame decoding. - -The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A -span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing -its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted -only for contiguous spans and sums raw lengths independently of string lengths. A transformed -emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush -it as its own frame or request snapshot reconciliation instead of slicing it. - -If a snapshot is requested while a partial candidate is held, the transaction first abandons that -candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and -snapshot sequence therefore describe the same raw high-water. - -### Serialization and teardown - -Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a -nested callback, but that callback is appended after the current source chunk rather than delivered -ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue. - -On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model -and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay -disposal must flush both ingress prefixes and its existing pending output batches before clearing -them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate -state after teardown. Ordinary chunks pass through as one unchanged emission. - -## Decision 2: Startup OSC Queries Are Consumed Authoritatively - -The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup -and the agent's short color-query timeout. Its implementation moves into the source-owned ingress -transaction. - -When registered for an agent spawn, it: - -1. recognizes exact OSC 10/11 query grammar across provider chunks; -2. builds replies from validated renderer-pushed foreground/background attributes; -3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority, - regardless of whether the query ended with BEL or ST; -4. records each reply transaction before writing it to the provider; -5. consumes the answered query from the authoritative model and view emissions so neither the - hidden model nor a delivered renderer can answer it a second time; -6. begins echo recognition as soon as each individual reply is written. - -If attributes or a provider are unavailable, native ConPTY consumes the query without replying; -WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers -startup response state, matching current behavior, but native ConPTY ownership still prevents a -downstream reply. - -### Exact authority transfer - -Startup query response authority opens only for a fresh session whose atomic creation installed the -transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of: - -- both OSC 10 and OSC 11 slots have been answered; -- the startup deadline expires; -- main sends an ordered authority-close control after either the consuming-view handshake or the - hidden-runtime ownership mark is established; -- the spawn fails, is cancelled, reattaches, or exits. - -Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source -replies, while complete queries remain consumed for the life of the PTY. Closing response authority -does not discard already-written reply candidates. Echo recognition has -its own bounded lifetime and may finish or drain after normal authority takes over. A close and a -provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to -a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control -method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered -boundary are either consumed at source or removed from emissions; queries after it pass unchanged -to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority. - -The regular authority rules continue after the bounded startup window: - -- delivered live bytes are answered by the live view; -- hidden-dropped live bytes are answered by the runtime model; -- replayed, seeded, and snapshot bytes are answered by nobody; -- the daemon's persistence emulator never writes replies. - -Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to -name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a -real third responder class, not an undocumented exception. - -## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model - -Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a -compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do -not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and -capability gate. - -The harness records the exact projection ConPTY returns for the canonical ST reply, including its -chunking and any console transformation. The implementation must not assume that the projection is -always merely "remove ESC", and BEL or other reply forms are not added without separate evidence. -For every written reply, the transaction records that exact expected projection. Recognition is: - -- FIFO in reply-write order; -- anchored at the next possible output position, not an unbounded substring search; -- active immediately for each reply instead of waiting for both color slots; -- bounded by the startup deadline and maximum reply length; -- streaming across chunk boundaries. - -If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged -and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is -buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A -match advances the raw span but emits no bytes to the model, persistence, or views. - -Projection matching cannot prove provenance. An application can print the same projected text at -the candidate position, causing a false-positive removal while the later real echo remains visible. -This is the central drawback of the workaround. It is accepted only if the real provider harness -shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise -Orca disables the projection and keeps the visible output rather than risking deletion. - -When the projection is enabled, the exact-collision behavior is explicit: the first identical -anchored candidate is removed and a later real echo is allowed through. The test fixture must assert -that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance -is knowable; the release gate must document the observed timing window and justify that tradeoff. -Unknown, delayed, or interleaved transformations pass through visibly. - -## Decision 4: Preserve ConPTY Focus Protocol - -Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It -must not remove, reorder, or fabricate transport bootstrap modes. - -The `[I` investigation gets a deterministic harness before a behavior change. The harness must -exercise the actual renderer focus callback and provider write path, not only inject `CSI I` -directly into node-pty. It records: - -- raw provider output and its order; -- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004, - or replayed snapshot state; -- the exact bytes written to the provider; -- the exact bytes returned by the provider and stored in the model; -- agent lifecycle state when the report was emitted. - -The implementation gate is strict: - -- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode - is not persisted as application ownership. -- If a live agent receives transport focus before it owns terminal focus reporting, record that - evidence without shipping a suppression heuristic from this document. -- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize - that transformation at the same pre-model ingress boundary used for OSC replies. - -No global `?1004h` filter ships under any outcome. - -If the harness proves a focus-ownership race, a follow-up design is required before implementation. -That design must define the output-to-input ownership signal, distinguish xterm focus events from -identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any -snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document -does not pre-approve an ownership state machine whose input provenance cannot yet be represented. - -## Snapshot and Replay Rules - -Interactive modes in a snapshot are capabilities of the live application, not proof that a new -view should emit input immediately. - -- Cold restore into a fresh shell keeps the existing full mode reset. -- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only - bootstrap ownership. -- Snapshot serialization/replay must not mutate live ownership trackers. -- A snapshot containing an OSC query or an old Orca reply never produces a provider write. -- Model and renderer snapshots must both be free of matched cooked reply projections. - -Any future focus ownership metadata must be explicit; it cannot be inferred from serialized -`?1004h` text. - -## Failure Policy - -Safety is asymmetric: - -- This design does not intentionally suppress a focus notification; evidence of a focus-ownership - race triggers a follow-up design instead. -- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the - application. -- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate - replies are forbidden. -- Losing buffered output on timeout or teardown is forbidden. - -## Cross-Platform and Remote Scope - -- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility - path; focus investigation only until a proven root cause has its own complete design. -- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround. -- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the - remote host protocol later supplies explicit equivalent evidence. -- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there; - desktop local main does not reinterpret its stream. -- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query - response authority through the existing terminal-driver election. - -## Edge Cases - -- A query or echo split at any byte boundary, including OSC ST split across chunks. -- The first reply echo arriving before the second color query. -- Unrelated output, an exact application-text collision, or a partial match before the real echo. -- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held. -- A provider write causing a synchronous nested output callback. -- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing - with output. -- BEL-terminated queries still receiving the canonical ST reply. -- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers. -- Old daemon/relay protocol versions and sessions that survive an application upgrade. -- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds. -- Focus gained through normal terminal input versus the separate explicit reattach-focus write path. - -## Test Plan - -### Deterministic provider harness - -- Capture the native ConPTY bootstrap across natural and forced chunk boundaries. -- Reproduce the OSC reply echo with the real provider and an agent query fixture. -- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11 - queries and combined OSC 10 `?;?`. -- Force the first reply echo before the second query. -- Split every byte boundary in query and echo fixtures. -- Print the expected projection immediately before the real echo and assert that the first - identical candidate is removed while the later echo passes through. -- Exercise focus gain/loss through the actual xterm focus path. - -### Ingress integration - -- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive - classified data before storing it. -- Assert renderer and mobile delivery receive the same visible output. -- Assert raw start/end spans advance by original provider length, producer ACK contribution is - counted exactly once, and mobile/runtime metadata reports the raw span rather than string length. -- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move, - and teardown, including a prefix held from an earlier callback. -- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the - write. -- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather - than slicing cleaned text. -- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam; - assert `SshPtyProvider` does not reinterpret relay data. -- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection. -- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold - restore; assert capable relay replay/history has the same property. Assert old daemon and relay - versions are detected and never represented as sanitized. - -### Authority and restore - -- Assert an early-consumed query is answered once by its source owner and never by renderer or - hidden model. -- Assert normal delivered/dropped query authority resumes after startup state clears. -- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do - not contain cooked OSC text. -- Assert replay never sends OSC or focus replies. -- Assert ordinary native console focus behavior remains enabled. -- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any - later ownership implementation defines its tests in the required follow-up design. - -### End-to-end acceptance - -On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions. -Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the -focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops -at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus -behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain -PowerShell terminal and a native focus-event consumer to prove no global regression. - -Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint, -formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also -require the repository's SSH end-to-end procedure. - -## UI Quality Bar - -This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must -look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a -clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and -focus presentation. - -## Review Screenshots - -1. A fresh native-Windows agent prompt after startup, with no OSC reply text. -2. The same session after hide/reveal restore, still clean and without duplicated output. -3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state. -4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior. - -## Rollout - -1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress - state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready - preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay - `PtyHandler` before their models, persistence/replay, and fanout. -2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates, - carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query - authority documents with exact transfer events. -3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical - ST replies, consume answered queries for model and views, and retain raw sequence accounting. -4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO - anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage, - and authoritative daemon/model/snapshot tests. -5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop - for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed - only with a failing regression test that selects that branch. -6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL, - and SSH scenarios before removing the old startup implementation. - -Each slice must keep query authority singular. The compatibility projection does not ship without a -model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness -fails on the old behavior and passes on the new behavior. - -## Lightweight Eng Review - -- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global - focus filtering and an unproven ownership state machine remain out of scope. -- Architecture/data flow: classification belongs at each PTY source owner after shell-ready - preprocessing and before every authoritative model, persistence, replay, or delivery consumer. -- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes, - authority races, snapshot and teardown drains, protocol-version skew, reattach, and host - isolation. -- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay, - runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and - renderer-focus harnesses; Electron and SSH end-to-end validation. -- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one - startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery - path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory. -- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage - disappears, without cursor/focus regressions or restore flashes. -- Required review screenshots: the four terminal states in `Review Screenshots`. -- Residual risks: the native projection may be too unstable to enable; exact projected application - output can collide; `[I` may require a separately reviewed focus-ownership design. - -## Non-Goals - -- Replacing xterm's general query parser. -- Filtering arbitrary escape-looking terminal output. -- Disabling ConPTY focus or Win32 input mode globally. -- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows. -- Solving unrelated MCP startup warnings reported beside the terminal garbage. - -## Final Invariants - -1. Provider bytes are classified once before model ingestion and view delivery. -2. A live query has exactly one responder; replay has none. -3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched - startup-reply projection. -4. Raw provider sequence accounting survives sanitization. -5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data. -6. ConPTY bootstrap modes reach live emulators unchanged. -7. No focus suppression ships from this design; a proven ownership race requires a follow-up design. -8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state. diff --git a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md deleted file mode 100644 index 8a31a894b07..00000000000 --- a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md +++ /dev/null @@ -1,620 +0,0 @@ -# Windows daemon-generation safety investigation (#9749) - -Investigation and completion snapshot: 2026-07-21 16:59 PDT -(2026-07-21 23:59 UTC) - -Branch baseline: `OrcaWin/issue-9749-windows-daemon-generation-safety` at -`937a2015e`, 40 commits after `v1.4.148-rc.1`. This document is the hard gate -before a reproduction harness or production change. GitHub access during the -investigation was read-only, and no installed Orca daemon, pipe, token, process, -or terminal session was contacted or changed. - -## Findings - -The reported incident is a composition of three lifecycle paths, not one: - -1. **Survival and adoption:** normal app quit deliberately disconnects from - daemon clients without shutting down their PTYs. Protocol-specific named - pipes let multiple generations coexist, and legacy adapters intentionally - reconnect to them so live PTYs remain warm and reattachable across upgrades. -2. **Broad current-generation replacement:** - `cleanupDaemonForProtocol` sends the only production - `shutdown { killSessions: true }` request found in the repository. Its - production callers replace or explicitly restart the current generation; - legacy discovery does not call it. A failed `listSessions` is currently - converted to an empty list before this broad shutdown, which makes its - reported kill count untrustworthy but does not suppress the shutdown. -3. **Reconnect-triggered per-session destruction:** #8871 provides the strongest - causal evidence. A reconnecting renderer restores stale remote handles, - synthesizes `pty-exit`, drops that reason at `session.tabs.close`, and the - authoritative host interprets the close as user intent. Its PTY router then - forwards individual `kill` requests to whichever current or legacy adapter - claimed each reusable session ID during discovery. - -`client-hello` is necessary to discover and route legacy sessions, but neither -the hello handler nor legacy discovery sends a kill or shutdown request. The -~3.3-second renderer-bootstrap-to-kill evidence in #8871, repeated renderer -spawn/burst correlations, and the untyped close path are substantially stronger -than #9749's inference that greeting an old pipe causes that daemon to kill its -table autonomously. - -The immediate safety boundary is therefore destructive request authority and -provenance. Cross-profile, sleeping-session, and generation-retirement policy -remains the larger #9138/#9229 problem. - -## Deterministic native-Windows reproduction - -Reproduction snapshot: 2026-07-21 14:36 PDT (2026-07-21 21:36 UTC). - -`tests/e2e/daemon-generation-reconnect-safety.spec.ts` now constructs an -isolated `orca-9749-dg-*` runtime under the Windows temporary directory. It -starts three real daemon-server processes on versioned v21/v22/v23 named -pipes, with a live canary and a stale-mirror canary in each generation. Each -canary records the PTY-root and descendant PID/start identity independently. -The fixture refuses cleanup outside its exact temporary root and terminates -only recorded fixture process incarnations; it never enumerates or connects to -installed Orca endpoints. - -The Electron-as-Node reconnect client performs three production -`DaemonPtyAdapter`/`DaemonPtyRouter.discoverLegacySessions` bursts, reattaches -every original PID, pings every canary, and opens a simultaneous second client -to each generation. It then sends duplicate `session.tabs.close` RPCs carrying -`reason: 'pty-exit'` through the real schema, `OrcaRuntimeService`, and routed -daemon adapters while the reconnect-client process remains alive. - -The current-main run failed at the intended external invariant: - -- before the close, all three daemons, six PTY roots, and six descendants were - alive; -- each daemon accepted eight new control/stream hellos across the reconnect and - parallel-client cycles; -- v21, v22, and v23 each logged two `session-killed` requests for the same - stale-mirror session ID within 1–4 ms; -- all three stale-mirror PTY roots and descendants exited, while all three - unrelated live canaries, all daemon processes, and reconnect client PID 26876 - remained alive; -- the final assertion requires those stale-mirror roots to remain alive, so it - is RED before the fix and will become GREEN only when lifecycle-originated - closes are adjudicated non-destructively at the host. - -Focused command: - -```text -pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -``` - -The captured per-generation timestamp/PID/hello/kill/liveness report is written -to the Playwright test output as `daemon-generation-reconnect-events.json`. - -## Release and protocol chronology - -| Release or branch | Date | Daemon protocol | Relevant lifecycle behavior | -| --------------------- | ---------- | --------------: | -------------------------------------------------------------------------------------------------------------------------- | -| `v1.4.141` | 2026-07-14 | 21 | Legacy adoption already intentional; #8871 observed here. | -| `v1.4.142` | 2026-07-15 | 22 | New versioned endpoint; v21 can remain reattachable. | -| `v1.4.143` | 2026-07-16 | 22 | #9138 macOS accumulation reported. | -| `v1.4.144` | 2026-07-17 | 22 | #9195 Windows survival reported. | -| `v1.4.145` | 2026-07-18 | 22 | No generation-retirement change. | -| `v1.4.146` | 2026-07-19 | 23 | #9749's old current generation; already contains #8661's continue-shutdown-on-dispose-failure behavior. | -| `v1.4.147` | 2026-07-20 | 24 | First stable release containing #9277: authenticated identity and atomic empty-daemon retirement. | -| `v1.4.148` | 2026-07-21 | 24 | Reporter current release; legacy v23 and older remain intentionally adoptable. | -| `main` at `937a2015e` | 2026-07-21 | 25 | Protocol bumped by #9651 (`cc44acaaa`) for PTY startup ingress; v24 is now legacy. No stable tag contains this commit yet. | - -`PREVIOUS_DAEMON_PROTOCOL_VERSIONS` is cumulative rather than one-version-only. -At the investigated `main`, it contains versions 1 through 24. - -## Investigation matrix - -Confidence is **proven** when the issue evidence and source/diff establish the -mechanism, **supported** when multiple observations fit a reachable source path, -and **suspected** where attribution is missing. - -| Item | State; dates; releases/protocols | Exact symptom | Mechanism and relevant code/commits | Shipped status; unresolved #9749 relevance; scope boundary | -| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#9749](https://github.com/stablyai/orca/issues/9749) | Open; 2026-07-21; Windows 11; daemon-host 1.4.146→1.4.148; old v21/v22/v23 and current v24 observed | Three surviving daemons were greeted at `16:41:32.277Z`–`.286Z`; later bursts logged 55/30/65/60/60 v22 kills and 10/32 v23 kills, including repeated IDs, while 191,266 main-process spans showed no coincident app death. One v23 broad `shutdown reason:rpc killSessions:true` and repeated `shutdown-dispose-failed` were present. | **Supported composition, reporter causality not proven.** Versioned legacy adoption explains hellos; `cleanupDaemonForProtocol` explains broad shutdown; #8871's stale-mirror close echo best explains reconnect-time individual kills. `daemon-server.ts` logs `session-killed` before awaiting `host.kill`, so repeated records prove repeated requests, not repeated physical death. | No fix shipped for the destructive initiator. #9277 mitigates empty v24+ accumulation only. Skipping all legacy hello or killing legacy generations on sight would destroy the promised warm-reattach behavior and is unsafe scope creep. | -| [#9138](https://github.com/stablyai/orca/issues/9138) | Open; 2026-07-17; macOS 1.4.143/v22; observed v18/v20/v21/v22 | UI showed 8 sessions while about 46 Claude processes remained; removing stale trees removed about 370 processes and reduced swap from 25 GB to under 5 GB. | **Proven accumulation, ownership policy deliberately conservative.** Normal disconnect, versioned endpoints, incomplete cross-profile ownership, sleeping/cold restore, and legacy wildcard claims prevent safe blind retirement. Prototype commit `7c915909b` implements an audit/journal/incarnation design but is unmerged. | No complete fix shipped. #9277 safely handles only empty current-generation v24+ daemons. The broader ownership journal, grace period, all-profile evidence, and enforcement rollout belong here, not the immediate #9749 close-authority patch. | -| [#9211](https://github.com/stablyai/orca/issues/9211) | Closed duplicate 2026-07-17; v21/v22 | A v21 daemon remained four days beside v22, and each app launch greeted both. | **Proven intentional legacy discovery with missing retirement.** `createLegacyDaemonAdapters` plus `discoverLegacySessions`. | Consolidated into #9138; no separate shipped fix. Same accumulation input as #9749, but not its destructive initiator. | -| [#9229](https://github.com/stablyai/orca/issues/9229) | Open P1; 2026-07-17; Linux headless | Failed shutdown left a 15.55 GiB/81-process old runtime beside a 1.21 GiB/4-process replacement. | **Supported reconciliation design.** Requires provenance, one destructive authority, exact daemon/session PID+start incarnations, complete evidence, and fail-closed missing reads. | Unshipped. Supplies invariants for later reconciliation. Implementing its full profile migration and journal in #9749 would be unsafe scope expansion. | -| [#9195](https://github.com/stablyai/orca/issues/9195) | Open; 2026-07-17; Windows 1.4.144/v22; comments through #9277 release read | `orca-terminal-daemon.exe` remains after app exit; later comments observed v21/v22/v23 and invisible sessions. | **Proven intentional for non-empty daemons.** Normal quit calls `disconnectDaemon`, not `shutdownDaemon`; the child is detached/unref'd. Empty accumulation was an unhandled gap before #9277. | #9277 shipped in 1.4.147 for empty v24+ only. Treating every survivor as a bug would regress warm reattachment. | -| [PR #9277](https://github.com/stablyai/orca/pull/9277) | Merged 2026-07-20 as `7adda25b0`; first stable `v1.4.147`; v23→v24 | Empty current-generation daemons accumulated after clean app disconnect or failed adoption. | **Proven by diff/tests.** Hello returns PID/start-time/launch-nonce identity; control/stream identity must match. `shutdownIfIdle` atomically checks one complete client, no unknown transport, no sessions, and no admission in flight. Shutdown fences admission before disposal. A two-minute watchdog covers only never-adopted startup. | Shipped in `v1.4.147-rc.4`, `v1.4.147`, `v1.4.148-rc.1`, and `v1.4.148`. It explicitly does not perform startup reaping, retire legacy daemons, infer ownership, or kill reattachable PTYs. Strong identity/capability foundation; partial mitigation only. | -| [#8871](https://github.com/stablyai/orca/issues/8871) | Open P0; 2026-07-15; first observed 1.4.141/v21; all corrections/comments read | First reconnect kill followed renderer bootstrap by 3.348 s. Three later fresh renderer processes correlated within about 1 s with bursts killing 4, 7, and 7 worktrees while app and daemon PIDs remained stable. | **Supported-to-proven request path.** Persisted remote handle → subscribe returns `no_connected_pty` → synthetic `pty-exit` → `closeTerminalTab(reason:'pty-exit')` → reason dropped from `session.tabs.close` → `closeMobileSessionTab` kills host PTY. Early pre-connect kills were correctly downgraded to unattributed. Close propagation intersects landed #8628 and later #8958. | Not fixed on main. This is the strongest immediate #9749 foundation. Trigger repair alone is insufficient; the authoritative host must adjudicate intent. | -| [PR #8872](https://github.com/stablyai/orca/pull/8872) | Open; 2026-07-15; head `bb69fac775`; rebased 2026-07-21 | Prevents mirror `pty-exit`/cleanup evidence from killing a host PTY while retaining real user close and dead-tab retirement. | **Actual diff/tests inspected.** Threads typed `user`/`pty-exit`/`cleanup`; host refuses non-user closes if any parent leaf has a connected PTY, republishes an unchanged snapshot with a guarded replay marker, and handles dead-leaf/live-sibling loops. Adds an optional daemon `kill intent:'auto'` guard, but intentionally sends no auto intent until protocol/capability negotiation exists. | Unshipped. The earlier Windows/WSL live matrix passed on an older head and was explicitly invalidated by the rebase, so native validation must be rerun. This is the best narrow safety foundation, but reasonless old clients remain destructive on a new host. | -| [PR #8888](https://github.com/stablyai/orca/pull/8888) | Open; 2026-07-15; head `b13235211`; reviews/follow-ups read | Ambiguous paired-runtime close RPCs could destroy host tabs with no requester attribution. | **Actual diff/tests inspected.** Default-denies intent-less paired-runtime `session.tabs.close`, `terminal.close`, and `terminal.closeTab`; validates user source/target, dedupes request IDs per device across reconnects, rate-limits, restricts create rollback to its connection, and traces device/connection/source/decision without bearer tokens. Adds local daemon-control client ID to `session-killed`. | Unshipped. Useful defense-in-depth and logging foundation. Its “old paired client gets successful no-op” behavior is safely conservative but needs explicit compatibility/product acceptance; taking the entire policy is broader than the smallest #9749 fix. | -| [#9414](https://github.com/stablyai/orca/issues/9414) | Open tracking issue; 2026-07-19 | Recognition-dependent terminal lifecycle actions can destroy or strand arbitrary processes. | **Proven design boundary.** Host owns adjudication; stale client transport state and agent-name recognition are not kill authority. Tracks #8872/#8888 and lifecycle-state work. | Unshipped tracker. Aligns with #9749 invariants; generic agent recognition work is separate scope. | -| [PR #8628](https://github.com/stablyai/orca/pull/8628) | Merged 2026-07-14 as `36cd8a334`; releases after that date | Tab close did not durably retire all associated terminal/session state. | **Actual merge diff inspected.** Centralized terminal retirement/ownership and ensured remote-owned closes reach the authoritative host. The remote call still carried no close reason, so lifecycle and user closes were indistinguishable. | Shipped and important context, not a regression to revert wholesale. #9749 must preserve its durable explicit-user close while adding authority. | -| [#8878](https://github.com/stablyai/orca/issues/8878) / [PR #9098](https://github.com/stablyai/orca/pull/9098) | Issue and PR open; 2026-07-15/17; PR head `9f3abd778` | A reconnecting paired client resumes a provider session still live on the host, producing duplicate TUIs. | **Proven companion feedback loop.** Client-local resume runs before authoritative mirror arrival; a #8871 kill also leaves a resume record. PR gates resume for runtime-owned worktrees while preserving the record. | Unshipped. Relevant reconnect stress case, but provider-session resume dedupe is not needed to stop #9749 kills. | -| [#9352](https://github.com/stablyai/orca/issues/9352) / [#9585](https://github.com/stablyai/orca/issues/9585) | Open; 2026-07-18/20; remote macOS and Windows | Closed or killed remote tabs return as `ptyId:null` phantoms; host snapshots keep dead terminal surfaces, and repeated host restarts accumulate them. | **Proven stale mirror/snapshot state.** Host `touchMobileSessionSnapshotsForPty` republishes the same tab; viewer mirrors every terminal surface. Remote transport also leaks the explicit-kill error. | Unshipped. Explains repeated stale close inputs and must be in stress coverage, but pruning dead surfaces is a separate lifecycle fix requiring sleeping-session care. | -| [#9217](https://github.com/stablyai/orca/issues/9217) / [#8970](https://github.com/stablyai/orca/issues/8970) | #9217 closed duplicate 2026-07-19; #8970 open; v1.4.143 | Agent/sidebar rows remain after local close or SSH relay connection loss. | **Proven UI/status lifecycle gaps.** SSH teardown intentionally preserves PTY ownership for reattach but omitted status clearing; renderer lacks a sweep. | Separate non-destructive roster cleanup. Combining sidebar cleanup with #9749 would be scope creep. | -| [#8851](https://github.com/stablyai/orca/issues/8851) / [PR #8825](https://github.com/stablyai/orca/pull/8825) | Issue closed; PR merged 2026-07-15 as `c8986ca52`; 1.4.143 notes | Finished named Claude children remained as idle sidebar rows. | **Proven Claude roster mechanism, not PTY lifecycle.** Working-only roster and hydration cleanup. | Shipped, but #8970 proves top-level/session residuals. Unrelated to destructive daemon authority. | -| [#8275](https://github.com/stablyai/orca/issues/8275) / [#8276](https://github.com/stablyai/orca/issues/8276) | #8275 open `cannot_repro`; #8276 closed duplicate; 2026-07-11; v20→v22 attempts | Rapid worktree removal was followed by daemon death and unrelated split panes exiting `-1`; no shutdown record. | **Different mechanism.** Shared daemon process dies during PTY teardown; current v22 attempt did not reproduce. | Not the #9749 initiator: #9749 daemons stay alive and log explicit shutdown/kill requests. Keep daemon-death regression coverage, but do not merge root causes. | -| [PR #8140](https://github.com/stablyai/orca/pull/8140) | Merged 2026-07-10 as `03a673708`/PR head `1710325e0`; fixes #8048 | Graceful then immediate Windows PTY teardown double-closed the same ConPTY handle and killed the shared daemon. | **Proven by actual native harness/diff.** `nodePtyKillIssued` makes later Windows force a no-op. Harness uses unique pipe/temp state, 25 victims, and a witness PTY/daemon survival assertion. | Shipped. Prevents one daemon-death path; does not authorize reconnect closes and does not retire descendants. Reuse its isolation/witness patterns only. | -| [PR #8284](https://github.com/stablyai/orca/pull/8284) | Closed unmerged 2026-07-16; head `ebe0b6523` | Proposed serial worktree PTY admission and verified fail-closed teardown across Windows/POSIX/SSH. | **Actual 50-file diff/tests inspected.** Retained shutdown ownership, admission fences, physical-exit proof, and Windows relay ConPTY ownership. | Contrary to the issue prompt, it did **not** merge. Landed work was split/superseded by #8661 and #8706. Its teardown patterns are useful, but adopting its broad branch is unsafe. | -| [PR #8661](https://github.com/stablyai/orca/pull/8661) | Merged before 1.4.146; key commit `a635ff9a7` | Disposal failure could prevent orderly runtime/daemon termination. | **Proven current source behavior.** Shutdown RPC catches `host.dispose`, logs `shutdown-dispose-failed`, then continues fencing, client destruction, and server close; resource disposal is retried. | Shipped before #9749. Corrects the report's inference that the failure necessarily leaves the endpoint authoritative forever. A native handle may still keep a process alive, but the server intends to close. | -| [#9045](https://github.com/stablyai/orca/issues/9045) | Open; 2026-07-16; Windows | Worktree deletion fails because agent descendants retain filesystem handles after PTY teardown. | **Supported descendant ownership gap.** Root PTY exit is not full tree exit on Windows. | Unresolved. It concerns cleanup after a legitimate kill, not who may initiate it. | -| [#9704](https://github.com/stablyai/orca/issues/9704) | Open; 2026-07-21; Windows 1.4.147/v24 | Killed PTY descendants survive (six trees/18 processes/~1.1 GB, later ~2 GB); runtime lists dead sessions as connected. | **Proven descendant leakage plus stale registry.** Explicitly asks for initiator attribution rather than assuming the kill was valid. | Unresolved. Different from #9749 because the root session was killed; #9704 concerns what survives afterward. | -| [PR #9752](https://github.com/stablyai/orca/pull/9752) | Open; 2026-07-21; head `6be947317` | Windows agent descendants survive explicit or natural PTY-root exit. | **Actual native patch and tests inspected.** Suspends recognized native-Windows agent ConPTY roots, creates/configures/assigns a kill-on-close Job Object before resume, owns the handle atomically, and falls back to direct-root termination. Plain terminals, WSL, POSIX, SSH relay, and unrelated sessions are excluded; Windows process-table commands are removed. | Unshipped. Correct post-authorization cleanup, explicitly not an initiator fix. Combining it into the #9749 authority patch would obscure causality; compose/test separately if it lands first. | -| [PR #9266](https://github.com/stablyai/orca/pull/9266) / [PR #9612](https://github.com/stablyai/orca/pull/9612) | Both open; 2026-07-18/20 | Alternative Windows descendant tree termination using process enumeration/taskkill-style sweeps. | **Competing cleanup approaches.** Carry PID-reuse, access-denied, cost, and partial-tree risks that native Job ownership avoids. | Unshipped. Do not duplicate inside #9749. | -| [PR #8706](https://github.com/stablyai/orca/pull/8706) | Merged 2026-07-15 as `40d015992`/merge `6dbeeda3e`; stable thereafter | POSIX agent descendants survived root teardown. | **Proven POSIX snapshot-before-root-kill.** Windows intentionally returns no snapshot. | Shipped for macOS/Linux only. No #9749 authority effect. | -| [#9193](https://github.com/stablyai/orca/issues/9193) / [PR #9288](https://github.com/stablyai/orca/pull/9288) | Both open; 2026-07-17/18 | `terminal close --tab` cannot address live floating/tabless PTYs; pane close can kill, and Windows may retain a stale entry. | **Proven addressing/registry gap.** PR routes tabless PTY through existing pane close. | Unshipped. Diagnostic and stale-registry relevance only; making more sessions closeable is not authority fencing. | -| [#9563](https://github.com/stablyai/orca/issues/9563) / [PR #9634](https://github.com/stablyai/orca/pull/9634) | Both open; 2026-07-20/21; headless macOS | LaunchAgent host invokes update, disconnects clients, old binary respawns, and ShipIt reports “App Still Running.” | **Supported updater/headless ownership race.** PR defers installation while serving headlessly. | Unshipped. Must be a relaunch scenario in the harness, but updater policy is separate from close authority. | -| [#8261](https://github.com/stablyai/orca/issues/8261) | Open; 2026-07-11; macOS/Linux headless comments | Silent update installation kills active PTYs; one headless log has `shutdown reason:rpc killSessions:true`. | **Supported broad shutdown during update.** | Unresolved adjacent caller/lifecycle context. Updater UX and install policy are scope creep; broad shutdown attribution is relevant. | -| [#8459](https://github.com/stablyai/orca/issues/8459) | Open; 2026-07-13 | Resource Manager labels live daemon sessions orphan from renderer-only evidence and bulk-kills them. | **Proven ownership-safety precedent.** Absence from one renderer is not authority. | Unshipped. Same invariant as #9749, different UI initiator. Do not couple UI resource-manager redesign. | -| [#8585](https://github.com/stablyai/orca/issues/8585) | Open; 2026-07-13; SSH relay | Failed relay `--connect` unlinks a socket while the old relay and PTYs remain alive. | **Different namespace/transport ownership leak.** | Unresolved. SSH regression consideration, not a native daemon-generation fix. | -| [#7783](https://github.com/stablyai/orca/issues/7783) | Open; 2026-07-08; macOS | Helper survives app quit with roughly 189 descendants. | **Supported historical survival/descendant leak.** | Unresolved adjacent accumulation evidence; no reconnect-kill attribution. | -| [#8457](https://github.com/stablyai/orca/issues/8457) | Open; 2026-07-13 | Headless serve and GUI relaunch ownership collide, interrupting or duplicating live agents. | **Supported multi-owner lifecycle conflict.** | Reinforces exactly-one reconciliation authority. Full headless lifecycle redesign is separate. | -| [#8362](https://github.com/stablyai/orca/issues/8362) | Open; 2026-07-12; remote relay | PTY master FDs leak across relay children. | **Different mechanism: missing close-on-exec/inherited descriptors.** | Unresolved, but unrelated to named-pipe discovery or destructive requests. | -| [#9569](https://github.com/stablyai/orca/issues/9569) / [PR #9587](https://github.com/stablyai/orca/pull/9587) | Issue closed 2026-07-20; PR open | Worktree removal dials a dead legacy v22 socket after v23 upgrade and fails ENOENT. | **Proven stale adapter routing.** PR tolerates dead legacy adapter teardown. | Unshipped. Demonstrates adapter lifecycle staleness; no authority fix. | -| [#8689](https://github.com/stablyai/orca/issues/8689) / [PR #8697](https://github.com/stablyai/orca/pull/8697) | Closed/merged 2026-07-14; merge `840d3277d` | Daemon accepts a connection but never answers hello, wedging startup. | **Proven bounded handshake/replacement path.** | Shipped. Harness must bound hello and avoid reconnect storms; not a session-kill mechanism. | -| [PR #7538](https://github.com/stablyai/orca/pull/7538) | Merged 2026-07-07 as `03cfc5bd1` | Windows update moved daemon code while live daemon/PTYs should survive. | **Proven historical compatibility intent.** Relocated host preserves same-protocol daemon across update. | Shipped. Later legacy adapters extended preservation across protocol bumps; blanket reaping would regress this contract. | -| [PR #2974](https://github.com/stablyai/orca/pull/2974) | Merged 2026-05-28 as `5a852415a` | Resolver refresh risked killing live PTYs. | **Proven preserve-live policy.** Protocol bump/legacy routing is preferred to broad cleanup. | Shipped. Strong evidence against “kill old generation on sight.” | -| [PR #7836](https://github.com/stablyai/orca/pull/7836) | Merged 2026-07-18 as `5f6728c1b` | Shutdown/provider selection race could clear a binding while the daemon PTY survived. | **Proven ownership race fix.** Retains provider/shutdown ownership until outcome. | Shipped. Preserve in regression tests; not close-intent adjudication. | -| [PR #1343](https://github.com/stablyai/orca/pull/1343) | Merged 2026-05-03 as `df1fefcc2` | Users lacked session visibility; stale PID files risked PID-reuse mistakes. | **Proven management/PID-start-time guard.** | Shipped. Useful diagnostic/incarnation precedent, but observability alone cannot prevent #9749. | -| [PR #9516](https://github.com/stablyai/orca/pull/9516) | Merged 2026-07-20 as `2e67af82d`/`de86f482c` | Windows worktree teardown RPCs could hang indefinitely. | **Proven bounded-deadline change.** | Shipped. Bounds cleanup but does not decide whether cleanup is authorized. | -| [PR #8768](https://github.com/stablyai/orca/pull/8768) / [PR #8817](https://github.com/stablyai/orca/pull/8817) | Merged 2026-07-14/15 as `02de3c565` and `f7926c11f` | Restored/legacy PTYs could render blank or be unmounted while still live. | **Proven adoption compatibility.** Keep legacy daemon PTYs mounted and defer snapshots correctly. | Shipped. Direct reason that refusing all legacy discovery is unsafe. | -| [#9441](https://github.com/stablyai/orca/issues/9441) / [PR #9446](https://github.com/stablyai/orca/pull/9446) | Open; 2026-07-19; macOS 1.4.146 | Large persisted profile drives high CPU/RSS and exits during startup restoration; clean user-data does not reproduce. | **Supported restore-load ordering issue.** PR defers full worktree scan. | Unshipped. Profile-switch/load stress case only; not destructive daemon authority. | - -## Timestamped incident and call-flow reconstruction - -### A. How generations survive - -1. **2026-07-15 13:50 local:** #9749's v21 daemon starts. -2. An app quit/update runs `disconnectDaemon`, whose adapter `disconnectOnly` - closes client sockets and leaves live PTYs/history reattachable. The daemon is - detached/unref'd, so parent death is not daemon death. -3. **2026-07-18 14:26 local:** a v22 daemon starts on a different versioned - endpoint while v21 retains its PTYs. -4. **2026-07-20 14:50 local:** v23 starts while both prior endpoints remain. -5. Windows endpoints are generated by - `getDaemonSocketPath(runtimeDir, protocolVersion)` as - `\\?\pipe\orca-terminal-host-v-`. - Tokens and PID records are likewise protocol-specific files. No endpoint - collision forces an old generation out. -6. Before protocol 24, a non-empty daemon had no generation-retirement - protocol. Since #9277, an empty v24+ daemon can atomically self-retire, but a - live session intentionally blocks it and v23-or-older behavior is unchanged. -7. #9749's claim that `shutdown-dispose-failed` necessarily leaves the pipe open - is not source-proven for 1.4.146. Commit `a635ff9a7` catches disposal failure - and continues ordinary shutdown; #9277 additionally closes admission first. - A stuck native handle may keep a process alive, but the endpoint is meant to - stop being authoritative. - -### B. Why startup greets every surviving generation - -1. Current startup establishes the v25 (v24 in the reporter build) adapter and - its complete control/stream lifecycle lease. -2. `createLegacyDaemonAdapters(runtimeDir)` loops every value in - `PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, derives that version's pipe/token/PID - paths, and probes each endpoint. -3. A responsive endpoint receives a `DaemonPtyAdapter` configured with that - exact old protocol. It has no respawn callback, because new code must not - recreate old environment semantics. -4. `DaemonPtyRouter.discoverLegacySessions()` calls `adapter.listProcesses()` - for each legacy adapter. -5. `listProcesses()` calls `ensureConnected()`. `DaemonClient.doConnect()` opens - a control socket, sends hello, then opens a stream socket and sends hello - using one client UUID. This is the precise source of each control/stream pair - in #9749 at `16:41:32.277Z` through `.286Z`. -6. v24+ hellos return PID/start-time/launch-nonce and require both sockets to - match. Old protocols return no identity, so successful token+protocol hello - authenticates the endpoint but cannot prove a process incarnation. -7. `listSessions` results populate `sessionAdapters: Map`. - That map is keyed only by reusable session ID, not daemon/session - incarnation; a later generation can overwrite an earlier claim. -8. No code in hello acceptance, adapter construction, or discovery sends - `kill`, `shutdown`, or `shutdownIfIdle` to a legacy daemon. - -### C. Exact broad shutdown caller - -1. Current-daemon replacement/manual restart/full cleanup calls - `cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)` (or the explicitly - supplied current handle protocol). -2. It probes that version's endpoint and creates a `DaemonClient` for the same - protocol. -3. It connects with the same control/stream hello sequence. -4. It requests `listSessions`; any error is converted to `{ sessions: [] }`. -5. It then unconditionally sends `shutdown { killSessions: true }` and treats a - reply race with daemon exit as success. -6. `daemon-server.ts` logs `shutdown reason:'rpc' killSessions:true`, begins the - ordinary shutdown admission fence, awaits `host.dispose`, logs but catches - `shutdown-dispose-failed`, writes the reply if possible, disposes resources, - destroys clients/transports, unlinks owned identity artifacts, and closes - the server. -7. Repository search found no other direct production sender of - `shutdown { killSessions: true }`. Legacy adapter discovery is not a caller. -8. Unsafe residual: list failure cannot be treated as proof of emptiness for - any future generation reaper, and broad shutdown still lacks an origin, - daemon-incarnation, ownership, and intent audit record. - -### D. Supported reconnect-to-mass-kill path - -1. **Renderer bootstrap T+0:** a paired desktop/runtime renderer hydrates - persisted mirrors containing process-lifetime remote terminal handles. -2. **T+milliseconds:** before the fresh authoritative session snapshot fully - reconciles, remote transport subscribes using a stale handle. -3. The host cannot resolve a connected PTY for that handle and returns a gone - condition; remote transport synthesizes `pty-exit`. -4. `Terminal.tsx` calls `closeTerminalTab(tabId, { reason: 'pty-exit' })`. -5. Current main uses that reason for local retirement behavior but - `closeWebRuntimeSessionTab` sends only `{ worktree, tabId }`. -6. Runtime RPC `session.tabs.close` has no origin/intent in its schema or - handler. `OrcaRuntimeService.closeMobileSessionTab` treats it like an - explicit user request and invokes either direct `ptyController.kill`, a - whole-parent renderer close, or headless teardown. -7. For daemon-backed terminals, the PTY controller is the `DaemonPtyRouter`. - `adapterFor(sessionId)` uses the discovery map to select a current or legacy - generation and its adapter sends `kill { sessionId, immediate }`. -8. The owning daemon logs `session-killed` before awaiting `host.kill`. This is - why app and daemon PIDs remain alive while real terminal sessions disappear. -9. **#8871 observed T+3.348 s** for the first bootstrap-to-kill incident. Three - other new renderer processes aligned within about one second with 4-, 7-, - and 7-worktree kill bursts. -10. Stale host tab surfaces (#9352/#9585), persisted client mirrors, reconnect - retries, and republished snapshots can invoke the same close again. If the - daemon still retains the session entry or physical exit is unresolved, a - reconnect can rediscover and target the same ID again. Because logging - precedes the awaited kill, repeated `session-killed` records can also be - repeated failed/not-yet-settled attempts; they do not prove a dead process - was resurrected and killed twice. - -### E. Why adjacent bugs are different - -- **#8275/#8048/#8140:** PTY teardown double-closes a Windows ConPTY native - handle and the shared daemon dies. There is no broad shutdown event. All - sessions disappear because their owner process died. -- **#9704/#9045/#9752:** a valid or invalid kill has already targeted the PTY - root, but Windows descendants remain and retain memory/files. The owner stays - alive or the registry stays stale. This is cleanup completeness after a kill, - not destructive authority. -- **#9749/#8871:** app and daemons remain alive. Explicit RPCs reach live daemon - sessions because stale client lifecycle evidence is interpreted as intent. - -## Compatibility behavior that must remain - -- App quit is a disconnect, not terminal shutdown. -- Same-protocol daemon survival across packaged Windows updates (#7538) avoids - terminating live work. -- Old-protocol adapters remain addressable after an upgrade so mounted and - sleeping PTYs can reattach (#2974, #8768, #8817). -- Legacy daemons cannot be judged empty from one current profile or renderer. - Missing ownership data, inactive profiles, sleeping sessions, remote/SSH - routes, and legacy claims all mean keep/audit. -- Current protocol 24+ can retire only after its own atomic server-side idle - predicate proves there is nothing to preserve. -- Older clients/servers must not infer support from an ignored additive field. - In particular, no caller may send daemon `kill intent:'auto'` until a - protocol bump or negotiated capability proves the daemon will enforce it. - -## Direction comparison - -| Direction | Immediate safety | Compatibility and failure mode | Decision | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| Skip `client-hello` for every old protocol | Avoids routing kills to legacy sessions, but also makes their live PTYs invisible/unattachable. | Directly violates warm legacy reattachment and converts preservation into inaccessible leaks. | Reject. Hello is not the authority bug. | -| Force-exit on `shutdown-dispose-failed` | Bounds a half-shutdown process if endpoint fencing and handle cleanup are correct. | Can silently destroy promised live PTYs; does not stop reconnect close RPCs; overlaps #9752 descendant semantics. Current source already continues server shutdown after disposal error. | Not the immediate fix. Test bounded endpoint loss separately. | -| Startup reaping | Can reduce accumulated attack surface. | Unsafe without #9138/#9229 all-profile evidence, exact incarnations, grace observations, barriers, and one authority. | Audit-only follow-up, not #9749 patch. | -| Explicit generation handoff/retirement | Correct long-term lifecycle model. | Requires old/new capability negotiation and ownership persistence across profiles, SSH, WSL, and sleeping sessions. | Broader #9138/#9229 work. | -| Daemon-side fencing | Prevents a retiring daemon from accepting new work or acting after retirement begins. | v24 #9277 already fences ordinary/idle shutdown admission; older protocols cannot be retrofitted. | Preserve and extend only with negotiated capability. | -| Host close-intent adjudication (#8872) | Stops the demonstrated stale `pty-exit` echo at the component that owns the live PTY, while real user close and genuinely dead tab retirement still work. | Old clients remain ambiguous unless paired with a default-deny compatibility policy. | Best narrow foundation. | -| Connection/device provenance policy (#8888) | Default-denies ambiguous paired-runtime destruction, adds dedupe/rate bounds and strong attribution. | Older paired clients receive a safe successful no-op; policy is broader than one close reason and must preserve mobile/CLI/local/SSH semantics. | Reuse focused policy/logging pieces; assess full policy after the failing harness. | -| Job Objects (#9752) | Reaps descendants after an authorized native-Windows agent PTY kill. | Cannot determine intent; plain terminal and WSL exclusions are semantically important. | Compose separately, never substitute for authority. | - -The smallest robust immediate change is expected to combine typed close intent -with host-side liveness adjudication and non-secret requester logging. The -three-generation harness must decide whether #8888's default-deny/dedupe layer -is also required for old-client and repeated-reconnect safety. Full generation -retirement stays in #9138/#9229. - -## Harness gate for the next phase - -The first executable artifact will be an isolated native-Windows harness, not a -production edit. It must: - -- allocate a temporary runtime/user-data root and unique pipe namespace; -- launch three disposable protocol fixtures (for example v21/v22/v23) with - distinct live canary children plus stale/terminating table entries; -- use the real `DaemonClient`, legacy adapter discovery, router, and runtime - session-close path where feasible instead of calling a proposed helper; -- prove hello/discovery alone is non-destructive; -- separately trigger the exact broad current-protocol shutdown caller and the - reconnect stale-mirror close path; -- distinguish daemon/app liveness, PTY-root liveness, and descendant liveness - with independent counters and process handles; -- repeat reconnect bursts and record duplicate requests for a retained ID; -- model a PTY owner whose disposal never proves physical exit and assert a - bounded, non-authoritative endpoint state; -- cover current→next protocol upgrade, same-version quit/relaunch, profile - switch, remote-server paired client, multiple simultaneous clients, mixed - client/server versions, and WSL/SSH routing boundaries; -- validate PID/start identity, dead parent, access-denied identity probes, no - listener/pipe/handle leaks, and no CIM/PowerShell per-session hot path; -- guarantee cleanup in `finally` using only fixture-owned exact PIDs, handles, - files, and pipe names. - -Externally visible RED invariants are: reconnect kills one or more canary PTYs -while the app/fixture processes remain alive; repeated stale input can issue -another destructive request for the same retained incarnation; and a refused -disposal does not reach a bounded terminal endpoint state. The post-fix GREEN -invariants are survival of every unrelated canary, one exact kill for an -explicit user target, legacy warm reattachment, bounded retirement fencing, and -zero leaked fixture processes/handles/pipes. - -## Implemented immediate safety boundary - -Implementation snapshot: 2026-07-21 15:26 PDT (2026-07-21 22:26 UTC). - -The initial implementation used a 30-second capability cache keyed only by -runtime-environment ID. That was rejected before finalization: a positive -`status.get` result could outlive a server replacement under the same -environment, and the later destructive request had no proof that it reached the -generation that supplied the capability. - -The implemented boundary makes compatibility and identity validation atomic -with the lifecycle request: - -1. Explicit user closes remain on `session.tabs.close`, preserving the durable - host-owned close behavior shipped by #8628 and compatibility with old - servers that ignore the additive `reason:'user'` field. -2. Renderer-originated `pty-exit` and `cleanup` echoes use the additive - `session.tabs.closeLifecycle` method. An old server returns - `method_not_found` before entering its legacy destructive close handler. The - client clears optimistic close suppression and requests an authoritative - snapshot; it never falls back to `session.tabs.close`. -3. The lifecycle method requires both the `publicationEpoch` observed in the - host snapshot and the exact terminal handle observed for that tab. Missing - evidence means keep, refresh, and audit rather than kill. -4. The host refreshes its PTY records, rejects a different publication epoch, - rejects a terminal handle that no longer belongs to the addressed parent, - rejects when provider liveness is unavailable, and rejects while any parent - leaf still has a connected PTY. Refusals carry a bounded reason - (`stale-publication`, `stale-terminal`, `unknown-liveness`, - `live-host-pty`, or `retirement-owner`) and republish only when doing so - cannot create the known dead-leaf/live-sibling echo loop. -5. A lifecycle close never signals a PTY or relays a renderer close. A dead - whole headless parent can be retired from persisted/runtime state with - `killPtys:false`; a renderer-owned parent or partial split remains with its - authoritative owner. Thus a reusable tab ID or incomplete provider read - cannot become destructive authority. -6. Reasonless closes from authenticated legacy mobile or runtime clients retain - their pre-change explicit-user meaning, so upgrading only the host does not - break close. Their old lifecycle/user ambiguity remains until the client - upgrades; unattributed in-process reasonless calls are refused and replayed. -7. Renderer close intents are scoped by runtime environment and worktree, and - terminal-incarnation evidence must match that exact runtime environment. - Identical tab/worktree IDs in another profile cannot suppress or authorize - this profile's retirement. - -The RPC span records origin/client kind, close reason, connection ID, request -ID, publication epoch, and allow/refusal decision. It does not record terminal -contents, authentication tokens, or environment secrets. The legacy adapter -hello/discovery path, warm PTY adoption, daemon shutdown protocol, and Job -Object descendant cleanup remain unchanged. - -This is the smallest immediate #9749 fix. Cross-profile generation inventory, -all-profile ownership evidence, sleeping-session policy, and explicit daemon -handoff/retirement remain #9138/#9229. Windows descendant reaping after an -authorized kill remains #9704/#9752. - -## GREEN evidence - -The post-fix native-Windows run completed at 2026-07-21 15:25 PDT: - -- v21/v22/v23 used distinct versioned named pipes under a disposable runtime - root, with six independent PTY-root/descendant canaries; -- three reconnect discovery bursts plus simultaneous clients opened the real - control/stream hello pairs without destructive side effects; -- desktop and two remote/profile connection identities attempted each stale - mirror retirement three times, then a full reconnect-client process - exit/relaunch repeated the same persisted IDs for six attempts total; -- the lifecycle requests traversed the production schema, dispatcher, - `OrcaRuntimeService`, and `DaemonPtyRouter` using publication/terminal - incarnation claims; -- every daemon, PTY root, and descendant remained alive while the app/client - process remained alive; -- the contained refusal-to-exit PTY produced `shutdown-dispose-failed`, lost - named-pipe authority within the bounded deadline, and was cleaned by exact - fixture-owned process identity; -- the reconnect/relaunch test passed in 42.9 seconds and the bounded-disposal - test passed in 15.2 seconds. - -Focused verification at this checkpoint: node and web typechecks passed; -changed-file `oxlint` and `git diff --check` passed; 181 focused renderer/RPC -tests passed; and 12 host close-adjudication tests passed (784 unrelated tests -filtered out). No PowerShell/CIM process-per-session path, polling loop, broad -installed-daemon discovery, or Job Object implementation was added. - -Additional validation completed at 2026-07-21 15:48 PDT: - -- the native generation harness passed 25 reconnect bursts in 52.1 seconds; - every v21/v22/v23 daemon and all six PTY-root/descendant canaries survived; -- the mixed-version daemon-lifecycle E2E kept the non-empty v22 daemon - reattachable while the empty current v24 daemon retired through #9277; -- a fresh `pnpm build:electron-vite` and a fresh paired-web-client build both - completed successfully; -- 182 remote-runtime, multi-client, remote-server parity, SSH-provider, WSL - host-context, and remote PTY transport tests passed; -- the complete runtime service/RPC group passed 884 of 885 tests. The lone - failure, `preserves existing badgeColor on runtime createRepo dedupe`, is an - existing Windows-only POSIX path expectation (`/tmp/...` versus - `\\tmp\\...`) in code untouched by this change; every close, remote, SSH, - and WSL case in that run passed; -- CLI typecheck, switch-independent changed-file lint, reliability gates, - max-lines ratchet, and `git diff --check` passed. The repository-wide - switch-exhaustiveness command is blocked by the pre-existing unmatched - `undefined | 'current' | 'duplicate'` cases in - `skill-freshness-group.tsx`, outside this diff. - -Two practical Electron runs exposed setup/teardown limitations without -contradicting the close-safety result: - -- `restart-restore-terminal-input.spec.ts` completed the clean-restart, live - daemon, restored-output, keyboard-input, and direct-input assertions, then - failed only in `RestartSession.dispose()` with `EPERM` deleting its isolated - profile. Restart Manager and Sysinternals Handle found no surviving file - lock after teardown; the exact fixture root deleted successfully later - without terminating a process. The same failure reproduced with the temp - root inside this worktree and with the production diff to - `orca-restart.ts` empty, so it is recorded as a fixture cleanup limitation, - not a session-liveness failure; -- the paired-browser navigation E2E built the web client and launched the - isolated desktop, but timed out before pairing because its host fixture - displayed `No workspaces found`. The deterministic multi-client runtime - integration passed; no terminal-close assertion failed in this E2E. - -All diagnostic downloads and worktree-local E2E temp roots were removed by -their exact verified paths. No installed Orca daemon pipe or real user terminal -was discovered, greeted, stopped, or mutated during these runs. - -## Internal review-until-clean and final native evidence - -The requested `$internal-review-until-clean` loop ran against merge base -`937a2015eaf85144d02848c5b6d4c09ecd423830`. Round 1 found and fixed four -in-scope safety defects: - -- lifecycle retirement could still relay a destructive renderer close or kill - retained/disconnected headless IDs; lifecycle requests now never signal a - process and only state-retire a dead whole headless parent; -- an unavailable/access-denied PTY inventory was treated like authoritative - absence; it now returns `unknown-liveness` and keeps/audits; -- pending close intent was keyed only by worktree and could cross-contaminate - two runtime profiles; it is now scoped and cleaned by environment/worktree; -- the harness accepted a two-second PID start-time tolerance; capture and - revalidation now compare the same CIM `CreationDate` exactly. - -The elegance pass also removed an unnecessary cached capability probe. The -additive method dispatch on the exact connection is the atomic compatibility -boundary; a cached positive result could outlive server replacement. The -performance pass found no production polling, process enumeration, listener, -or subprocess addition. The existing bounded controller inventory refresh is -unchanged in frequency, snapshot refreshes coalesce per environment/worktree, -and all added tracking maps have completion or ownership cleanup. Round 2 -re-interrogated the full diff and found no remaining proven in-scope issue. -After strengthening the native harness to invoke production desktop discovery, -round 3 found one Windows-only test defect: three scanner tests modeled a live -v9 endpoint through POSIX `existsSync` but let every Windows named-pipe probe -connect. Their socket mock now accepts only v9 and errors every other version; -all 133 daemon lifecycle tests pass. The subsequent full-diff review is clean. - -The latest native Windows run captured its event reconstruction at -2026-07-21 16:58 PDT (2026-07-21 23:58 UTC). It launched v21/v22/v23/v24/v25 -on isolated versioned named pipes, then called the same -`createLegacyDaemonAdapters` scanner as desktop startup: the v25 client found -exactly v21-v24. Every generation accepted 16 reconnect control/stream hellos; -desktop and two remote-profile paths sent six lifecycle attempts for each -persisted stale-mirror ID across process relaunch. All five daemons, all ten -PTY roots, and all ten descendants were alive afterward, with zero -`session-killed` events. This directly covers the v24→v25 current-to-next -upgrade boundary while keeping older generations reattachable. - -The bounded `shutdown-dispose-failed` scenario also passed and now separates -authority from liveness explicitly: the late connection failed after endpoint -fencing while the refusing daemon, PTY root, and descendant remained alive -until exact fixture cleanup. The full Playwright command, including a fresh -Electron E2E build, exited successfully in 137.1 seconds, and no -`orca-9749-dg-*` directory remained. A final no-rebuild rerun passed both -scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed -again in 24.0 seconds, and mixed-version retirement/live-session preservation -passed 2/2 in 23.1 seconds. - -Latest focused verification includes 15/15 host adjudication tests, 948/949 -focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/ -retirement/access-failure tests, all three typechecks, and 215 -remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote -PTY, and shared-control tests. The sole focused failure remains the untouched -Windows `/tmp` normalization baseline documented above. The experimental -`terminal-session.daemon-generation-reconnect-safety` reliability gate records -the invariant, RED/GREEN oracle, performance budget, promotion criteria, and -known platform/provider gaps. - -## Fresh PR review after current-main integration - -The explicitly requested post-PR `$internal-review-until-clean` pass completed -at 2026-07-21 18:02 PDT after merging `v1.4.150-rc.0` main, including the -remote-runtime network-recovery work from #9774. It found and fixed three -additional contract gaps: - -- a stale PTY-exit callback could borrow a replacement or sibling handle from - tab-wide state; lifecycle evidence now comes only from the exact callback PTY; -- the legacy close endpoint accepted lifecycle reasons without incarnation - evidence; it now accepts only explicit user intent, with reasonless - compatibility retained for authenticated legacy mobile and runtime clients; -- keep-on-unknown preserved the PTY but could leave its client mirror hidden; - the host now republishes unchanged authority when inventory is unavailable. - -The PR feedback loop also corrected the bulk-close payload assertion and made -the fixture protocol list collision-safe. The final native no-build run passed -both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds, -mixed-version retirement passed, and practical Electron restart/input passed -two scenarios with one intentionally skipped wedge scenario. Current-main -remote recovery (112 tests), focused close/reconnect suites, all 15 host -adjudication cases, all typechecks, reliability gates, max-lines, changed lint, -formatting, and diff checks pass. The full lint command remains blocked only by -pre-existing current-main switch-exhaustiveness and localization findings; no -remaining in-scope review finding is open. - -## Additional clean review after `v1.4.150-rc.0` integration - -This additional requested `$internal-review-until-clean` pass completed at -2026-07-21 18:56 PDT (2026-07-22 01:56 UTC) against merge base -`4d0e3f51ce0325a8f4670b4074618494984ab63d`. Round 1 found and fixed four -in-scope evidence/performance gaps: - -- the native harness omitted production `DaemonPtyRouter.listProcesses`, so - its synthetic lifecycle closes could pass through `unknown-liveness`; it now - routes the production inventory, uses worktree-prefixed daemon session IDs, - and requires every close to return `live-host-pty` with a republished - snapshot; -- concurrent reconnect closes each started a full cross-generation PTY - inventory; the host now shares one in-flight inventory and a deterministic - count test proves two concurrent closes call `listProcesses` once; -- the renderer introduced a second snapshot-refresh map; it now reuses the - existing environment/worktree remote-session deduper used by PTY reconnect; -- the reliability gate omitted the parked-tab callback path; its exact exiting - PTY incarnation assertion is now part of the gate. - -The review also added a wire-compatibility assertion proving that the additive -`{ reason: 'user' }` field is stripped by the previous `ActivateTab` server -schema. The repeated elegance and performance interrogation found no remaining -avoidable infrastructure, polling, subprocess churn, listener/handle leak, or -unbounded reconnect work. Round 2 re-read the full production and fixture diff, -constructed stale-publication, stale-handle, concurrent-close, mixed-client, -and old-server failure paths, and found no remaining proven in-scope issue. - -Fresh verification includes all three typechecks; reliability-manifest, -max-lines, changed-file lint, formatting, and diff checks; 175 remote snapshot -and PTY transport tests; and the isolated lifecycle/reconnect adjudication -group. The strengthened native five-generation Windows harness passed after a -fresh Electron E2E build in 137.2 seconds, required `live-host-pty` for every -synthetic close, left every daemon/root/descendant alive, and left no -`orca-9749-dg-*` directory. The full focused gate reached 992/993 passing tests; -the only failure remains the untouched Windows `/tmp` versus `\\tmp` -`createRepo` baseline. The newly merged headless-update group reached 143/144 -passing tests plus 25 skips; its only failure is an untouched LF-only -source-text assertion that does not match CRLF on Windows. - -## Completion evidence matrix - -| Original acceptance requirement | Authoritative evidence | Status | -| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| Complete recursive issue/PR/commit/tag investigation before production edits | The 41-row investigation matrix above, release chronology, and timestamped call-flow reconstruction cover every required starting item and every directly discovered related item. GitHub reads were completed before the first production edit. | Proven | -| Explain survival, generation discovery/hello, broad shutdown caller, repeated reconnect kills, adjacent-bug distinctions, and compatibility | Sections A–E identify `disconnectDaemon`, `createLegacyDaemonAdapters` → `discoverLegacySessions` → control/stream hello, `cleanupDaemonForProtocol`, the stale-handle `pty-exit` path, #8275 versus #9704, and the warm legacy reattach contract. | Proven | -| Isolated native-Windows multi-generation reproduction with external liveness oracle | `daemon-generation-reconnect-safety.spec.ts` uses an exact temporary user-data/runtime root, versioned pipe names, disposable ConPTY children, independent daemon/root/descendant PID-start identities, bounded output, and exact cleanup. The historical RED run above recorded real repeated `session-killed` events while daemon/client witnesses stayed alive. | Proven | -| Exercise the desktop discovery path and current→next mixed-version upgrade | The latest native run calls production `createLegacyDaemonAdapters`; v25 discovers exactly v21/v22/v23/v24, reattaches every original process incarnation, and leaves all five generations alive. | Proven | -| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) | -| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven | -| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven | -| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and authenticated legacy versus unattributed reasonless callers. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven | -| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) | -| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven | -| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable | -| Practical Electron restart behavior | A real isolated Electron application created a daemon-backed terminal, wrote and restored output across clean app quit/relaunch, preserved the exact daemon PID, and accepted both keyboard and direct terminal input after reattachment. | Proven on native Windows (`electron-headless`, 24.0 s) | -| Internal review-until-clean and final gates | The original three review rounds and both additional PR review loops are clean. The latest loop fixed four oracle/performance/gate gaps, then completed a clean full-diff re-review. Typecheck, lint, format, reliability manifest, max-lines ratchet, Electron build, native harness, and focused suites pass apart from the documented untouched baselines. | Proven | -| Public GitHub and real daemon safety | No public GitHub mutation occurred. Fixture guards reject non-temporary roots and known Orca user-data paths; no installed pipe/token/session was discovered or contacted. | Proven | - -Live Linux SSH validation remains an explicit gap: the required throwaway -Docker target is unavailable on this runner (`docker` is not installed and no -Docker Desktop process or standard executable path exists), and `wsl.exe` -reports that WSL is not installed. A real remote or localhost was deliberately -not substituted. The Orca worktree comment was updated through the scoped -`worktree set --comment` CLI with the investigation, RED reproduction, -root-cause, implementation, native-validation, and clean-review milestones. -No terminal or daemon command was issued through the CLI, and no public GitHub -comment was made. diff --git a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md deleted file mode 100644 index b3453c0c1d2..00000000000 --- a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md +++ /dev/null @@ -1,1009 +0,0 @@ -# Direct SSH reconnect fan-out - -Status: implemented and validated; ready for maintainer merge - -This design was reviewed in two full rounds, reconciled with current main, and implemented across main, preload, renderer, shared contracts, reliability gates, and Docker SSH fixtures. Review findings are absorbed into the invariants and implementation below. - -Validated against `origin/main` at `21dee21a6d9d398bb332ddf0f85fbb4d5de7cd1b`. Current main includes remote-runtime resume/online recovery (#8255), worktree-owned multi-host routing (#10986), negotiated paired close intent (#10129), fail-closed runtime SSH setup (#10799), terminal-view parking (#11016), host-correct SSH folder adoption (#10818), hydration-loop ID indexes (#10891), runtime output chunking (#10915), consolidated changed-code quality gates (#11117), orchestration migration safety (#11107), and CLI-compatible remote timeout parsing (#11206). None replaces the direct desktop SSH reconnect path, but each constrains its ownership and lifecycle integration below. - -Scope: direct SSH reconnect recovery across main, preload, and renderer - -## Summary - -One direct SSH reconnect currently starts two sequential target-preparation waves, or three when a nonempty remote-workspace snapshot is applied. Each wave refreshes every target repo and then lineage. Because the next preparation starts after the previous one settles, the existing in-flight repo coalescer does not collapse these sequential scans. Simultaneous targets multiply the work, while disconnect handling separately calls the single-tab `clearTabPtyId` action once per live tab. - -Replace this with host-qualified, epoch-fenced provider reads and a renderer coordinator with two distinct modes: - -- reconnect finalization retries the exact target's terminal panes immediately, before optional discovery; -- preparation-only refreshes the exact target's catalog, worktrees, and lineage without remounting panes. - -Main owns one provider-incarnation authority per direct SSH target. It is a strict composition of the existing `connectionGeneration` and a new opaque `providerEpoch`: one helper rotates both atomically on the same transition set, and provider work carries and compares the pair. Existing file-mutation fencing continues to carry `connectionGeneration`, but it now advances on every provider-invalidating transition too; reconnect recovery cannot advance one clock without revoking the other. Every authoritative request carries the exact execution host and captured authority through preload to main; main selects the provider by that complete identity and revalidates the authority before any durable side effect. Renderer preparation deduplicates only overlapping work with identical concrete inputs. A completed preparation is not cached for the lifetime of an authority. - -Direct SSH provider calls use a dedicated fair limiter with five local slots, explicit deadlines, and cancellation. Cancel acknowledgement means local waiter settlement; the existing fire-and-forget `rpc.cancel` does not acknowledge relay-handler completion. A separate cancel-debt allowance bounds replacement admission while relay work may be finishing. The five-slot bound applies only to locally unsettled detected-worktree provider calls submitted by this coordinator; aggregate telemetry reports the late-work allowance and other same-relay traffic separately. Runtime discovery, sidebar refreshes, filesystem events, catalog reads, and lineage reads retain their own concurrency behavior and are measured separately. - -This remains separate from the sidebar fix in `9d3ae3adc7`, which does not own `ssh:state-changed`, remote-workspace preparation, or terminal binding cleanup. - -## Goals - -- Recover terminal panes without waiting for Git discovery on the same or another target. -- Keep coordinator-owned direct SSH provider work bounded and fair across targets. -- Make provider selection correct when repo IDs collide across local, direct SSH, and remote-runtime hosts. -- Reject obsolete provider results before any main or renderer authoritative mutation. -- Preserve final-state convergence for later same-connection remote snapshots and wake refreshes. -- Keep disconnect and retry scope symmetric across Git worktrees and folder workspaces. -- Preserve relay reattach identifiers and terminal/session recovery semantics. -- Make timeout, cancellation, non-authoritative data, and operational failure separately observable without exposing identifiers. - -## Non-goals - -- Changing SSH credentials, backoff policy, or user-facing reconnect controls. -- Moving runtime-owned work into the direct SSH coordinator. -- Changing the component-scoped sidebar refresh queue added by `9d3ae3adc7`; the shared detected-worktree provider coalescer does gain lease accounting. -- Expanding remote-workspace serialization to folder workspaces. -- Changing Git commands, worktree parsing, or Git capability detection. -- Changing the remote-runtime wire protocol. -- Establishing a renderer-wide or application-wide provider-call ceiling. - -## Current flow and root cause - -### Connected path - -`applySshConnectionStateChange` currently: - -1. filters `store.repos` by raw `connectionId`; -2. calls `Promise.all(remoteRepos.map(fetchWorktrees))`; -3. calls `fetchWorktreeLineage`; -4. scans target worktrees and bumps terminal generations one worktree at a time; and -5. calls `syncRemoteWorkspaceAfterConnect`. - -`syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, repeating repo and lineage refresh. A nonempty snapshot then reaches `applyRemoteWorkspaceSnapshot`, which prepares a third time. - -For a target with `R` repos, the connected path therefore performs `2R` detected-worktree scans and two lineage reads without a snapshot, or `3R` scans and three lineage reads with one. The existing detected-worktree single-flight joins only requests whose provider reads overlap. These preparation waves are sequential in the current call chain, so it does not collapse their scan count. Other overlapping callers can still join a scan; telemetry must measure observed calls rather than infer a global `kR` multiplier. - -### Disconnect path - -Terminal failure states walk repo-derived worktrees and call `clearTabPtyId(tab.id)` for each tab whose `ptyId` is present. The general single-tab action scans workspace buckets, clones global terminal maps, publishes store changes, bumps worktree activity, and can persist metadata on every call. - -For `T` live tabs, `W` workspace buckets, and terminal maps of size `M`, synchronous work is approximately `O(T × (W + M))`, plus repeated Zustand publications and session-persistence debounce resets. Connection loss is not user activity. - -### Boundary and ownership defects - -- Renderer `fetchWorktrees(repoId, { executionHostId })` uses the host to choose and stamp renderer ownership, but the local preload/main request currently carries only `repoId`. -- Main then calls first-match `store.getRepo(repoId)` and cannot enforce the renderer's intended host. The proven producer is renderer-catalog aliasing across hosts, not duplicate UUID rows created by main; same-ID main rows remain a defensive ambiguity case. -- Main can prune lineage and backfill metadata before renderer receives a result, so a renderer-only stale-result fence is insufficient. -- Current main-owned `connectionGeneration` and the renderer-local state-change counter have different sources and advance rules. Only the main-owned value is authoritative. -- Relay replacement can change the provider incarnation without advancing the existing main-owned generation. The correction is to rotate the generation and provider epoch together, not to use the renderer counter. -- Raw repo-ID filtering can select another execution host. Folder workspace keys are omitted entirely. -- Direct SSH catalog and lineage preparation follows focused-runtime ownership in some paths. -- Direct SSH lineage preparation currently calls bare `fetchWorktreeLineage()`, whose ownership can follow focused-runtime settings rather than the SSH target. - -### Deterministic current-main baseline - -The falsifiable current-main invariants are: - -1. one connected direct SSH event must issue at most one detected-worktree scan per exact repo input and one host-qualified lineage read; -2. an authoritative detected-worktree or lineage result must identify one exact execution host and, for direct SSH, the complete provider authority; -3. explicit worktree, repo-derived, runtime-owner, folder, and PTY provenance must not contradict one another; and -4. a first timeout classified as retryable must not release lineage, sync, or token creation before its retry reaches a terminal outcome. - -Current main violates the first two at the smallest deterministic seams: - -- `applySshConnectionStateChange` runs `fetchWorktrees` plus lineage and then calls `syncRemoteWorkspaceAfterConnect`; -- `syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, and a nonempty snapshot calls `applyRemoteWorkspaceSnapshot`, which calls it again; -- the shared renderer coalescer joins only overlapping promises and deletes the entry when they settle, so these awaited sequential waves remain `2R` scans and two lineage reads without a snapshot or `3R` and three with one; -- renderer refresh selection accepts `executionHostId`, while preload `worktrees.listDetected` and main `worktrees:listDetected` accept only `{ repoId }`; main then uses first-match `store.getRepo(repoId)`. - -Baseline commands run from the rebased worktree: - -```bash -pnpm install --frozen-lockfile -rg -n "prepareRemoteWorkspaceTarget|applyRemoteWorkspaceSnapshot|syncRemoteWorkspaceAfterConnect|store.fetchWorktrees|fetchWorktreeLineage" src/renderer/src/hooks/useIpcEvents.ts -rg -n "listDetected: \\(args: \\{ repoId: string \\}\\)|store.getRepo\\(args.repoId\\)" src/preload/api-types.ts src/main/ipc/worktrees.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "coalesces concurrent duplicate refreshes for the same repo and host|keeps same-repo refreshes separate for different execution hosts|fetches the requested host when duplicate repo ids exist" -pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts -t "coalesces concurrent authoritative detected worktree scans" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts -t "clears stale remote PTYs when an SSH connection fully disconnects|waits for the remote workspace client id before dropping self notifications" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts -t "surfaces relay channel loss while the SSH connection remains alive|does not broadcast a premature connected when relay deploy fails|times out after 30s with no response|advances both recovery schedulers" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts src/renderer/src/lib/manual-terminal-worktree-parking.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/agent-background-session-launch-host.test.ts src/renderer/src/lib/launch-agent-background-session-remote.test.ts src/renderer/src/hooks/useAutomationDispatchEvents.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktree-by-id-index.test.ts src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts -``` - -Results initially on `79ec57d04`, then rerun after rebasing through `974447175`: 3/3, 1/1, 2/2, 4/4, and 3/3 selected SSH seam tests passed respectively; the three newly relevant parking files passed 39/39. On `1fd0f731f`, the four folder-automation/adoption files passed 516/516. On `694363805`, the hydration-index and output-frame equivalence files passed 25/25. Source-contract inspection confirmed the `2R`/`3R` call graph and the dropped host field; later main changes did not alter those seams. This is the historical baseline evidence used to define the candidate oracles. Candidate runtime and Docker SSH evidence is recorded separately below. - -### Candidate implementation and validation - -The candidate implements the composed authority pair, host-qualified catalog/worktree/lineage reads, separate waiter and provider identities, five-slot fair scheduling with a seven-start provider budget, the first-timeout retry barrier, exact-target terminal recovery, fenced remote-workspace hydration, and privacy-safe aggregate telemetry. Coordinator routing defaults on and can be disabled per build with `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or per renderer session with `orca.directSshReconnectCoordinator.enabled=false`; the fallback retains host/authority fencing, atomic terminal recovery, and bounded preparation. - -Deterministic validation on the final rebased implementation passed all 42 changed unit suites (1,960 tests), including the 11-file direct-SSH terminal gate (637 tests), the 11-file renderer provider/transport gate (763 tests), and the 12-file main/preload/runtime/shared authority gate (513 tests), plus full typecheck, changed-code quality, reliability-gate and max-lines checks, `git diff --check`, and an Electron E2E-mode build. The authority gate proves retained SSH connection and detected-port payload admission is wired into native preload and runtime-client/environment production routes. An earlier full `pnpm test` exercise ran 3,707 files and 39,083 tests; only two assertions in unchanged `agent-exec-handler.test.ts` failed because the managed terminal already injects `GIT_CONFIG_*` prompt settings. The file passed 10/10 with those inherited settings removed. Final exact-head CI evidence is recorded in the PR description. - -Real transport validation used a macOS Electron headless client against an ephemeral Linux Docker SSH/relay target: - -```bash -ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ - tests/e2e/ssh-docker-relay-perf.spec.ts \ - --config tests/playwright.config.ts \ - --project electron-headless \ - --workers=1 - -ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ - tests/e2e/ssh-cold-activation-restore.spec.ts \ - --config tests/playwright.config.ts \ - --project electron-headless \ - --workers=1 -``` - -The four-test relay suite and the cold-restore journey passed. The relay suite covered streaming, a background ACK-stalled PTY, file/Git pressure, and live terminal input/output before and after SSH disconnect/reconnect; the reconnect case independently read the post-reconnect proof file inside the Linux container. The cold-restore journey proved all six restored SSH terminals remounted and accepted remote input after renderer reload. Repo registration waits on exact renderer catalog ownership and full authority, requires an authoritative host-qualified worktree response, and uses no timing sleep. - -Remaining live gaps are headed paired-Orca-server and headless `orca serve` non-interference, WSL, physical Windows and Linux desktop clients, and a multi-target live fan-out/large-terminal-map benchmark. Docker SSH proves the direct SSH provider/relay path, not paired-runtime parity. - -Current-main reconciliation: - -- #8255 advances paired remote-runtime control and pane backoffs on resume/online. Direct SSH registers its own wake input and must not call or absorb that scheduler; one OS event may wake both ownership domains, but each exact pane finalizes at most once. -- #10986 correctly routes an exact worktree by its own host in multi-host projects. Direct reconnect reuses that worktree-specific precedence only after rejecting any contradictory provenance; it never falls back to project-wide repo ambiguity or focused-runtime ownership. -- #10129 makes capable paired-runtime reasonless close fail closed. Direct SSH cleanup clears transient bindings only: it never emits `session.tabs.close`, retires a tab, or kills a provider PTY. -- #10799 confirms runtime project setup must refuse `ssh:` rather than act locally. The desktop direct-SSH handler likewise rejects `runtime:` hosts, and runtime-owned SSH rows remain under runtime authority. -- #11016 parks terminal views without clearing their PTYs. Direct SSH binding cleanup does not invoke parking, close, or layout mutation; the mounted view observes the atomic PTY-state patch independently. -- #10818 routes folder automation through `getKnownWorktreeById` and ambiguity-aware `getFolderWorkspaceConnectionId`, and publishes agent tabs only after binding the spawned PTY. Direct reconnect uses the same effective folder connection as provenance, rejects mixed ownership, preserves agent state, and never treats an adopted PTY as live under a newer authority without matching binding evidence. -- #10891 preserves legacy first-wins semantics while indexing ID-only hydration lookups. Direct reconnect must not treat `buildWorktreeByIdIndex` as ownership proof: target hydration and reattach resolve a host-qualified worktree first and fail closed on duplicate or contradictory ownership. -- #10915 changes runtime output-frame chunking without changing PTY binding, authority, close intent, or hydration ownership. Direct reconnect does not reset output sequence state or reinterpret runtime frames. - -## Invariants - -### Authoritative provider authority - -1. Main is the sole issuer of `SshProviderEpoch` and `connectionGeneration`. Renderer never increments, orders, parses, or synthesizes either value. -2. Direct SSH state is normalized to `providerEpoch: SshProviderEpoch | null` plus `connectionGeneration?: number`. Main-originated direct SSH state always carries a valid pair. `null` or a missing generation means unknown authority, not zero and not a renderer fallback. -3. A single `rotateSshProviderAuthority(targetId)` helper advances both values atomically before every provider-invalidating transition: new connect ownership, transport replacement or loss, relay/multiplexer replacement or loss, provider disposal, target readoption/reassignment, and permanent target removal. No producer may rotate or publish only one component. -4. `connectionGeneration` remains the existing SSH mutation expectation. Expanding its rotation set closes relay-only mutation races. Provider/reconnect operations compare the full `(providerEpoch, connectionGeneration)` pair; this is the formal composition rule, not two independent clocks. -5. Main registers the provider serving the new pair before broadcasting `connected`. A broadcast-first transition is invalid because it can start a preparation against an unregistered provider with no guaranteed self-heal event. -6. Renderer partial state writers are patches. They preserve both main authority fields and cannot author a `connected` state with a new value. -7. Every boundary that decides equality, copies state, admits retained state, preloads state, reconciles state, or republishes state must preserve both fields. This inventory includes `sshConnectionStatesEqual`, `admitSshConnectionState` and its byte/range checks, public-state projection, `ssh:getState`, `ssh:state-changed`, startup reconnect, runtime-client retained payloads, renderer/runtime SSH state stores, web-file mutation reads, and all fixtures/builders. Allowlisted clones must name both fields; equality treats either field changing as significant. -8. A recovery operation captures one exact `DirectSshAuthority = (targetId, providerEpoch, connectionGeneration)`. Equality is the only permitted authority operation. -9. If a connected event lacks either component, the renderer performs one bounded `ssh:getState` reconciliation with a per-target arrival watermark. The reply may fill authority only if no newer push event arrived and the stored event/status still matches the initiating event; it cannot transition status or resurrect an older `connected` state. If authority remains unknown, authoritative preparation, retry, sync, and snapshot mutation fail closed with `authority-unknown`; disconnect cleanup remains allowed. -10. After every await and inside every authoritative store updater, current state must still name the same connected target and exact authority pair. -11. Supersession is determined by coordinator arrival order and exact equality, never numeric ordering. -12. On any authority change, before new preparation admission, cancel the target's queued work, locally settle obsolete waiter leases, send exactly one cancellation for every affected in-flight provider request ID, mark all late results stale, and retain main-side post-await fences. Terminal finalization for the new authority does not wait for old relay work. -13. Exhausting one target's per-session generation counter rolls the process generation scope and revokes every direct SSH target, not only the target that exhausted its counter. Main invalidates every cached authority and aborts every registered provider request from the old scope before any target can publish or admit work under the new scope. - -### Host-qualified ownership - -1. Direct reconnect work owns only `toSshExecutionHostId(targetId)`. -2. Every coordinator detected-worktree provider invocation carries `(repoId, executionHostId, expectedAuthority, providerRequestId)`. Each renderer consumer separately owns a `waiterLeaseId`; a lease ID never crosses IPC or names provider work. -3. Main resolves a repo by the complete `(repo.id, executionHostId)` identity only after validating all present repo provenance. `executionHostId` and legacy `connectionId` must agree when both are present; a catalog row for which either source names the requested host while the other names another SSH, local, or runtime host makes the host catalog non-authoritative. Zero, contradictory, or multiple matches fail closed. Main never uses explicit-field precedence to hide a contradiction and never falls back to first-match `getRepo(repoId)` for a host-qualified request. -4. A local host request can select only a local repo. A direct SSH host request can select only the matching target/provider. A runtime host is rejected by the desktop handler and must use the existing runtime RPC route. -5. A successful response uses a local or direct-SSH discriminant. The direct-SSH variant cannot be constructed without the resolved execution host and full authority pair. Renderer validates the wire discriminant and rejects a mismatch before any use. -6. Runtime-owned or runtime-transported worktrees remain under the runtime environment scheduler, including SSH execution hosts whose `runtimeOwnerEnvironmentId` names a HUB. -7. Raw repo IDs, paths, UI focus, and unqualified legacy metadata are not ownership evidence. Pre-catalog scope resolution uses explicit worktree/repo provenance and `getExplicitRuntimeEnvironmentIdForWorktree`; it must not use the focused-runtime fallbacks in `getExecutionHostIdForWorktree` or `getRuntimeEnvironmentIdForWorktree`. `runtime:unresolved-owner` and focus-only results are ambiguous, not another host's. Unknown ownership is diagnostic and retryable, but never authoritatively replaced or deleted. -8. All present provenance must agree. Explicit worktree ownership takes precedence only after agreement is proven; repo-derived ownership is a fallback only when explicit worktree ownership is absent. A worktree stamped `ssh:B` with repo-derived `ssh:A`, or any direct-SSH row with an explicit runtime owner, is `contradictory-owner` and is preserved without refresh, merge, retry, or pruning. - -### Merge fencing - -1. Provider results are immutable until all host and authority checks pass. -2. Main revalidates the request host, provider instance, and exact authority after the provider await and before: - - remembering worktree roots; - - pruning persisted lineage; - - stamping or backfilling worktree metadata; or - - returning an authoritative result. -3. Renderer revalidates immediately after the preload/runtime await and before any use of the result, including: - - `routeListingBranchSwitchesThroughGitIdentity`; - - hosted-review sanitation; - - `updateWorktreeGitIdentity`; - - `buildWorktreePurgeState`; - - `worktreesByRepo` or `detectedWorktreesByRepo` merges; and - - best-effort lineage refresh. -4. Host-scoped catalog and lineage snapshots are revalidated before their sole renderer merge. -5. A remote-workspace token and snapshot revision are revalidated after hydration and immediately before session merge/publish. The merge also preserves any terminal-recovery revision newer than the snapshot operation. -6. Stale, superseded, timed-out, and canceled results perform zero authoritative mutations and are not logged as operational errors. - -### Terminal state - -The disconnect action preserves the current call-site predicate: a tab is affected only when `tab.ptyId != null`. A tab without a `ptyId` remains byte-identical even if it has `pendingActivationSpawn` or an inconsistent auxiliary PTY index. - -For each affected tab, the atomic action must: - -- set `tab.ptyId` to `null`; -- empty its `ptyIdsByTabId` entry; -- consume `pendingActivationSpawn`; -- remove pending Codex restart and restart-notice entries for the cleared live PTY; -- preserve `lastKnownRelayPtyIdByTabId` for relay-grace reattach and the `#9911` orphan-safety invariant; and -- leave layouts, deferred SSH sessions, pending reconnect IDs, suppression/shutdown guards, tab IDs, titles, generations, and agent state unchanged. - -The action must not bump activity, sort worktrees, or persist worktree metadata. A repeat after all qualified bindings are clear returns the original store state. -It also must not emit `session.tabs.close`, `session.tabs.closeLifecycle`, provider shutdown, or process signals. Binding loss is not close intent, tab retirement, or proof that a PTY died. - -Reconnect finalization must: - -- use `shouldRetryPaneSpawnOnSshReconnect`; -- include exact direct SSH Git-worktree and folder-workspace keys; -- run synchronously before catalog or provider awaits; -- treat a non-null `ptyId` as live only when transient binding provenance names the current authority and the current tab-wide spawn/reattach attempt has established live authority; -- clear stale binding evidence from a missed disconnect or prior authority before testing retry eligibility; -- keep at most one tab-wide retry attempt in flight per tab and authority; -- join renderer pending-spawn promises only for the same retry attempt; authority or tab-generation advance starts independently, and a late obsolete fresh PTY is rejected and retired; -- accept a spawn or reattach acknowledgement only when its attempt, authority, tab generation, target-qualified PTY, and committed PTY index all match; the first split success establishes the tab fallback and a live continuation lease, and later or post-success-mounted siblings capture and commit through that exact lease without replacing the fallback; -- admit a reattach identity before publishing renderer PTY handlers; revalidate the captured lease after every asynchronous SSH preparation wait and, after the synchronous already-exited delivery case, before error or launch-metadata publication, deferred-state mutation, binding cleanup, or replacement spawn; drop stale owned handlers without killing the durable PTY a current lease may adopt; -- settle retirement of a newly created session-expired fallback when admission rejects it, including after transport destruction, and surface shutdown refusal as unknown, without killing a rejected reattach or cold restore that another current lease may adopt; -- preserve the continuation lease if its primary PTY exits while another split leaf from the same attempt is still activating, then promote the late sibling when it commits; -- on an attempt-one sibling failure or timeout, revoke that attempt and rotate the whole tab once; after attempt two is exhausted, retain its continuation lease for siblings already settling while forbidding attempt three; -- when a split pane detaches to a new tab, project the same exact live or pending authority and retry history to both resulting tabs, including a bound-plus-unbound split or an all-null continuation gap before any leaf binds; the detached null-PTY tab remains activation-pending, and a rejected acknowledgement or detach returns the original relevant maps unchanged; -- permit a tab hydrated, newly discovered, or left unbound after a failed spawn to receive a bounded same-authority corrective bump; -- update all affected workspace buckets in one Zustand publication; and -- leave preparation-only requests, nonqualifying tabs, and every other host unchanged. - -The coordinator keeps separate authority-scoped pending-attempt state and successful-binding state, not a set of bump attempts and not a cached preparation outcome. The live binding carries the exact attempt ID as a continuation lease for every split leaf in that tab generation. A healthy live binding, including a bounded empty-primary activation gap, suppresses correction; an unresolved tab is reconsidered on wake, snapshot completion, and preparation completion with at most one tab-wide attempt in flight. One authority chain preserves its complete attempt history and has a hard limit of two automatic attempts. A timeout taking longer than the former rolling 30-second window cannot age out the first attempt and start a third automatic attempt; later wake, snapshot, and preparation triggers remain exhausted until authority replacement rotates pending, binding, and history state. - -## Design - -### 1. Composed provider authority and host-qualified IPC - -Add `SshProviderEpoch` to the shared SSH types and compose it with the existing main-owned connection generation in `src/main/ssh/ssh-provider-authority.ts`. This module owns atomic rotation and delegates generation storage/assertion to `ssh-connection-generation.ts`; it does not introduce an independently advancing clock. - -The direct SSH state boundary becomes: - -```ts -type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } -type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } -type WaiterLeaseId = string & { readonly __waiterLeaseId: unique symbol } -type SshExecutionHostId = Extract - -type DirectSshStateAuthority = { - providerEpoch: SshProviderEpoch | null - connectionGeneration?: number -} - -type DirectSshAuthority = { - targetId: string - providerEpoch: SshProviderEpoch - connectionGeneration: number -} -``` - -The epoch wire value is a bounded opaque string. Branding is compile-time only. Main state broadcasts and `ssh:getState` include the pair; renderer state stores it without interpretation. `rotateSshProviderAuthority` is the only transition writer and `assertSshMutationExpectation` observes the generation advanced by that same call. - -Extend preload/main detected-worktree APIs: - -```ts -type LocalDetectedWorktreeRequest = { - providerRequestId: ProviderRequestId - repoId: string - executionHostId: typeof LOCAL_EXECUTION_HOST_ID -} - -type DirectSshDetectedWorktreeRequest = { - providerRequestId: ProviderRequestId - repoId: string - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority -} - -type ListDetectedWorktreesArgs = LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest - -type AuthoritativeHost = - | { - kind: 'local' - executionHostId: typeof LOCAL_EXECUTION_HOST_ID - } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - -type HostQualifiedDetectedWorktreeResult = - | { - status: 'complete' | 'non-authoritative' - providerRequestId: ProviderRequestId - repoId: string - authority: AuthoritativeHost - result: DetectedWorktreeListResult - } - | { - providerRequestId: ProviderRequestId - executionHostId: ExecutionHostId - status: - | 'canceled' - | 'timed-out' - | 'stale' - | 'ambiguous-owner' - | 'authority-unknown' - | 'rejected' - } -``` - -`DirectSshDetectedWorktreeRequest` requires the full pair as one `expectedAuthority`; local requests cannot carry it. Construction and runtime admission also require `executionHostId === toSshExecutionHostId(expectedAuthority.targetId)`. Every data-bearing direct-SSH response, including `non-authoritative` metadata fallback, is therefore impossible to construct without both fields. Runtime validation rejects decoded SSH data payloads missing either component even if an untyped or older boundary fabricates one. Main owns the 30-second provider deadline; no renderer-supplied timeout can extend it. The desktop handler rejects runtime hosts. Keep an explicitly unqualified legacy overload only for existing callers during migration; it fails closed when more than one host owns the repo ID and is removed after all callers pass a host. - -Main selects the repo and provider before starting work, captures the provider object and full authority, and checks all three again after `provider.listWorktrees`. A host-qualified SSH response cannot be restamped by renderer as another host. - -Use host-qualified lineage ownership as well: - -```ts -type ListDesktopLineageForHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -type HostLineageSnapshot = - | { - authoritative: true - authority: AuthoritativeHost - worktreeLineageById: Record - workspaceLineageByChildKey: Record - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' - } -``` - -Main filters the snapshot to the requested host. Renderer replaces only an authoritative discriminated host scope; malformed SSH authority is rejected before merge. Direct SSH preparation replaces the current bare `fetchWorktreeLineage()` call with this host-qualified API; it does not substitute `{ forceLocalOwner: true }`. - -Main destructive pruning must use qualified repo/worktree ownership. An existing row `meta.hostId` must agree with the resolved repo host in every case. Only an absent legacy host may be inferred when exactly one stored repo owns the repo ID, preserving today's path-reuse cleanup. When multiple hosts can own the ID, pruning requires row `meta.hostId`; absence or conflict makes the result non-authoritative and preserves the row. Authoritative scans backfill absent `meta.hostId` so legacy rows self-heal. Never use repo-ID-prefix pruning across hosts. - -The shared renderer store and web preload retain their existing non-direct-SSH callers. Preserve the legacy/runtime overload and its argument/echo shape in `web-preload-api.ts`, but do not broaden the web client into direct-SSH coordination: paired web clients cannot subscribe to desktop `ssh:state-changed`, so the proposed total direct-SSH web break is not reachable. Shared API type changes still receive compatibility tests so a runtime-routed web read is not rejected merely because the wrapper dropped its requested host. - -### 2. Exact direct SSH target scope - -Add `src/renderer/src/lib/direct-ssh-target-scope.ts`: - -```ts -type DirectSshGitRepoRef = { - repoId: string - executionHostId: SshExecutionHostId -} - -type DirectSshTargetScope = { - catalogRevision: number - gitRepos: DirectSshGitRepoRef[] - gitWorktreeIds: Set - terminalWorkspaceKeys: Set - lineageWorkspaceKeys: Set - ambiguousOwnerCount: number - contradictoryOwnerCount: number -} -``` - -Resolution rules: - -- Build the expected host with `toSshExecutionHostId(targetId)`. -- Resolve repos by `(repo.id, executionHostId)`. -- Collect explicit worktree host, exact repo-derived host, projected runtime owner, and any restored host evidence before selecting a row. -- If two present sources disagree, classify the row as `contradictory-owner`; preserve it and exclude it from refresh, merge, terminal retry, snapshot projection, and pruning. -- When explicit worktree host is present and no source contradicts it, require it to equal the expected host. Use exact repo-derived ownership only when the explicit worktree host is absent. -- Require `getExplicitRuntimeEnvironmentIdForWorktree` to be `null`; an explicit runtime owner contradicts direct SSH even when another source names the expected SSH host. -- Do not use focused-runtime fallback ownership during pre-catalog reconnect. Focus-only local/runtime results and `runtime:unresolved-owner` are ambiguous. -- Do not use `buildWorktreeByIdIndex`, raw `getKnownWorktreeById`, or another first-wins ID-only lookup as authoritative direct-SSH ownership. Hydration and reattach use an exact host-qualified row or fail closed. -- Accept a folder workspace only when its effective connection is exactly `targetId`, its execution host is expected, runtime owner is `null`, and all candidate repo/group/workspace provenance agrees. -- Treat mixed/conflicting folder provenance as contradictory; duplicate same-host owners and unresolved legacy rows are ambiguous/unowned. -- A parsed live app-SSH PTY can recover a stale-catalog terminal only when no explicit other-host or runtime ownership contradicts it. - -Git refresh uses `gitRepos`; terminal clear/retry uses raw Git IDs and folder keys from `terminalWorkspaceKeys`; unified lineage uses `worktree:` and folder keys from `lineageWorkspaceKeys`; snapshot projection uses `gitWorktreeIds`. Folder workspaces never enter the path-based remote-workspace schema. - -When exact repo rows are missing, use a new host-scoped desktop catalog read rather than focused `fetchRepos()` or an all-desktop refresh: - -```ts -listReposForExecutionHost({ - executionHostId, - expectedAuthority -}) -``` - -Main validates explicit and legacy ownership before producing the host snapshot. A row with contradictory `executionHostId` and `connectionId` cannot be filtered into one host by precedence or silently omitted from the other; a contradiction touching the requested host returns a non-authoritative catalog with no rows. - -The renderer merges the immutable response into only that host scope after a full-authority fence. This action owns a per-host catalog revision and in-flight entry; it does not share `reposFetchGeneration` with focused-runtime or all-host fetches. Thus a concurrent runtime-focused catalog refresh cannot silently supersede direct SSH hydration. -Its authoritative response uses the same `AuthoritativeHost` discriminant as detected worktrees and lineage, so direct-SSH catalog data also requires the complete pair. - -### 3. Bounded and cancelable direct SSH provider scheduler - -Add `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts`. It is owned by the `useIpcEvents` effect and used only for direct SSH coordinator scans. - -Required behavior: - -- at most `DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY` (five) locally unsettled coordinator-owned detected-worktree requests in flight; -- owner-aware round-robin selection by target, with repos submitted incrementally rather than flattening one target into a global FIFO; -- no fixed collection window for an idle singleton request; -- an input key of `(repoId, executionHostId, providerEpoch, connectionGeneration, catalogRevision, authoritative requirement)`; -- join only a currently running logical repo task with the exact same key; -- retain the logical key across a first `retrying` timeout and delete it only on a terminal outcome; -- explicit `complete`, `non-authoritative`, `timed-out`, `cancel-budget-exhausted`, `canceled`, `stale`, and `rejected` outcomes; and -- no console error or degraded count for expected cancellation/supersession. - -Each provider invocation has the existing 30-second main-owned deadline. Main creates an `AbortController`, passes its signal to `SshGitProvider.listWorktrees`, and therefore reaches the multiplexer `rpc.cancel` path on timeout. A transient first timeout changes the repo task to `retrying` and requeues it once at the tail of that target's round-robin lane if the full authority remains current. That repo task promise does not settle, and target lineage/token creation does not start, until the retry completes, reaches its second timeout, is invalidated, or retry admission terminates as `cancel-budget-exhausted`. Add cancellation IPC keyed only by `providerRequestId`; authority advance, target invalidation, last-waiter release, and effect teardown abort matching main requests. Queued requests have no provider request ID and cancel without IPC. - -The first timed-out provider invocation and its leases settle before retry admission. The logical repo task remains pending and acquires a fresh provider request ID plus fresh waiter leases for the retry; preparation waiters never reuse an already-canceled provider identity. - -Cancellation uses waiter leases: - -- `waiterLeaseId` and `providerRequestId` are different opaque types generated independently. A provider request ID names one underlying preload/main/provider invocation; every consumer of that shared invocation receives its own renderer-only waiter lease ID. -- The shared detected-worktree coalescer, not the direct coordinator alone, owns the lease registry so sidebar/filesystem consumers also keep a joined provider invocation alive. -- Canceling or superseding one lease settles only that consumer. It does not call cancellation IPC while any other lease remains. -- Releasing the last lease sends exactly one cancellation IPC carrying the provider request ID and captured host/authority. Main never receives, stores, or accepts waiter lease IDs. -- Main aborts the provider request when that cancellation identity matches or when provider authority is invalidated. It does not reconstruct waiter ownership. -- Local cancellation returns after waiter settlement and, for the last lease, main provider-promise settlement. `ssh-channel-multiplexer` rejects its local provider promise when it sends fire-and-forget `rpc.cancel`; no relay response is awaited. -- The scheduler releases the local slot when the underlying provider promise settles locally. The original relay handler may observe abort later, so this metric is not a hard relay-process concurrency claim. -- Track every locally canceled underlying call as conservative cancel debt on its provider instance. Admission requires `locallyUnsettled + cancelDebt <= DIRECT_SSH_PROVIDER_START_BUDGET` (seven), so five canceled calls permit at most two replacements and repeated cancel/retry cannot create unbounded client-originated work. Debt is not cleared by elapsed time or local promise settlement because neither proves relay completion; it clears only when the owning provider/multiplexer is disposed or replaced. A logical task denied admission by this budget settles terminally as `cancel-budget-exhausted`; it never waits for provider replacement and never leaves the preparation barrier pending indefinitely. A hard bound on handlers surviving disposal is impossible without a relay acknowledgement, so telemetry states this as a seven-start per-provider budget rather than a total remote-process guarantee. - -```ts -type DetectedWorktreeRefreshLease = { - waiterLeaseId: WaiterLeaseId - providerRequestId: ProviderRequestId - result: Promise - release(reason: 'superseded' | 'invalidated' | 'stopped'): void -} -``` - -Each lease has its own settlement promise. Normal provider settlement resolves all remaining leases and removes the provider entry automatically; early `release` is idempotent and settles only that lease as canceled. - -Every other preparation await is bounded too: host-scoped catalog and lineage IPC use five-second deadlines, workspace hydration retains its ten-second deadline, and existing remote-workspace RPC deadlines remain in force. Catalog and lineage waiters accept coordinator cancellation; because their main work does not launch a provider process, a late reply is discarded by the renderer fence rather than holding a scheduler slot. - -The existing runtime project scheduler keeps its own five-worker pool, 250 ms debounce, and 5-second minimum interval. Sidebar and filesystem-event refreshes remain unchanged. Cross-subsystem isolation is intentional; the coordinator's bound is not presented as a renderer-wide bound. - -Keep the public `detectedWorktreeRefreshKey` shape unchanged. Its in-flight entry records a provider invocation identity and a lease map; direct-SSH authoritative work may join only when host and full authority also match. An incompatible entry under the same public key gets a separate underlying invocation rather than an unsafe join. The coordinator's authority/revision key wraps this coalescer without fragmenting compatible sidebar or filesystem-event sharing. The shared coalescer owns provider request IDs and waiter leases; the coordinator owns authority/revision fencing, retry state, and scoped metrics. - -### 4. Per-target reconnect coordinator - -Add `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts`, instantiated once in `useIpcEvents`: - -```ts -type DirectSshAuthority = { - targetId: string - providerEpoch: SshProviderEpoch - connectionGeneration: number -} - -type PreparationInput = DirectSshAuthority & { - catalogRevision: number - repoRefs: DirectSshGitRepoRef[] - authorityRequirement: 'required' | 'allow-metadata-fallback' - snapshotRevision?: number - reason: 'reconnect' | 'initial-hydration' | 'workspace-snapshot' | 'wake-refresh' -} - -type DirectSshReconnectCoordinator = { - requestReconnect(authority: DirectSshAuthority): Promise - prepareOnly(input: PreparationInput): Promise - finalizeHydratedTerminals(authority: DirectSshAuthority): number - correctUnboundTerminals(authority: DirectSshAuthority, reason: CorrectionReason): number - replaceAuthority(authority: DirectSshAuthority): void - invalidate(targetId: string): void - stop(): void -} -``` - -There is no global reconnect wave and no authority-long prepared-outcome cache. Transient per-tab pending/live-binding state is separate and exists only to settle or re-arm terminal recovery. - -`replaceAuthority` compares the complete authority tuple. An exact-equal replacement is a no-op: it does not settle leases, clear pending/live terminal state, cancel provider work, or fragment an overlapping preparation. Only a different tuple performs authority replacement. - -#### Reconnect-finalization flow - -```ts -async function requestReconnect(authority) { - if (!isCurrentConnectedAuthority(authority)) return stale() - - // Authority replacement first settles all obsolete local leases and fences their late work. - coordinator.replaceAuthority(authority) - - // A missed disconnect can leave a non-null PTY from the old provider incarnation. - invalidateStaleDirectSshTargetPtyBindings(authority) - - // Terminal-critical and synchronous: no catalog, Git, lineage, or other target can gate it. - const retried = retryDirectSshTargetPanes(authority) - - // Relay flapping never delays terminal recovery, but it does damp full Git preparation. - if (!hasAuthorityBeenStableFor(authority, RELAY_LOST_STABILIZED_MS)) { - scheduleLatestAuthorityPreparation(authority, RELAY_LOST_STABILIZED_MS) - return terminalOnlyOutcome(retried, 'stabilizing') - } - - const input = await capturePreparationInput(authority, 'reconnect') - if (!input.ok) return terminalOnlyOutcome(retried, input.reason) - - const prepared = await prepare(input.value) - if (!prepared.token || !isCurrentConnectedAuthority(authority)) { - return combine(retried, prepared) - } - - // Catch tabs whose exact ownership or hydration became visible during this target's preparation. - const discoveredRetries = correctUnboundTerminals(authority, 'preparation-complete') - void syncRemoteWorkspaceAfterConnect(prepared.token) - return combine(retried + discoveredRetries, prepared) -} -``` - -The first terminal retry is complete before `capturePreparationInput` performs any await. Target B therefore retries even when target A has five slow provider requests. A same-authority duplicate runs bounded correction: healthy live bindings are no-ops, pending attempts are not duplicated, and failed/unbound tabs can re-arm. When workspace hydration completes, `finalizeHydratedTerminals` reruns against the current authority and handles newly hydrated or still-unbound tabs. - -Preparation for each target progresses independently. While authority remains current, it performs its host-scoped lineage read only after every repo task reaches a terminal state: `complete`, `non-authoritative`, final `timed-out`, `cancel-budget-exhausted`, or `rejected`. Authority-wide `canceled`/`stale` returns without lineage or a token. A first retryable timeout is the nonterminal `retrying` state and cannot release lineage, terminal correction, sync, or token creation. The target never waits for another target's repos or lineage. - -When authority rotates again within `RELAY_LOST_STABILIZED_MS` (currently five seconds), replace the delayed preparation with the latest authority and perform terminal finalization immediately. Only the authority that survives the stabilization window starts catalog/Git/lineage work. A same-authority wake during that window coalesces into the pending latest-authority preparation. This is damping, not an epoch-long result cache. - -#### Preparation-only flow - -`prepareOnly` runs catalog/worktree/lineage preparation and returns a token. It never invokes terminal retry and never starts reconnect sync. An unsolicited snapshot uses this mode, then applies that snapshot with the returned token. - -Reconnect and preparation-only requests may share exact overlapping catalog, repo, or lineage promises through ref-counted waiter leases. Superseding one consumer settles only its lease; it does not abort work still owned by another current consumer. They do not share finalization side effects. - -#### Input-scoped deduplication - -Preparation captures concrete inputs: exact authority pair, catalog revision, sorted repo/host refs, authoritative requirement, and snapshot revision when present. Only currently overlapping operations with identical relevant inputs join. - -Completed entries are removed immediately. Consequently: - -- a later same-authority wake rebroadcast runs another bounded refresh; -- a later snapshot revision runs another bounded preparation; -- a newly discovered repo changes the catalog revision/repo fingerprint and cannot join an older scope; -- a snapshot received during reconnect can share the still-running provider reads; and -- the reconnect call chain can pass its completed token directly to sync/apply without a second preparation. - -The token is an operation result, not a coordinator cache entry: - -```ts -type DirectSshPreparationToken = { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - authorityRequirement: PreparationInput['authorityRequirement'] - snapshotRevision: number | null - outcome: 'complete' | 'degraded' -} - -type SnapshotApplyToken = DirectSshPreparationToken & { - snapshotRevision: number -} -``` - -An unsolicited snapshot passes its revision into `prepareOnly`, so the returned token is already snapshot-bound. Reconnect preparation returns `snapshotRevision: null`; after `remoteWorkspace.get`, sync revalidates authority and creates a `SnapshotApplyToken` by copying the fetched revision onto that token. `applyRemoteWorkspaceSnapshot` accepts only `SnapshotApplyToken` and requires exact revision equality, so it cannot reuse preparation across incompatible snapshots. - -### 5. Fenced worktree, catalog, and lineage merges - -Refactor detected-worktree listing so provider acquisition and store mutation are separate: - -```ts -const listed = await listDetectedWorktreesForRepoCoalesced(request) -if (!isCurrentHostAuthority(request, listed)) return canceled('stale') - -// No code using listed.result may occur above this fence. -return mergeDetectedWorktrees(listed.result, request) -``` - -`mergeDetectedWorktrees` performs the sole renderer mutation for that result. It owns git-identity routing, review-link sanitation, purge state, and both worktree maps in one fenced path. It must not start an unfenced lineage refresh. - -The existing shared coalesced refresh key already includes `executionHostId` and remains unchanged. Add the full authority and concrete input revision only to the coordinator wrapper key. Do not describe host parsing as new key behavior and do not alter cross-subsystem coalescing. - -Host-scoped catalog and lineage functions likewise return immutable results. Each has one merge entry point that revalidates the current exact authority inside the Zustand updater. A stale result produces zero publications. - -Main uses the same pattern around provider work: - -```ts -const authority = resolveExactProvider(args) -const gitWorktrees = await authority.provider.listWorktrees(repo.path, { signal }) -if (!stillOwnsExactProvider(authority, args.expectedAuthority)) return stale() - -return buildAndCommitAuthoritativeResult(authority, gitWorktrees) -``` - -No prune, root-memory update, or metadata backfill occurs before `stillOwnsExactProvider`. - -### 6. Atomic terminal disconnect and retry - -Add terminal-slice actions: - -```ts -clearDirectSshTargetPtyBindings(targetId: string): number -invalidateStaleDirectSshTargetPtyBindings(authority: DirectSshAuthority): number -retryDirectSshTargetPanes(authority: DirectSshAuthority): number -settleDirectSshPaneRetry(result: DirectSshPaneRetryResult): void -``` - -Put pure projections in `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts`. - -`clearDirectSshTargetPtyBindings` traverses `tabsByWorktree` once, selects exact target scope, then applies the `tab.ptyId != null` predicate. For every affected tab the same atomic projection sets `tab.ptyId` to `null`, empties its `ptyIdsByTabId` entry, consumes `pendingActivationSpawn`, and removes pending Codex restart/restart-notice entries for the cleared live PTY. It preserves `lastKnownRelayPtyIdByTabId`, layouts, deferred SSH sessions, pending reconnect IDs, shutdown/suppression state, IDs, titles, generations, and agent state. It lazily clones only changed workspace arrays and maps, commits one patch, and triggers no activity, sorting, or metadata persistence. Tabs without a current `ptyId`, including those with `pendingActivationSpawn`, are untouched. - -`invalidateStaleDirectSshTargetPtyBindings` validates the authority inside the updater and applies that complete atomic projection to a non-null `ptyId` when its transient `ptyAuthorityByTabId` does not equal the current authority. Snapshot-imported or legacy bindings without current-authority provenance are wake hints, not live bindings. - -`retryDirectSshTargetPanes` validates the exact authority inside the updater, resolves scope from that same state snapshot, applies `shouldRetryPaneSpawnOnSshReconnect` plus stale-binding evidence, excludes only current live-success and pending-attempt tab IDs, and commits one `tabsByWorktree` patch. It records a unique tab-wide attempt outside the success ledger. `settleDirectSshPaneRetry` records current-authority success only after a live PTY binding is committed. A failed or timed-out first attempt rotates the tab once; an exhausted second attempt retains continuation authority for sibling callbacks but cannot start a third attempt. - -`clearTabPtyId` keeps genuine single-PTY exit semantics, but split recovery adds two exact-authority projections: promote an already-bound surviving PTY under the same lease, or preserve the lease and activation suppression across an empty-primary gap until a same-attempt sibling commits or settles. `syncPaneDetachPtyOwnership` likewise projects one current split lease and history to both resulting tabs without spawning, exiting, or changing authority, even when the pending attempt has not produced its first PTY. A pane mounted after the first sibling succeeds captures the retained live lease; every spawn callback rechecks the full authority pair and retires a stale provider PTY. Permanent target removal continues through `src/renderer/src/store/slices/ssh-target-cleanup.ts`, whose deletion of last-known and deferred liveness is invalid for a reconnectable disconnect. - -`applySshConnectionStateChange` receives an explicit origin and becomes orchestration: - -```ts -type SshStateApplyOrigin = 'push' | 'initial-hydration' - -const applySshConnectionStateChange = (targetId, state, origin: SshStateApplyOrigin) => { - const previous = getSshConnectionState(targetId) - setSshConnectionState(targetId, state) - - if (isTerminalFailure(state.status)) { - coordinator.invalidate(targetId) - clearRemoteDetectedAgents(targetId) - clearPortForwards(targetId) - setDetectedPorts(targetId, []) - clearDirectSshTargetPtyBindings(targetId) - return - } - - if (state.status === 'connected') { - if (!state.providerEpoch || state.connectionGeneration === undefined) { - void reconcileAuthorityOnce({ - targetId, - initiatingEventWatermark: getStateEventWatermark(targetId), - initiatingState: state, - initiatingOrigin: origin - }).then(applyAuthorityPatchIfStillCurrent) - } else { - const authority = { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - - if (origin === 'initial-hydration') { - coordinator.replaceAuthority(authority) - void coordinator.prepareOnly({ - ...authority, - authorityRequirement: 'required', - reason: 'initial-hydration' - }) - } else if ( - previous?.status !== 'connected' || - previous.providerEpoch !== state.providerEpoch || - previous.connectionGeneration !== state.connectionGeneration - ) { - void coordinator.requestReconnect(authority) - } else { - coordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareAndSyncWithoutHealthyTerminalRemount({ - ...authority, - reason: 'wake-refresh' - }) - } - } - } -} -``` - -Increment the per-target state-event watermark before applying every push event. `setSshConnectionState` must publish when either authority component changes. Reconciliation may only patch missing authority onto the still-current initiating state; it never enqueues a complete stale state reply. - -The terminal-failure cleanup order remains `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, then atomic PTY binding clear. Preserve the current port-broadcast race defense and detected-agent re-detection behavior. - -An already-connected same-authority rebroadcast is a wake refresh, not a healthy-pane remount. It performs bounded unbound/stale correction, fresh bounded preparation, and sync. Initial hydration always carries `origin: 'initial-hydration'` and uses `prepareOnly`; it is never inferred from the absence of `previous`. A bounded authority reconciliation retains the initiating origin, so filling a retained state's missing pair also cannot become a reconnect transition. Hydrating an already-live state must not remount healthy panes. Workspace session completion invokes `finalizeHydratedTerminals` only for reconnect authorities recorded during that hydration interval, but a failed/unbound attempt remains eligible for later same-authority triggers. - -## Remote-workspace data flows - -### Reconnect sync - -1. Reconnect finalizes qualifying terminals immediately. -2. Its target-scoped preparation returns a token. -3. Before awaiting `remoteWorkspace.get`, `syncRemoteWorkspaceAfterConnect(token)` captures whether the exact target currently has local tabs. This capture-before-await ordering is load-bearing. -4. The function validates the token, obtains the snapshot, and passes the same token to `applyRemoteWorkspaceSnapshot`. -5. Snapshot apply does not prepare again and is preparation-only with respect to coordinator retry; existing snapshot-driven `reconnectPersistedTerminals` behavior remains. -6. For `revision === 0`, mark hydration and publish the current local session only when the pre-await `hasLocalTabs` capture was true. Revalidate authority before upload. Do not recompute that predicate after preparation/hydration, which could overwrite a newer relay snapshot with locally imported state. -7. Snapshot projection and persisted-terminal reconnect receive only host-qualified worktree references from the token's exact target scope. They cannot reset, replace, remove, or reattach sibling SSH, local, WSL, or runtime-owned tabs or their PTY indexes, layouts, active selection, generations, retry state, or live-binding state, even when raw repo/worktree IDs or paths collide. - -### Unsolicited snapshot - -1. Capture current target authority and the incoming snapshot revision. -2. Call `prepareOnly` with those concrete inputs. -3. Wait for existing workspace-session hydration, bounded by the current 10-second deadline. -4. Capture the target terminal-recovery revision and same-ID local tab recovery fields before snapshot projection. -5. Revalidate the token, snapshot revision, arrival order, and recovery revision immediately before merge. If recovery advanced, rebase the projection on the latest local recovery fields rather than applying the stale captured copy. -6. Apply once as preparation-only. For a same-ID tab, preserve any newer local `generation`, pending attempt, successful current-authority binding, and terminal-recovery revision. Remote `generation` is not comparable across clients and cannot overwrite a local retry. Imported `ptyId`/pending reconnect data is a wake hint until `reconnectPersistedTerminals` settles and current-authority binding provenance is recorded. -7. Run the target-scoped, host-qualified `reconnectPersistedTerminals`, then `finalizeHydratedTerminals` for a reconnect authority. A failed reattach clears pending state and re-arms correction; a successful exact reattach atomically retires its pending attempt and records the current binding without an extra bump. -8. If path resolution still reports unknown worktrees, record a degraded result; do not loop unboundedly. - -A later same-authority snapshot always receives a new preparation attempt after earlier work completed. This preserves convergence when another client creates a worktree while the connection stays live. - -Remote snapshot identity is repo-qualified where the schema provides repo identity: resolve by `(executionHostId, repoId, normalizedPath)` and fail closed on ambiguity. For legacy path-only entries, retain the existing resolver only when the path has exactly one candidate in the exact target scope. Two worktrees at the same absolute host path were not proven producible, so this is a robustness invariant rather than a claim that current storage necessarily creates that collision. - -The existing `buildWorktreeByIdIndex` and `reconnectPersistedTerminals` path retains first-wins ID-only compatibility for its current callers. Direct-SSH snapshot apply must pass host-qualified worktree refs through a dedicated overload or pre-resolved map; it cannot hand raw IDs back to an ID-only lookup and recover authority afterward. - -## Failure handling - -- **Unknown authority:** reconcile once behind the per-target arrival watermark; then fail closed with a retryable diagnostic. Do not use a renderer counter or apply the reply's status. -- **Catalog timeout/failure:** retain cached exact-owner scope, record degradation, and continue target preparation and already-completed terminal recovery. -- **One repo is non-authoritative:** keep its safe metadata fallback separate from operational failure and do not authoritatively delete rows. -- **One repo times out:** the provider invocation settles locally and sends best-effort cancel. On the first retryable timeout, keep the logical repo task and target preparation barrier pending in `retrying`, then requeue once at the target lane tail. A second timeout is final and degrades the repo. If current-provider cancel debt denies retry admission, settle the logical task as terminal `cancel-budget-exhausted`; this releases one degraded preparation outcome rather than an early-success token or an indefinite wait. None of these states blocks another target's terminal retry. -- **One repo rejects:** locally settle it without retry unless classified by the existing narrow transient predicate; operational rejection remains distinct from timeout and non-authoritative data. -- **Lineage timeout/failure:** preserve current lineage, mark target preparation degraded, and continue sync with exact cached worktree scope. -- **Authority advance or target invalidation:** synchronously cancel queued work, settle obsolete local waiter leases, send exactly one cancellation for every affected in-flight provider request ID, rotate renderer in-flight inputs and terminal-attempt state, and reject late results at both main and renderer fences. The new authority begins terminal finalization without waiting for relay acknowledgement. -- **Relay flapping:** finalize terminals for every new authority, but replace/defer full preparation until the latest authority survives `RELAY_LOST_STABILIZED_MS`. -- **Missed disconnect/stale binding:** clear only PTY bindings whose transient binding authority is absent or old, retain last-known relay identifiers, then retry under the new authority. -- **Failed terminal spawn/reattach:** settle the attempt as failed, remove it from pending/success state, and allow rate-limited correction on later preparation, hydration, snapshot, or wake triggers. -- **Workspace hydration timeout:** set existing per-target sync error; do not undo terminal or worktree recovery. Snapshot merge never replaces a newer local recovery revision. -- **Coordinator stop:** cancel queued work, locally settle every waiter, send best-effort aborts for unshared provider calls, and return without waiting for relay acknowledgement. Effect cleanup stops coordinator after subscriptions and before disposing its dedicated scheduler. -- **Unknown owner:** preserve state, count it, and retry on later host-qualified catalog input. -- **Contradictory owner:** preserve state, report the conflicting provenance classes without identifiers, and do not retry or mutate that row until a later catalog revision changes the evidence. - -No failure path falls back to unbounded `Promise.all`. - -## Observability - -Emit one aggregate diagnostic per target operation, not per global wave and not per repo/tab. Use `[direct-ssh-reconnect]` for reconnect finalization and `[direct-ssh-prepare]` for preparation-only work. - -Fields: - -- mode and reason; -- terminal panes retried, stale bindings cleared, successful corrections, and terminal-finalization duration; -- catalog outcome and duration; -- repo tasks completed, non-authoritative, retrying, final timed-out, cancel-budget-exhausted, canceled, stale, and rejected; -- direct-scheduler queue-wait and provider-execution duration distributions; -- timeout retry count, local waiter settlements, cancel debt, and replacement admissions delayed by cancel debt; -- peak locally unsettled coordinator-owned detected-worktree concurrency and estimated late-work allowance; -- lineage outcome; -- Git-worktree, folder-workspace, ambiguous-owner, and contradictory-owner counts; -- overlapping request joins; -- authority rotations observed and preparations damped during flapping; -- total target-operation duration. - -Expected supersession/cancellation is debug-level and does not increment degraded/error metrics. Timeout is separate from queue wait and operational rejection. `fetchWorktrees === false` is not used as a failure proxy; the new discriminated result preserves non-authoritative versus rejected outcomes. - -Do not log target IDs, repo IDs, paths, labels, hosts, usernames, credential errors, snapshot content, raw request IDs, or terminal output. A stable per-session opaque target alias may correlate concurrent aggregate events and is discarded at process exit. - -The product event deliberately omits terminal correction failure/re-arm counts, concurrent non-coordinator call counts, and arrival-order discard counts because this implementation has no truthful production observation for them. It does not populate unobserved fields with constant zeroes. - -Typed product telemetry is implemented with one strict identifier-free aggregate event per target operation. Queue and provider percentiles are derived from that operation's real scheduler samples. The seven-day dogfood dashboard/query is operational follow-up outside this repository; this PR does not claim a dashboard artifact. Schema tests reject identifiers and keep queue wait, execution, timeout, operational rejection, cancellation, and stale results distinct. - -The concurrency metric is explicitly `coordinator_owned_direct_ssh_detected_worktree_concurrency`. It measures locally unsettled provider promises and says nothing about runtime lineage RPCs, sidebar's eight-worker pool, filesystem-event calls, catalog/lineage IPC, total application provider concurrency, non-coordinator same-connection calls, or relay handlers finishing after local cancel. - -## Tests - -This implementation registers the worktree scan-count, host/authority, timeout-barrier, and no-cross-host mutation oracles in the existing `git-worktree.refresh-event-semantics` gate. It extends `terminal-provider.ssh-remote-reattach-contract` for direct-SSH binding clear/retry, hydration, folder workspace, paired-close non-interference, and #8255 wake isolation. A new reliability gate is unnecessary because those existing gates own the lifecycle contracts. - -### Main/preload host and authority contract - -- Preload forwards `repoId`, `executionHostId`, the complete expected authority, and `providerRequestId` unchanged; main owns the deadline and never receives `waiterLeaseId`. -- Renderer host intent across local, direct SSH A/B, and runtime-alias catalog rows routes to the exact provider. Fabricated duplicate main-store rows still fail closed as defense in depth. -- Desktop main rejects a runtime execution host. -- Zero or multiple same-host matches fail closed; unqualified legacy calls fail closed when ownership is ambiguous. -- Compile-time fixtures cannot construct a direct-SSH `complete` result or `authoritative: true` lineage snapshot without both authority fields. Runtime admission rejects malformed wire values that omit either field. Local authoritative variants carry neither SSH field. -- A response echoes exact host and complete authority; renderer rejects any host, target, epoch, generation, provider request, or discriminant mismatch. -- Relay loss/replacement, transport loss/replacement, provider disposal, target readoption, and permanent removal rotate epoch and generation in one helper. -- A per-target generation counter exhaustion rolls the process generation scope, revokes every target's old authority and mutation token, and aborts every old-scope provider request before new-scope admission. -- Relay-only replacement rejects both old reconnect/provider work and an old `SshMutationExpectation`. -- A fresh `connected` authority is not broadcast until its provider is registered. -- An old provider result after any rotation performs no root-memory, lineage-prune, or metadata-backfill mutation. -- Cancellation and timeout reach the provider `AbortSignal` and multiplexer `rpc.cancel`. -- Duplicate repo IDs never trigger cross-host repo-prefix lineage pruning. -- Legacy lineage prunes under a unique repo owner, ambiguous legacy rows are preserved, and an authoritative scan backfills `meta.hostId`. -- `sshConnectionStatesEqual`, retained-payload admission, public projection, preload push/get, startup reconnect, runtime retained/client payloads, renderer/runtime stores, and state builders preserve both authority fields. -- Main-originated broadcasts always publish the complete authority pair. Malformed epoch-only or generation-only retained inputs are rejected; compatibility push/get inputs can only remain `authority-unknown` for bounded reconciliation and perform no authoritative mutation. One valid pair change publishes once and an exact duplicate is a no-op. -- Retained-state admission accepts a bounded valid epoch, rejects malformed/oversized epochs, and never strips a valid authority component. -- The web preload compatibility overload preserves requested-host echo for runtime reads; this does not enable direct-SSH coordination in paired web clients. -- A stale `ssh:getState` reply arriving after a disconnect/reconnect push cannot change status or fill authority; a same-watermark reply can fill only missing authority. -- Main host-catalog admission rejects a row whose explicit execution host contradicts its legacy connection host; it returns no authoritative rows instead of selecting or hiding that row by precedence. - -### Renderer worktree/catalog/lineage fences - -- The coordinator in-flight key differs by host, full authority, catalog revision, and authoritative requirement; the shared coalescer key remains unchanged. -- Inputs that differ only by `authorityRequirement` do not join, and the resulting token echoes that exact requirement. -- Exact overlapping requests join; the same key after completion runs again. -- An old result after disconnect/reconnect or relay-only replacement causes zero store publications. -- Git identity, hosted-review links, purge state, both worktree maps, and best-effort lineage remain byte-identical on stale results. -- Local, another SSH target, and runtime owners with the same repo ID remain unchanged. -- Target snapshot hydration and persisted-terminal reconnect use host-qualified worktree references, prune the retry/live/history ledgers of tabs authoritatively deleted from that exact scope, and leave retained or sibling SSH, local, WSL, folder, and runtime tabs, PTY indexes, layouts, active state, generations, and recovery ledgers unchanged. -- A renderer worktree result is admitted only while every current same-ID repo row still has valid, non-contradictory explicit/legacy ownership; malformed or contradictory provenance introduced during the provider await makes the result stale without store mutation. -- A target-scoped catalog fetch cannot be superseded by focused-runtime `fetchRepos`. -- Host-scoped lineage deletes a stale direct SSH row while preserving local, another SSH target, runtime, and unknown-owner rows. -- Runtime-focused UI state cannot redirect direct SSH catalog, worktree, or lineage ownership. -- An ID-only first-wins hydration index containing the same worktree ID under SSH A and SSH B is never authoritative for direct reconnect; a target-B snapshot reattaches only the host-qualified B row or fails closed. -- Pre-catalog direct SSH scope with a focused runtime treats focus fallback as ambiguous and still recovers tabs supported by explicit PTY/host provenance. -- An explicit `ssh:B` worktree with repo-derived `ssh:A` is `contradictory-owner` for both targets and remains byte-identical. The inverse mismatch, an SSH host plus explicit runtime owner, and conflicting folder group/repo connections fail the same way. -- Repo-derived ownership is accepted only when explicit worktree ownership is absent; adding a conflicting explicit owner converts the same row from accepted to preserved/contradictory without cross-host deletion. -- Git 2.25-compatible worktree fallbacks remain unchanged. - -### Direct SSH scheduler/coordinator - -- A singleton reconnect begins terminal finalization immediately without a collection delay. -- Target B terminal finalization completes while all five direct provider slots are occupied by target A. -- Target B's first repo starts after at most one bounded provider deadline when it arrives behind five A calls. -- Round-robin admission prevents a large target from continually reoccupying every released slot. -- Peak coordinator-owned detected-worktree calls is five; runtime/sidebar activity can raise total app concurrency without failing this assertion. -- Joined consumers receive distinct waiter lease IDs and one shared provider request ID. Canceling one settles only that lease while the other completes from the original call; no provider cancel is sent. -- Last-waiter release sends exactly one cancellation for that shared provider request ID. Authority invalidation sends exactly one cancellation for each affected in-flight provider request, including multi-repo preparation; no cancellation is sent merely because one lease leaves while another current-authority lease still owns that invocation. A waiter lease ID presented to main is rejected and cannot abort provider work. -- A coordinator lease joining sidebar/filesystem work cannot abort that work when the coordinator is superseded; the remaining non-coordinator lease keeps the provider request alive. -- A timeout sends `rpc.cancel`, settles locally, and releases its local slot without a relay acknowledgement. -- Repeated timeout replacements never exceed the two-call cancel-debt allowance; denied work settles as terminal `cancel-budget-exhausted` and is reported separately. -- After a first retryable timeout, the repo task is `retrying`: lineage calls, preparation tokens, sync, and snapshot apply remain at zero until the retry settles. -- A successful retry then releases one lineage read and one token; a second timeout or cancel-budget exhaustion releases a degraded result without starving another target. -- An exact-equal `replaceAuthority` call preserves every pending lease, live binding, and overlapping preparation and sends no cancellation. -- A same-authority reconnect rechecks terminals but does not rebump a healthy current-authority binding. -- A newly hydrated tab receives one retry later under the same authority. -- A tab whose exact ownership becomes visible during that target's preparation receives one retry without delaying the first terminal finalization. -- An authority advance with five old slow calls cancels queued work, settles/aborts old leases, starts new terminal finalization immediately, admits new work under cancel-debt rules, and permits zero old main/renderer mutations. -- A changed authority supersedes old target work by arrival, without numeric comparison. -- A same-authority connected rebroadcast performs bounded correction plus preparation/sync without retrying healthy terminals. -- A failed or timed-out pane on attempt one rotates the tab once; attempt-two failure cannot start attempt three or revoke continuation authority from siblings already settling. -- Three authority rotations inside the stabilization window perform three immediate terminal checks but only one full preparation for the final stable authority. -- `prepareOnly` never invokes terminal retry or reconnect sync. -- A preparation-only request shares exact in-flight repo work with reconnect preparation but not reconnect finalization. -- A completed preparation never suppresses a later same-authority wake or snapshot preparation. -- Stop locally settles queued/in-flight waiters without waiting for relay acknowledgement and prevents post-stop finalization. -- One system resume/browser-online event advances #8255 remote-runtime backoffs and direct-SSH wake preparation independently; runtime-owned SSH rows never enter the direct coordinator, and a direct tab already live under current authority is not double-bumped. - -### Atomic terminal recovery - -- Many live tabs/worktrees clear in one store publication; a second clear is a true no-op. -- Only tabs with `tab.ptyId != null` are changed. -- A null-PTY tab with `pendingActivationSpawn` remains byte-identical. -- A live-PTY tab consumes `pendingActivationSpawn` in the same patch that clears its tab and split-pane PTY indexes. -- `lastKnownRelayPtyIdByTabId` survives and the `#9911` orphan predicate remains reconnectable. -- Live split-pane PTY indexes and Codex restart metadata clear. -- Layouts, deferred sessions, pending reconnect IDs, shutdown/suppression state, titles, and agent state remain unchanged. -- No worktree activity, sorting, or metadata persistence occurs. -- Exact target A clear/retry leaves target B, local, WSL, floating, and runtime-owned tabs unchanged even with duplicate repo IDs. -- Exact direct SSH folder workspaces clear/retry; mixed, ambiguous, and runtime folders do not. -- A parsed target PTY recovers a stale-catalog tab only without contradictory ownership. -- A relay/provider replacement after a missed disconnect clears a pre-authority `ptyId` and retries it while preserving last-known relay IDs. -- Binding provenance for another authority or no provenance is stale; a successful current-authority spawn becomes live and suppresses healthy correction. -- One authority chain permits at most two automatic correction attempts even when each timeout exceeds 30 seconds; later same-authority triggers remain exhausted until authority replacement. -- Rejected stale/mismatched acknowledgements preserve every store map and publish nothing. Concurrent split spawn or reattach acknowledgements share one exact attempt; the first establishes the fallback/live lease, and both already-mounted and post-success-mounted siblings join it. -- Primary exit before a sibling binds preserves the exact continuation lease and activation suppression; the sibling then becomes the fallback without a corrective remount. -- Primary and non-primary split detach both preserve exact authority and retry history on the surviving source and detached destination; an all-null pending-only detach preserves the lease before either side binds, and a same-authority correction leaves both live. -- Disconnect retains exact existing ordering and effects for `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, and atomic PTY clear. -- Disconnect and reconnect emit zero paired `session.tabs.close`/`closeLifecycle`, provider shutdowns, or process signals. -- A manually parked direct-SSH worktree follows the same store patch and retry eligibility without invoking parking, close, or layout mutation. - -### Remote-workspace and hydration integration - -- Connect plus sync performs one logical preparation and passes its token through a nonempty snapshot apply without preparing again. -- An unsolicited snapshot joins exact in-flight preparation but starts a new preparation after that work settles. -- A later same-authority snapshot referencing a newly created worktree resolves and imports its tabs. -- Preparation-only snapshot handling never bumps terminal generations. -- Preparation-only means no coordinator generation bump; existing snapshot-driven terminal reattach remains and is finalized afterward. -- Initial hydration with no previous renderer state follows the explicit `initial-hydration` origin into `prepareOnly` and performs zero reconnect retries; reconciliation retains that origin. -- Reconnect finalized before session hydration retries newly hydrated tabs exactly once afterward. -- Immediate finalization bumps a tab, then a snapshot with the same stable tab ID and an older/absent generation cannot reduce the local generation or suppress correction. -- Snapshot hydration that lands after a newer local retry preserves the newer terminal-recovery revision, pending attempt, and current binding provenance. -- Snapshot-imported `ptyId` without current-authority live evidence remains retry-eligible; successful exact target-scoped `reconnectPersistedTerminals` retires the pending attempt and records it live, while failure re-arms it. -- Snapshot projection and reconnect are host-qualified end to end; another SSH target, local, WSL, and runtime-owned state remain byte-identical despite colliding raw IDs or paths. -- Same-authority wake rebroadcast performs bounded fresh discovery. -- Revision-zero sync captures `hasLocalTabs` before `remoteWorkspace.get`, uploads only on that capture, and revalidates authority before publish. -- Snapshot apply rejects a token whose `snapshotRevision` differs, and reconnect sync can create a `SnapshotApplyToken` only from the snapshot fetched by that same fenced operation. -- Repo-qualified snapshot mapping keeps same-named repo paths isolated; a legacy path-only entry applies only with one exact-target candidate and otherwise fails closed. -- Folder workspace keys never enter snapshot projection. -- Hydration timeout does not undo terminal recovery. - -### Performance and diagnostics - -Seed direct SSH targets, a runtime environment, sidebar refreshes, worktrees, folder workspaces, split tabs, and large terminal maps. Assert: - -- coordinator-owned direct SSH detected-worktree concurrency never exceeds five; -- runtime and sidebar work are excluded from, and may exceed, that scoped count; -- terminal retry is submitted before any provider task and uses one publication; -- disconnect uses one publication and schedules session persistence once; -- queue wait, provider duration, timeout, and cancellation are distinct; -- canceled/stale outcomes do not console-error or increment degraded counts; -- diagnostics contain counts/durations plus only an ephemeral target alias; -- the typed telemetry schema rejects identifiers and emits observed queue/provider distributions, timeout/retry/cancel-debt counts, flapping damping, and successful correction results; and -- the adapter computes bounded p50/p95/p99 values from each operation's real scheduler samples. - -## Implementation map - -- `src/main/ssh/ssh-provider-authority.ts` composes epoch issuance with `ssh-connection-generation.ts`; `src/main/ipc/ssh.ts` rotates on every provider transition, registers before `connected`, and publishes the pair. -- `src/shared/ssh-types.ts`, `ssh-retained-payload-admission.ts`, `runtime-client-events.ts`, and public SSH state projection define, validate, and retain the complete authority. `src/preload/api-types.ts`, `src/preload/index.ts`, startup reconnect, runtime SSH state, and web mutation readers copy it unchanged. -- `src/renderer/src/store/slices/ssh-target-cleanup.ts` and `ssh.ts` make the authority pair equality-significant and patch-preserving. The state-event/reconciliation code in `useIpcEvents` owns arrival watermarks. -- `src/main/ipc/worktrees.ts` and the provider authority module own exact host/provider resolution, the provider-request registry, main deadlines/aborts, post-await fences, qualified lineage pruning, and host metadata backfill. Main has no waiter-lease concept. -- `src/renderer/src/store/slices/detected-worktree-refresh-leases.ts` owns the existing public coalescer key, compatible provider invocation entries, independently generated provider request and waiter lease IDs, ref-counted last-waiter cancellation, and per-lease settlement for every caller. -- `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts` acquires shared leases and owns the five local slots, target round-robin, authority/revision wrapper key, nonterminal `retrying` state, one timeout retry, and cancel-debt allowance. -- `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts` owns per-target authority replacement, preparation waiters, stabilization damping, preparation barriers/tokens, pending terminal attempts, success ledger, and bounded corrective triggers. -- `src/renderer/src/lib/direct-ssh-target-scope.ts` uses explicit provenance for Git and folder scope; it never reads focused-runtime ownership. -- Terminal slice actions and `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts` own atomic clear, stale-binding invalidation, transient PTY authority, attempt settlement, and single-publication retry projection. -- `src/renderer/src/hooks/remote-workspace-target-sync.ts` owns capture-before-await revision-zero push, token/revision fences, repo-qualified legacy-safe resolution, local recovery preservation, snapshot-driven reattach, and post-hydration finalization. -- Typed aggregate telemetry owns the privacy schema, per-operation distributions, and fail-soft histogram emission. External dogfood dashboard/query configuration remains an operational follow-up. - -## Rollout - -The implementation preserves this dependency order: - -1. Add the opaque epoch and `rotateSshProviderAuthority`, expand `connectionGeneration` rotation to the same transition set, and inventory every state equality/copy/preload/retained/reconciliation boundary. Land complete-pair publication, malformed partial-authority rejection, retained-admission, stale-reconciliation, provider-before-broadcast, and old-mutation-expectation tests before any coordinator routing. -2. Add host-qualified detected-worktree and lineage IPC, discriminated authoritative response admission, exact main provider selection, main-owned 30-second deadline, provider-request cancellation, and main pre-mutation fences. Preserve the web/runtime overload without enabling direct-SSH web coordination. Keep the coordinator disabled. -3. Refactor renderer worktree/catalog/lineage reads to immutable results with pre-mutation full-authority fences. Add the host-scoped catalog lane independent of focused-runtime supersession, all-provenance contradiction rejection, legacy lineage host backfill, and shared coalescer leases with distinct waiter/provider IDs. -4. Add exact target scope, atomic disconnect clear, stale-authority binding invalidation, retry-attempt settlement, and transient binding provenance. Switch disconnect and reconnect terminal handling together so Git/folder clear-retry symmetry and detected-agent/port cleanup remain intact in every commit. -5. Add the dedicated fair direct SSH scheduler and per-target coordinator with local-settlement semantics, the first-timeout preparation barrier, one timeout retry, cancel-debt admission, authority-advance invalidation, same-authority correction, and flapping damping. Route connected events through reconnect finalization; leave #8255 remote-runtime recovery and runtime-owned SSH rows unchanged. -6. Extract `prepareRemoteWorkspaceTarget`, `syncRemoteWorkspaceAfterConnect`, and `applyRemoteWorkspaceSnapshot` into `src/renderer/src/hooks/remote-workspace-target-sync.ts`. Split preparation-only from reconnect mode, pass the full operation token through revision-zero push and snapshot apply, preserve newer local recovery state, finalize imported terminal hints, and remove repeated preparation calls. -7. Enable typed aggregate diagnostics/telemetry and dogfood with renderer-catalog owner aliasing, many direct targets, an active focused runtime, sidebar activity, folder workspaces, relay-only flapping, timeout/retry/cancel debt, missed disconnects, failed pane spawns, arrival-order races, system resume/browser online events, and cross-device snapshot changes. - -Stage 5 is gated on stages 2–4. The coordinator must never call catalog or lineage APIs whose ownership depends on `settings.activeRuntimeEnvironmentId`; a partial coordinator-first rollout is forbidden. System-resume/browser-online recovery must not double-bump a tab already live under the direct SSH authority ledger. - -Release checks: - -- zero host/authority mismatch accepted at either main or renderer mutation fences; -- zero authority component dropped at equality, retained, preload, startup, public-state, or reconciliation boundaries; -- zero authoritative direct-SSH result constructible or admitted without the full authority pair; -- no cross-host provider selection in duplicate-ID integration tests; -- no contradictory provenance accepted by OR-matching or repo fallback; -- terminal finalization is scheduled before provider discovery and is not delayed by another target or runtime/sidebar work; -- p95/p99 direct scheduler queue wait and provider duration, plus timeout/retry/cancel-debt rates, are reported separately from same-relay non-coordinator traffic; -- peak coordinator-owned direct SSH detected-worktree concurrency is at most five; -- waiter cancellation is lease-local, last-waiter release sends one cancellation for its provider request, authority invalidation sends exactly one per affected in-flight provider request, provider cancellation settles without relay acknowledgement, and cancel debt remains bounded; -- no lineage read, preparation token, or sync starts while a first timed-out repo remains retrying; -- authority advance invalidates old queued/in-flight work and relay flapping produces only one stable preparation wave; -- missed disconnect, failed spawn, and hydration overwrite cases converge through bounded same-authority correction; -- later same-authority snapshots containing new worktrees converge; -- revision-zero push preserves capture-before-await ordering and stale reconciliation cannot resurrect connected state; -- direct SSH lineage deletion is host-correct; and -- Git and folder terminal overlays clear and retry symmetrically while port and detected-agent cleanup remains intact. - -Rollback disables coordinator routing with build-time `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or session key `orca.directSshReconnectCoordinator.enabled=false` while retaining composed authority rotation, authority-boundary preservation, host-qualified IPC, mutation fences, and atomic terminal actions. The fallback reconnect path uses the dedicated bounded scheduler and preserves port/detected-agent cleanup; it does not restore host-blind or unbounded `Promise.all`. - -## Cross-platform and compatibility - -- Use existing execution-host, folder-workspace, workspace-key, SSH PTY-ID, and path utilities. Do not concatenate execution-host IDs or parse filesystem paths. -- The design introduces no keyboard behavior or platform-specific UI. -- Direct SSH on Windows, macOS, Linux, and WSL follows the same host/authority rules. -- Cancellation uses typed provider request IDs over Electron IPC and existing `AbortSignal` support; waiter lease IDs remain renderer-local, and no OS signal semantics or new cancel-ack protocol crosses the wire. -- No Git command, option, parser, or native dependency changes. Git 2.25 compatibility and capability fallbacks remain authoritative. -- Remote runtimes require no server upgrade. Runtime-host requests continue through the existing runtime RPC route. -- Keep new modules within normal line limits; do not add or widen a `max-lines` disable. - -## Rejected alternatives and overreach - -### Treat in-flight coalescing as collapsing the current sequential scan count - -Rejected. The connected refresh, sync preparation, and snapshot preparation await one another. Their provider reads do not overlap, so the current single-flight map has no live promise for the later wave to join. It can reduce unrelated overlapping calls, but does not invalidate the `2R`/`3R` sequential-path diagnosis. - -### Use either main connection generation or provider epoch as an independent clock - -Rejected. The renderer fallback counter has different ownership and is never authoritative. The existing main generation and new opaque epoch are composed by one rotation helper and transition set: mutation consumers compare the generation, provider/recovery consumers compare the pair, and no producer may advance them independently. - -### Cache completed preparation for an entire authority - -Rejected. Stable connections can receive wake refreshes and later snapshots referencing newly created worktrees. Only exact overlapping input work is shared; completed work never suppresses later inputs. - -### Use one global wave and FIFO for direct SSH plus runtime discovery - -Rejected. It couples terminal recovery and target preparation to unrelated queues and timeouts. Direct terminal finalization is synchronous per target; direct provider work uses a dedicated fair bounded scheduler. - -### Let preparation-only reuse reconnect finalization - -Rejected. Snapshot preparation must never initiate coordinator retry or reconnect sync. Existing snapshot-driven persisted-terminal reattach remains part of apply and is reconciled with current-authority binding evidence afterward. Only immutable catalog/repo/lineage reads may be shared. - -### Fence only before the final worktree-map merge - -Rejected. Main lineage/metadata changes and renderer git-identity routing occur earlier. Every authoritative side effect is downstream of an exact host/authority fence. - -### Use `{ forceLocalOwner: true }` for direct SSH lineage - -Rejected. It cannot authoritatively replace an SSH host scope and leaves deleted SSH lineage behind. The wire response and renderer replacement scope must both name the exact execution host. - -### Reuse the component sidebar single-flight coalescer - -Rejected. It is component-scoped, fire-and-forget, and lacks provider-authority, cancellation, terminal, and preparation semantics. The coordinator instead acquires leases from the shared detected-worktree provider coalescer while preserving its public key shape. - -### Replace direct `Promise.all` with `refreshRuntimeProjectWorktrees` - -Rejected. It bounds only one invocation, remains host-blind at the desktop IPC boundary, and does not remove repeated preparation. - -### Use one scheduler per SSH target - -Rejected. It permits `5 × targetCount` locally unsettled provider calls. One dedicated fair scheduler provides a truthful five-local-call bound without a cross-target completion barrier; cancel debt states the separate late-relay allowance. - -### Wait for a relay cancellation acknowledgement - -Rejected. `rpc.cancel` is a notification and canceled handlers intentionally send no response. Local waiter/provider settlement releases scheduler ownership; late effects remain authority-fenced and replacement admission is bounded by cancel debt. - -### Expand direct SSH coordination to the paired web client - -Rejected. Paired web clients cannot subscribe to the desktop direct-SSH state path. Preserve shared overload compatibility and host echoes, but do not create a new direct-SSH web transport to address an unreachable version of the shim claim. - -### Apply a fixed collection debounce - -Rejected. It taxes the singleton common case and is unnecessary when exact overlapping work already coalesces. Fair incremental admission handles multi-target bursts. - -### Call `fetchAllWorktrees` - -Rejected. It refreshes unrelated hosts, weakens failure isolation, and collides with the separate sidebar surface. - -### Reuse permanent target-removal cleanup - -Rejected. Removal intentionally deletes last-known and deferred liveness. Reconnectable disconnect must preserve them. - -### Batch repeated `clearTabPtyId` calls - -Rejected. React batching does not remove Zustand updater work, global map cloning, activity writes, or session debounce resets. - -### Move reconnect recovery into main - -Rejected. Main owns provider authority and mutation fences; renderer owns tab bindings, session projection, and Zustand updates. The host/authority boundary is the smaller ownership correction. diff --git a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md b/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md deleted file mode 100644 index 18ceea5bff2..00000000000 --- a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md +++ /dev/null @@ -1,576 +0,0 @@ -# Preserve active orchestration work across contract updates - -Status: implementation validation - -Date: 2026-07-28 - -## Decision - -Do not block updates, restart active workers, or revive the retired scheduler. - -On first launch after the contract update, atomically adopt the durable -pre-Run graph into one ordinary lightweight Run. Preserve every Task, -Dispatch, message, gate, terminal, PTY, process, worktree, and prompt -identifier. Mark only the pre-update Dispatch attempts as legacy-contract -attempts and accept the exact command shapes already pasted into those -workers through a narrow adapter. - -This is state migration plus a per-Dispatch protocol adapter. It is not a -second scheduler: - -- the migrated Run owns the preserved graph and coordinator inbox; -- legacy attempts keep their original protocol until they settle; -- every new attempt uses the current protocol, even on a preserved Task; and -- no placement, polling, concurrency, or deadline loop is resurrected. - -## User-visible outcome - -- The update installs immediately. -- Existing coordinator and worker terminals keep the same process, PTY, - worktree, prompt, Task ID, Dispatch ID, and history. -- Orca does not inject text, focus a tab, or start a replacement editor. -- The legacy graph appears in an automatically adopted, normal Run instead of - being stranded in `run_legacy_local`. -- An original live worker with retained launch proof can keep using the - `heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its - existing prompt. -- An original live coordinator can keep listing and settling preserved Tasks, - dispatch remaining work with current commands, check direct legacy mail, and - use the pre-cutover `reply` CLI guidance. -- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and - unmistakably read-only. They never advertise Reply or Ack actions. -- A pre-update WSL process whose older launcher did not propagate the hidden - token remains lifecycle read-only. Its assignment, process, terminal, and - filesystem work remain valid and inspectable. - -The retired `orchestration run`, `coordinator-start`, and `coordinator-stop` -scheduler commands remain retired. Their in-memory loop cannot be reconstructed -truthfully. The coordinator agent and all durable work remain accessible; it -explicitly dispatches any ready work that was never assigned before the update. - -## Evidence and constraints - -- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and - `decision_gates` by assigning them to `run_legacy_local`. -- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming - `check` shapes. It omitted `worker_done --outcome`; a subject equal to - `Failed` or beginning `Failed:` represented failure. Its escalation carried - `taskId` but no `dispatchId`. -- `reply` existed in the old CLI and message formatter guidance, not in that - pinned worker prompt. -- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and - provider `listProcesses()` does not expose the launch token. Handles and - pane IDs alone therefore cannot authorize compatibility. -- Managed hooks source the owner-only endpoint file on every invocation, so an - old process reaches the new hook server after restart. The hook cache retains - the prior authenticated pane/launch-token association and distinguishes - hydrated rows from events observed by the current runtime. -- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and - a running process cannot acquire it retroactively. -- The CLI transport mutation receipt is keyed by rotating runtime - authentication. It cannot deduplicate a legacy mutation across restart. -- Current invalid-capability lifecycle sends deliberately remain as auditable - rejected messages. Compatibility must not silently change that behavior. -- PR #11142 fixes background-tab materialization for newly dispatched workers. - It does not recover a pre-fix worker whose PTY survived an update after its - renderer tab binding was lost; workspace activation can otherwise mistake - that worker for a sleeping provider session and launch `codex resume`. -- The packaged Windows launcher keeps its Electron-as-Node child alive for the - lifetime of a blocking CLI command. A seamless design cannot assume that a - bounded RPC wait releases the installed executable. - -## Non-negotiable invariants - -1. Adoption never spawns, writes, signals, stops, focuses, or injects a - terminal and never mutates the assigned filesystem. It may restore one - persisted background tab/leaf binding for the exact already-live PTY. -2. Every durable row and identifier survives adoption; only its owning Run and - explicit protocol metadata change. -3. Compatibility is write-once per Dispatch. Migration never classifies a - current retry as legacy. -4. A legacy mutation requires one uniquely resolved legacy attempt or - coordinator principal plus current live proof of its original process tree. -5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote - attachments never create authority. -6. Current Dispatch behavior remains unchanged, including auditable rejected - lifecycle messages, capabilities, process incarnation, Run Delivery, and - question semantics. -7. Legacy direct mail and current Run Delivery are disjoint, even when their - Dispatches share one migrated Task and Run. -8. A B-era disconnect may replay accepted work; it may not lose mail or create - a second question, reply, completion, or settlement. A cut after an A-era - ask was already answered is explicitly ambiguous because A persisted no - invocation identity; recovery replays its answer through legacy check before - a same-text question is asked again. -9. Different reply bodies for the same source and principal conflict instead - of creating multiple replies. -10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows - follow the same proof rules. Missing proof degrades to inspection without - invalidating the retained assignment or filesystem work. - -## Storage and atomic adoption - -Raise the schema version and add: - -```text -dispatch_contexts.contract_version INTEGER NOT NULL -dispatch_contexts.launch_token_hash TEXT -messages.delivery_contract TEXT NOT NULL - CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only')) -legacy_adoptions( - source_run_id PRIMARY KEY, - adopted_run_id UNIQUE NOT NULL, - scheduler_state_lost INTEGER NOT NULL, - adopted_at -) -legacy_compatibility_principals( - id PRIMARY KEY, - run_id NOT NULL, - dispatch_id, - role CHECK(role IN ('worker', 'coordinator')), - host_scope NOT NULL, - terminal_handle NOT NULL, - pane_key NOT NULL, - launch_token_hash NOT NULL, - process_incarnation, - status CHECK(status IN ('committed', 'settled', 'revoked')), - UNIQUE(role, run_id, dispatch_id) -) -legacy_operation_receipts( - principal_id, - operation_key, - method, - payload_hash, - effect_id, - response_json, - completed_at, - PRIMARY KEY(principal_id, operation_key) -) -legacy_mail_receipts( - principal_id, - message_id, - acknowledged_at, - PRIMARY KEY(principal_id, message_id) -) -``` - -The schema migration runs in one `BEGIN IMMEDIATE` transaction: - -1. create a persisted random Run ID and `legacy_adoptions` marker if one does - not already exist; -2. create an ordinary `legacy = 0` Run with a recovery objective; -3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages, - and question threads to it without changing their IDs, sequence, status, or - content; -4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection - rows `audit_only`; -5. mark a Dispatch legacy only when it predates the contract column and lacks - a current dispatch capability; and -6. fence any impossible outstanding legacy Delivery, move retained Delivery - history to the adopted Run, assert every Task/Dispatch/gate/message/question/ - Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only - audit tombstone. - -Every new Dispatch and message writes the current contract explicitly. -Repeated startup, a partially initialized fixture, and WAL recovery reuse the -persisted adoption marker. Reset and fresh-database paths cannot synthesize -legacy authority. - -## Durable principals and live authority - -After the hook server and PTY inventory are ready, a one-shot adoption service -may commit compatibility principals. It does not mutate terminal processes or -input. - -Before renderer hydration, legacy Dispatch evidence also fences automatic -provider resume for the exact sleeping pane. Once the relevant local daemon or -SSH relay has authoritative inventory, Orca intersects the durable handle, -pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match -is adopted into its original tab/leaf as inactive background state while the -coordinator tab remains active. Missing, stale, reused, cross-host, or -ambiguous evidence stays fenced and inspectable. Ordinary provider resume is -unblocked only when authoritative inventory proves that exact PTY exited. - -At hook-server startup, copy the validated hydrated status rows into an -immutable main-process-only startup snapshot before the server listens or -binds the new listener. Later live events update a separate current-runtime -observation map and cannot overwrite the pre-update commitment source. A -current event is preferred but is not required before the worker's first -post-restart lifecycle command when controller inventory proves the exact -original PTY and process incarnation are live. - -For each candidate it resolves the union of: - -- durable Dispatch assignee and pane; -- equivalent-pane aliases; -- remote attachment and host ownership; -- coordinator candidates from active legacy coordinator rows, Task creator - handles, Dispatch coordinator/parent provenance, and direct-message - sender/recipient history; -- the restored terminal inventory and process incarnation; and -- the hydrated, previously authenticated hook pane/launch-token association. - -The candidate commits only when that union identifies exactly one role and -row. The orchestration database stores a SHA-256 token commitment, never the -raw token. The existing owner-only hook status cache is an input to the -one-time commitment, but a hydrated status is not enough to mutate. - -Every compatibility call then requires: - -- a hidden launch token and pane inherited by the calling process; -- the immutable pre-update hook commitment, plus either a matching - current-runtime hook event or exact current controller proof of the original - PTY and process incarnation; -- a live PTY with the committed handle, host scope, and process incarnation; -- the committed principal and exact active legacy Dispatch or adopted Run; and -- an operation-specific unique match. - -The current hook event or exact controller inventory proves that the process -survived the restart; equality with the pre-update commitment proves it is the -same launch. If the hook cache was absent, the provider cannot report the -proof, the token changed, or any candidate is ambiguous, the row remains -read-only. - -For new Dispatches, persist the launch-token hash at dispatch time so later -contract updates do not depend on hook-cache recovery. Never return or log raw -tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON -redaction tests. - -Host scope is runtime-owned: - -- local: stable local execution-host identity; -- WSL: stable native execution-host identity plus distro; -- SSH: saved-host authority plus remote pane/process identity; and -- federated runtime: authenticated peer fingerprint plus remote attachment. - -`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH -fallback must carry the hidden evidence without exposing it in command text or -logs. A local runtime never accepts a raw remote token as local authority. -Pre-update WSL processes without the token cannot use the lifecycle adapter. - -For SSH, the host passthrough and in-process fallback stamp the saved target, -`SshRelaySession` connection incarnation, and attachment provenance from the -authenticated runtime channel; caller environment cannot choose them. WSL -stamps the runtime-owned native host and distro in both the full bridge and -fallback bridge. The verifier intersects those stamps with the committed host -scope before considering token or pane evidence. - -## Mixed-contract Run and mail routing - -The adopted Run is a normal current Run. Tasks belong to it, so a current retry -of a preserved Task naturally routes to the same Run without a cross-Run Task -reference. - -Message delivery is selected by `messages.delivery_contract`, not by Run ID: - -- old and adapter-created direct messages are `legacy_direct`; -- current sends are `current_delivery`; and -- migration rejections and non-actionable history are `audit_only`. - -Current Run Delivery creation, filtering, waiting, and acknowledgment select -only `current_delivery`. Legacy `check` selects only the exact principal's -`legacy_direct` address. History and inbox may show all three with explicit -labels but never attach actions to `audit_only`. Mixed legacy/current attempts -under one Task are covered across send, ask, check, reply, and settlement. - -The original coordinator is automatically associated with the adopted Run -after its principal proves live. `run-use` may bind that same principal without -converting legacy Dispatches. A different coordinator cannot silently take -over while a legacy attempt is active; an explicit recovery takeover is a -separate current-contract action and revokes legacy coordinator mutation -authority before rebinding. - -When that coordinator uses old direct `check`, the adapter also performs a -non-consuming existence check for `current_delivery` mail. It never mixes the -messages into legacy output; it prints the exact current Run check command and, -after that command creates a Delivery, the exact acknowledgment form. This -keeps an unchanged coordinator informed about current retries without -weakening current Delivery semantics. - -## Exact legacy adapter - -The CLI keeps accepting the old flags. It sends a hidden compatibility -envelope; the runtime, not the CLI, decides whether legacy behavior applies. - -Version B generates a random hidden `compatibilityInvocationId` once per CLI -process and reuses it for every transport reconnect by that invocation. -`legacy_operation_receipts.operation_key` is this invocation ID for B calls. -The method and canonical payload hash must also match. - -Attested compatibility calls bypass the rotating-auth generic mutation ledger -after the contract fence routes them to the adapter. Their only dedupe boundary -is the compatibility-principal receipt transaction described here; current -calls continue through the existing generic ledger unchanged. - -Version A had no invocation ID. Cross-cutover reconstruction is therefore -limited to effects with a durable semantic identity: final settlement per -Dispatch, still-pending ask per normalized question/options/recipient, reply -per source/principal/body, and read recovery per message. Heartbeat and -escalation remain at-least-once across an A-to-B manual retry because -collapsing identical commands could suppress a legitimate later event. Tests -and guidance do not promise cross-cutover exactly-once behavior for those -repeatable signals. - -An A-era ask that was already answered before its output was lost is also -ambiguous. B does not guess whether the same text means retry or a new -question. Its answer is in the durable recovery cohort and the error tells the -agent to run the exact legacy check command first. After that answer is printed -and acknowledged, a new B invocation with identical text creates a new -question. If multiple identical A threads exist, ask fails ambiguous and -recovery check drains them by message sequence; no arbitrary thread is chosen. - -### Lifecycle `send` - -- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then - intersect them with the verified worker principal. -- `escalation`: resolve Task + verified worker principal to exactly one active - legacy Dispatch because the old prompt supplied no Dispatch ID. -- Preserve the old direct coordinator recipient after verifying it belongs to - the adopted Run principal. -- Infer missing `worker_done` outcome as `failed` only when the trimmed subject - is `Failed` or begins `Failed:`; otherwise infer `succeeded`. -- Normalize to the current internal settlement model. - -Compatibility validation happens before message insertion. Current lifecycle -handling is not refactored through this prevalidation: its existing -invalid-capability audit message behavior remains locked by regression tests. - -The DB commits the direct message, lifecycle effect, and compatibility receipt -in one transaction. If a pre-update `worker_done` already settled the Dispatch, -the adapter reconstructs the matching semantic receipt from the existing -message and settlement. The persisted A settlement is authoritative: the -pinned A reconciler recorded even a `Failed:` subject as completed, so B -returns that original completed result rather than retroactively changing Task -or dependency state. Only a not-yet-accepted B call applies the corrected -failure inference. A conflicting retry fails. - -### `check` - -Legacy check is at-least-once: - -1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs - without changing them; -2. the CLI prints the complete response; and -3. a hidden ack atomically records receipts and marks those exact rows read. - -A cut after read and before ack replays the same IDs. `--peek` and `--all` -remain read-only. - -Version A could mark a message read before losing its response. On adoption, -every eligible read direct message connected to a still-active legacy attempt -and lacking a post-adoption receipt is inserted into a durable recovery cohort -using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains -that stable cohort in bounded sequence-ordered pages before ordinary unread -mail. Each page is labeled -`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print -ack, and then advances. The cohort never grows after adoption, so every -eligible row is covered without an unbounded response. Settled-at-cutover and -unrelated historical mail do not enter it. - -### `ask` - -Legacy ask is an idempotent mutation followed by a resumable read: - -1. create or find the direct legacy gate/question by verified Dispatch, - normalized question, options, and recipient; -2. atomically commit its message/thread plus operation receipt and return the - stable question ID; -3. wait in bounded read-only slices using that ID; and -4. after printing the answer, hidden-ack that exact answer. - -On first adoption, existing pending decision-gate threads are indexed into the -same semantic identity. A retry resumes them instead of creating another gate. - -### `reply` - -Legacy reply requires the source thread, the verified coordinator principal, -and a unique question owned by the adopted Run. One transaction inserts the -reply, resolves the exact gate, marks the exact source, and completes the -receipt. - -An existing pre-update reply with the same source, principal, and body is -reconstructed as success. A second body for the same source/principal is a -conflict, not another reply. - -### Coordinator and Task commands - -After automatic binding, unchanged Task listing, Task settlement, dispatch, -and gate inspection use the adopted Run. A new attempt receives a current -preamble, capability, launch-token commitment, and current message contract. -No unrelated Task can be created until the migrated coordinator binding is -proven or explicitly taken over. - -## Windows update behavior - -No compatibility mutation holds a DB transaction while waiting. Windows -legacy `ask` commits the question, prints its stable ID and exact resume -command, then exits before a long wait whenever the installed launcher cannot -prove that the executable is replaceable. - -The native packaged launcher sets an internal -`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child. -After the DB commit, legacy `ask` writes and flushes the stable question ID and -the exact resume command, then exits with a documented `75` resume-required -status. The command name comes only from the validated inherited -`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or -`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather -than rendered. The launcher propagates status 75. No long wait starts on this -path. - -This compatibility path makes the accepted question recoverable without -claiming Windows updater or uninstaller process ownership. Generic executable -drain, replacement, and uninstall behavior remains separate updater -reliability work and requires its own Windows artifact proof. - -## Rendering and authoritative guidance - -Formatting is authority-aware: - -- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands; -- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay; -- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and -- current messages: unchanged. - -Update `skill-guides/orchestration.md` and regenerate its bundled source. The -guide explains automatic adoption, exact continuity, the retired scheduler -boundary, proof failures, current retries, and the Windows ask resume fallback. -It must not tell agents to restart a worker whose Dispatch was adopted. - -Formatting cannot use database provenance alone; current liveness and -attestation are runtime facts. Recovery may reveal the exact already-live PTY -once as an inactive background tab. It never creates a PTY or process, writes -input, switches workspaces, focuses, splits, or injects a terminal. - -## Deterministic verification - -### Schema and authority - -- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh - fixtures; preserve IDs and row counts and reuse one adopted Run. -- Assert Task, Dispatch, decision-gate, message, question-thread, and retained - Delivery Run ownership agrees; outstanding legacy Deliveries are fenced. -- Prove current retries in the adopted Run remain current across repeated - migrations. -- Test unique worker/coordinator import from prior hook commitment plus current - live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process, - duplicate-token, duplicate-role, equivalent-pane, and remote-attachment - ambiguity. -- Race a current-runtime hook before adoption and prove the immutable startup - commitment is neither overwritten nor confused with live proof. -- Cover manual coordinators with no `coordinator_runs` row and reject - conflicting candidates from Task, Dispatch, and message provenance. -- Assert all legacy authority failures occur before message/receipt/gate/state - effects while current invalid-capability rejection remains unchanged. -- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration - storage. - -### Exact old behavior and replay - -- Replay the pinned prompt's heartbeat, successful and `Failed:` - `worker_done`, task-only escalation, ask, and consuming check. -- Replay the old formatter's reply command separately. -- Cover every operation once after B and once accepted by A immediately before - the cutover where a durable semantic identity exists, including the bounded - recovery replay for an A check and preservation of A's persisted settlement. -- Prove one B invocation reuses its invocation ID across reconnect, while - separate identical heartbeat/escalation invocations remain distinct. -- Inject real transport cuts after check read, ask commit, reply commit, and - completion commit; prove stable IDs and one receipt/effect. -- Page a recovery cohort larger than one response limit across repeated - disconnects and prove every eligible row appears until ack, then never again. -- Test mixed legacy/current attempts for every send/check/ask/reply route and - prove current Delivery cannot consume legacy direct mail and old coordinator - check only advertises the exact non-consuming current Delivery command. - -### Live two-launch E2E - -The focused renderer/daemon restart regression reconstructs the rc.3-era -orphan artifact in a shared candidate profile: a durable legacy Dispatch, -sleeping provider record, and live daemon PTY with its visual binding removed. -It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf, -Task, and Dispatch; emits no provider-resume spawn or interruption; preserves -coordinator focus; and remains inert after switching away and back. This -candidate/candidate fixture covers the renderer regression deterministically -but does not replace the distinct installed-A/candidate-B packaged cutover -journey below. It does not claim packaged Windows updater behavior. - -Use distinct installed A and B artifacts plus an external append-only fake -agent spawn ledger: - -1. launch A with coordinator and worker terminals and record the replaceable A - desktop/main PID, runtime ID, version, and artifact hash separately from the - durable daemon, coordinator, and worker identities; -2. accept one command with durable semantic identity on A—pending ask, reply, - completion, or check—cut its response, close A without stopping the daemon - or fake agent, install B, and prove B owns the relaunched runtime; -3. assert A's desktop/main process and runtime are gone, B has a different - desktop/main PID, runtime ID, version, and artifact hash, while the daemon - plus coordinator/worker PID and start time, PTY/session, terminal handle, - tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical; -4. prove that exact pending ask, reply, completion, or check resumes or - deduplicates on B; do not use heartbeat, escalation, or an already-answered - A ask as an exactly-once oracle; -5. run the remaining exact old lifecycle commands and settle the same - Dispatch; -6. switch away and back and assert no second spawn/tab and no - `Conversation interrupted`; and -7. create a current attempt on preserved state and prove current grammar. - -The append-only spawn ledger and an append-only interruption ledger, daemon -inventory, runtime terminal inventory, and every viewer DOM independently -prove one launch, process, handle, tab, and no `Conversation interrupted`. -Transport fault injection cuts the real CLI connection after check output but -before ack, and after ask, reply, and completion commit. Re-run the byte-exact A -argv under B and assert stable IDs plus exactly one message, gate, reply, -settlement, and receipt. - -Retain #11142's local visible-inactive test and extend its oracle beyond DOM -counting with the ledgers and host inventory. - -Run separate remote journeys: - -- headed paired server A-to-B with a surviving external client; -- headless `orca serve` A-to-B with the same surviving-client oracle; -- both prove the remote PID/PTy continuity, both viewer states, zero - client-local handles/tabs, and wrong-host evidence with zero effects; -- Docker SSH restart/reconnect with a remote proof file binding the process, - pane, and token commitment; -- physical Windows WSL coverage proving the correct distro and rejecting a - wrong-distro token; and -- a git-independent folder workspace parameter through the same adoption - journey. - -Packaged Windows updater continuity is outside this migration gate. A separate -updater change must prove candidate-built A/B install, executable release, -resume, and normal uninstall behavior on Windows before making that guarantee. - -## Implementation slices - -1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox. -2. Hook-commitment import, live principal verifier, host scopes, and redaction. -3. Atomic compatibility receipts plus lifecycle send/escalation. -4. Check read/ack and cutover recovery replay. -5. Ask create/resume, reply conflict semantics, and coordinator binding. -6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback. -7. Authority-aware formatting and the version-matched orchestration guide. -8. Two-launch, remote, platform, and #11142 regression coverage. -9. Focused unit/type/lint/E2E checks and final adversarial review. - -## Definition of done - -- Updates are never blocked. -- Every durable legacy Task is owned by an ordinary visible Run. -- Every provably original live legacy Dispatch can finish through its existing - prompt without changing process or worktree. -- New attempts use the current contract and mailbox in that same Run. -- No authority comes from IDs or handles alone. -- No adoption step starts, stops, writes, signals, focuses, splits, or injects - a terminal. Exact live-PTY recovery may restore one inactive background - renderer tab/leaf binding. -- B-era disconnects replay or deduplicate instead of losing or duplicating - work; A-era repeatable signals and already-answered asks follow the explicit - ambiguity recovery rules. -- Lost scheduler memory is reported precisely without stranding durable state. -- Local/background, remote, SSH, WSL, folder, and Windows guarantees have - deterministic proof. -- The PR contains this plan, focused implementation, and no unrelated changes. diff --git a/docs/reference/remote-agent-session-host-authority.md b/docs/reference/remote-agent-session-host-authority.md deleted file mode 100644 index 80a905abef7..00000000000 --- a/docs/reference/remote-agent-session-host-authority.md +++ /dev/null @@ -1,456 +0,0 @@ -# Remote agent-session host authority - -Status: implemented single-PR v1 design for issues #8878 and #9352; deterministic validation complete. - -## Reliability contract - -- **Invariant (`agent-session.remote-host-authority`):** one provider-session identity has at most one live PTY owner and canonical host surface on every claim-capable route; exact exit retires that incarnation durably. -- **Failure source:** issues #8878 and #9352, including concurrent remote clients, ambiguous replies, exit-before-publication, and stale client snapshots. -- **Oracle:** the focused ownership/lifecycle matrix and `pnpm test:repro:remote-agent-session` prove one physical spawn, canonical retry adoption, exact exit retirement, stale-publication rejection, and no restart resurrection. -- **Gate:** the experimental `agent-session.remote-host-authority` entry in `config/reliability-gates.jsonc`. -- **Coverage:** deterministic macOS local/daemon/remote-runtime evidence plus SSH/relay fault-injection; Linux, Windows, WSL, and live SSH remain explicit gaps. -- **Performance budget:** no polling or terminal-output work; admission-only provider reconciliation is inflight-deduped, and operation state is capped and expiring. -- **Diagnostics:** structured RPC error codes, PTY incarnation IDs, owner generations, operation dispositions, and the repro artifact distinguish fallback, adoption, conflict, and retirement failures. -- **Residual gaps:** durable fresh-operation journaling, automatic sleep checkpoints, verified nested-SSH namespaces, and multi-process profile coordination are documented under Future extensions. - -## Summary - -A remote Orca host, not an attached renderer, decides whether a provider agent -session already has a live PTY. Clients send structured intent (fresh launch or -explicit provider identity); the host returns one canonical terminal surface. - -This fixes two related failures: - -- A paired client could consume its own persisted sleep record and launch a - second TUI while the remote host still owned the first one (#8878). -- An exited host terminal could remain in `session.tabs.list` as a handle-less - placeholder, get persisted by clients, and return as a ghost tab (#9352). - -The v1 protocol deliberately fails closed after authority side effects begin. -At mixed-version boundaries the host may return -`agent_session_legacy_required`, but only after a read-only execution-owner -check and before trust, claim, spawn, or any retained replay fence. The client can then run -its retained exact legacy request, so upgrading any subset of clients, hosts, -daemons, or relays does not remove workflows that worked before the upgrade. - -## Scope and guarantees - -This change guarantees: - -1. Runtime-owned worktrees always queue renderer sleep records into the normal - transport. A fully capable route turns that intent into an authoritative - ensure/adoption; mixed-version routes preserve the legacy wake behavior. -2. On capable hosts, known provider sessions resume through a structured - `terminal.ensureAgentSession` request. -3. On claim-capable execution routes, concurrent or repeated ensures for the - same canonical provider identity return one execution owner and one - canonical terminal surface. -4. A fresh launch uses `terminal.createAgentSession` with a caller-scoped - operation ID. Stable runtime surface identity, daemon session identity, and - relay operation identity prevent response-loss retries from creating a - second process while their respective owner remains alive. -5. New hosts continue accepting agent-bearing legacy terminal-create RPCs, so - old clients behave exactly as they did before the host upgrade. -6. Structured explicit resume returns `agent_session_legacy_required` before - side effects when a daemon is old or an SSH route cannot attest its execution - namespace. A claim-capable route still fails closed on malformed, - conflicting, or unknown ownership after dispatch. -7. Natural or explicit PTY exit retires only the exact PTY incarnation and - terminal surface from both `terminal.list` and `session.tabs.list`, repairs - active/group topology, and removes durable host persistence. -8. New clients use structured authority only when advertised. Capability - absence or a transient read-only probe failure selects the exact legacy - payload; protocol incompatibility remains blocked. - -The following are not v1 guarantees: - -- automatic resume of an intentionally sleeping remote agent; -- fresh-launch exactly-once behavior across a full runtime process restart; -- host-authoritative deduplication of resumes through an unverified - direct/nested SSH execution namespace (those launches retain legacy behavior); -- supervising a provider process after its owning PTY exits; -- coordinating multiple independent Orca main processes for one profile; -- preventing a nonconforming new client from deliberately sending the same - legacy wire request as an old client; authenticated request-level capability - negotiation does not yet exist, so the server cannot distinguish them. - -Those constraints are explicit so future work can extend the protocol without -weakening the v1 safety boundary. - -## Authority model - -There are three layers: - -| Layer | Responsibility | -| --------------------------------- | --------------------------------------------------------------------------------------- | -| Client/renderer | Sends structured intent and mirrors host snapshots | -| Runtime/controller | Resolves worktree and provider identity, signs a claim, publishes the canonical surface | -| Execution owner (daemon or relay) | Atomically claim-or-spawn, prove liveness, and recover live claims from listings | - -The execution owner is the lowest process that can atomically answer “is there -already a live PTY for this agent identity?” Keeping the registry there closes -the race between multiple runtime calls. The controller also keeps a registry -above providers so separate local/SSH routes cannot independently claim the -same identity. - -## Structured requests - -### Explicit resume - -`terminal.ensureAgentSession` accepts only a supported agent and normalized -provider identity: - -```ts -{ - kind: 'explicit' - worktree: string - agent: ResumableTuiAgent - providerSession: { - key: 'session_id' | 'conversation_id' - id: string - transcriptPath?: string - } - agentArgs?: string | null - launchPreferences?: { model?: string; effort?: string; mode?: string } - presentation?: 'focused' | 'background' - placement?: { tabId?: string; leafId?: string } -} -``` - -The host canonicalizes the provider identity, binds it to the execution -namespace and canonical worktree, and signs a digest claim. Raw resume commands -do not cross a claim-capable boundary; compatibility-selected legacy and -unverified nested SSH paths retain their prior opaque command behavior. - -The execution owner performs one atomic operation: - -```text -claim absent -> reserve -> spawn -> publish live owner -> created -claim live -> prove PTY liveness -> return canonical owner -> adopted -claim unknown -> fail closed; do not spawn -claim conflict -> fail closed; do not spawn -``` - -Only an adopted owner may override the requested tab, leaf, handle, or PTY ID. -A fresh provider result must match the surface requested by the host. - -### Fresh launch - -`terminal.createAgentSession` accepts structured agent, prompt-delivery mode, -launch preferences, optional explicit agent arguments, and a cryptographically -random client operation ID. Draft prompts remain drafts; submitted prompts use -the normal startup-delivery path. Omitted agent arguments preserve host -defaults, while an explicit string is preserved as a client override and an -explicit null/empty value clears host argument defaults. Free-form client -environment variables are deliberately not accepted because PATH, loaders, and -other process authority remain host-owned. - -The runtime reserves the caller-scoped operation before any asynchronous -workspace or capability preflight, then fingerprints the host-resolved request -under the authenticated device identity: - -- same caller + operation ID + same fingerprint returns `replayed`; -- same caller + operation ID + different fingerprint fails; -- malformed, future-dated, expired, or over-capacity operations fail closed. - -The operation ledger is memory-bound and retained for 24 hours. Pre-spawn -failures release the entry for a safe retry. Once PTY creation commits, or the -provider reports an unknown physical outcome, the same rejected promise remains -as the replay fence. This handles response loss and ordinary reconnects to the -same running host. It does not claim exactly-once creation after the runtime -process itself restarts. - -Physical commit is the native-spawn boundary, not listener registration or -surface publication. The in-process provider reports it immediately after -`node-pty` returns; daemon and relay paths report it when their lower owner -returns from spawn/create-or-attach. Commit reporting is one-shot across these -layers. Any later error retains the operation fence because the PTY may already -exist even if publication failed. - -The runtime derives the execution-operation ID, tab ID, leaf ID, and terminal -handle deterministically from the authenticated operation. A daemon-backed -spawn derives a legacy-length session ID from that execution operation, so -`createOrAttach` returns the same PTY after a lost response without shrinking -the accepted worktree-ID boundary or skipping first-spawn setup. An SSH -provider performs a bounded read-only relay probe before structured work: - -- a relay advertising `agentSessionCreateOperationVersion: 1` receives the - operation ID and replays one successful spawn result for 24 hours; -- an older, malformed, or temporarily unreachable relay makes the host return - `agent_session_legacy_required`, after which the client sends its unchanged - legacy payload; -- negative relay capability results are not pinned, so an in-place upgrade is - observed on the next request. - -Relay operation-owned PTYs survive stale request contexts so the retry can -recover the same PTY and incarnation. Ordinary stale shell spawns keep the -existing cleanup behavior. - -## Claim identity - -The claim contains no raw provider session ID. A host-only signer hashes: - -- normalized agent/provider identity; -- canonical worktree scope; -- execution machine and principal; -- container/runtime namespace; -- a conservative provider-root bucket. The v1 implementation deliberately - merges account roots for an agent, which can produce a safe conflict but - cannot authorize duplicate execution. - -The wire binding includes a key ID, digest version, identity digest, worktree -scope digest, and agent kind. Owner state adds a random generation, PTY ID, and -canonical surface. - -Generation and PTY-incarnation guards prevent a late exit or liveness result -from releasing, retiring, or adopting a replacement owner that reused the same -PTY ID. - -## Recovery and failure semantics - -Before every claimed ensure, the controller stages complete listings from local -and registered SSH providers, validates every owner, and atomically replaces -the authoritative portion of its registry. Absent owners are pruned only for -providers whose listing is authoritative; disconnected scopes retain their -fence. Active reservations survive reconciliation. Valid metadata also rebuilds -PTY-to-provider routing. - -Controller-owned in-process fallback claims are intentionally not serialized -in ordinary local process listings. Their listing absence is therefore not -authoritative: the controller keeps the claim while the exact PTY incarnation -remains listed and releases it through the normal exit path. Daemon routers and -degraded providers likewise advertise listing authority only for a proven -PTY-to-provider route; an unknown ID never falls through to an unrelated -current/fallback provider for this decision. - -Recovery is fail closed: - -- owner PTY differs from the listed session: `agent_session_ownership_unknown`; -- two listings disagree about an identity or generation: - `agent_session_conflict`; -- the recorded provider is disconnected or unregistered: - `execution_owner_unavailable`; -- a claim-bearing spawn reaches a daemon/relay without execution-owner claim - protocol v2 (including PTY incarnation proof): - `agent_session_claim_unavailable`. Nested SSH routes that cannot construct a - claim are selected into legacy behavior before this boundary. - -Unknown liveness never means dead. A transient relay outage therefore retains -the claim and cannot authorize a replacement agent. - -Daemon adoption is attach-only. If the owner exits between liveness proof and -attach, the request fails rather than falling through to a new unclaimed shell. - -Serialized relay shell state intentionally omits provider claims. Spawn-based -revival creates a new shell and cannot inherit authority from the old process. - -## Renderer behavior - -Runtime-owned worktrees queue cached resume evidence through the same mounted -pane transport regardless of capability-cache timing. A capable host adopts or -creates one canonical owner; an old host or execution owner receives the exact -legacy launch. A cold or expired cache therefore cannot bypass authority or -remove the pre-change workflow. - -AI Vault resumes use provider metadata for agents with a structured identity, -including Antigravity conversation IDs and Pi transcript/session paths. If -metadata or host capability is absent, Orca preserves the prior opaque legacy -resume request instead of blocking the user. - -Background launches, quick launches, and mounted remote panes use -`terminal.createAgentSession` on capable hosts. Otherwise each call site sends -the exact pre-change `terminal.create` or `session.tabs.createTerminal` payload. -The common router calls legacy after only these safe outcomes: the host capability -is unavailable before dispatch; the structured RPC returns the stable -pre-side-effect `agent_session_legacy_required` code; or a replaced old host -returns `method_not_found`, proving it never recognized the structured request. -Timeouts, malformed results, and every other structured error never downgrade. - -Structured create/ensure responses record an exact -environment/worktree/provisional-tab to canonical-host-tab handoff. Snapshot -reconciliation removes a provisional pane only when its requested tab ID is -mirrored or that explicit handoff points to a host tab in the snapshot; agent -kind alone is never identity. This prevents an unrelated Claude/Codex session -from deleting a same-agent automatic-resume pane. The matched pane's pending -startup and automatic-resume claim are removed atomically, and the client -re-accepts the current host snapshot in case it arrived before the response. - -Every structured result is host-owned. If snapshot handoff destroys the -provisional transport while create/ensure is in flight, late completion cannot -close the canonical PTY even when reconciliation is still catching up. - -After the host accepts creation, a later tab-move or snapshot-refresh failure -still returns `created`. Reporting the launch as failed would invite a retry -with a new operation ID and could duplicate the fresh agent. - -## Exit and persistence lifecycle - -PTY exit is terminal authority. A generic persisted `sleeping` row is not a -reason to preserve a surface. Only an exact, runtime-owned stop transaction may -temporarily preserve the intentional handle-less surface. Otherwise the -runtime: - -1. verifies the PTY incarnation and identifies the exact worktree/tab/leaf; -2. removes that leaf from the host snapshot; -3. removes an empty parent tab; -4. repairs split groups, active group/tab, recent order, and layout; -5. removes the terminal binding from the persisted host workspace session and - advances that repo's host topology revision; -6. synchronously flushes that retirement before publishing the in-memory - absence; -7. rebases later renderer writes onto the host's current terminal membership, - so metadata and layout edits remain writable but missing/live panes cannot - be added or removed by a stale client. - -The durable fence is one monotonic revision per affected repo, not one record -per historical close or deleted worktree. Its storage is therefore proportional -to repos plus current terminal surfaces. A real host-admitted spawn advances -the revision when it adds a tab or leaf, allowing fresh terminals after a -retirement while an older renderer snapshot remains unable to revive the old -surface. Legacy per-pane tombstones are accepted for mixed-version recovery -and collapsed into the repo revision on the next normalized write. -The revision remains private to each execution host: renderer hydration and -writes omit it, avoiding collisions when different hosts contain the same repo -ID. Each host preserves the revision while rebasing client session writes. - -An exit can also beat initial terminal registration. The runtime records that -PTY/incarnation before any surface exists, rejects registration of the same or -unproven incarnation before mutating provider/output sequence, execution -context, ownership, lease, binding, handle, terminal, or tab state. The native -callback or successful lower-owner return still reports physical-spawn commit -before this admission check so a lost provider response cannot authorize a -second fresh agent. A proven different incarnation, or an explicit new local -lifecycle for a provider that cannot report incarnation identity, clears the -fence. When registration rejects the recorded incarnation, that specific -caller's fence is released after rejection so repeated early-exit failures do -not accumulate process memory. - -Registration intent is explicit: the controller marks the expected PTY before -dispatch and clears that intent on every success or failure path. Surface -absence is never treated as evidence that registration is still in flight. -SSH and daemon providers also settle an attach/create response against any exit -that arrived in the same transport batch before returning control upward. This -keeps response/exit ordering and incarnation comparison at the layer that can -observe both events. - -“Explicitly killed” is treated as a normal terminal-gone lifecycle outcome in -the remote transport, not as an unexpected product-error toast. - -## Protocol and compatibility - -The runtime protocol remains v3, with minimum compatible client and server v2. -This change adds optional RPCs and fields, so a protocol fence would make a -rolling upgrade worse without providing an authorization boundary. The runtime -advertises `agent-session.host-authority.v1`; clients negotiate that capability -before choosing a launch path. - -| Client | Host | Result | -| ------ | ---- | -------------------------------------------------------------------------------------------------------- | -| New | New | Structured authority is enabled when the resolved execution owner also supports it. | -| New | Old | The client selects legacy before spawn, or falls back on safe `method_not_found`; behavior is unchanged. | -| Old | New | The host still accepts legacy agent-bearing terminal creates; behavior is unchanged. | -| Old | Old | Unchanged legacy behavior. | - -Capability probing is read-only. A transient probe failure may select legacy, -because no structured side effect has started. A real protocol compatibility -block is still surfaced and never bypassed. A capable host then checks the -resolved daemon or relay. Only `agent_session_legacy_required`, emitted before -trust, claim, spawn, or any retained replay fence, permits legacy; every later error stays on -the structured path. The other post-dispatch exception is `method_not_found` -from an old host, which proves the method could not have started. Host and -lower-owner unsupported verdicts are not pinned, and observing a new runtime ID -invalidates a predecessor's positive verdict, so rolling upgrades and process -replacement re-probe promptly. The SSH probe is bounded below the client RPC -timeout, concurrent callers have independent cancellation, successful -structured creates require PTY and incarnation identity, and request -cancellation is checked at the real provider seams: after asynchronous -capability/connection preflight and immediately before local native spawn, -daemon `createOrAttach`, or SSH `pty.spawn`. Once SSH dispatch begins, an -operation failure is treated as an unknown physical outcome rather than a safe -fresh retry. - -The same monotonic rule applies below the runtime: - -| Runtime/controller | Execution owner | Result | -| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------- | -| New | New daemon | Stable operation-derived session ID makes retry attach to the same PTY. | -| New | Old daemon | The host returns `legacy_required` before side effects; the client sends its exact old resume/create request. | -| New | New relay | Relay operation ledger replays the same PTY and incarnation. | -| New | Old relay | The host returns `legacy_required` before side effects; the client sends its exact old spawn request. | -| Old | New daemon/relay | New optional fields are absent, so pre-change behavior is unchanged. | - -This contract is monotonic: upgrading any subset never removes a workflow that -worked before. The bug fix activates only where every authority layer required -for that specific path can prove support. - -## Deterministic reproduction harness - -Run: - -```sh -pnpm test:repro:remote-agent-session -``` - -The harness builds Orca, starts a real headless Electron `orca serve` process on -an ephemeral port, and connects independent Node client processes over the -normal encrypted WebSocket pairing path. It creates and registers a real Git -repository in an isolated profile and uses the real daemon claim registry with -a controlled agent subprocess. No installed agent, external service, fixed -port, timing race, or Docker daemon is needed. - -It asserts: - -- two clients race the same structured resume; -- exactly one daemon subprocess is spawned; -- both clients receive the same canonical handle, tab, pane, and PTY; -- a retry that may have lost its earlier response adopts that owner; -- a real `terminal.close` produces PTY exit and both `terminal.list` and - `session.tabs.list` omit the surface; -- a stale layout publication cannot recreate the retired surface; -- restarting the serve process with the same profile cannot resurrect the - terminal or tab. - -Lower-level tests separately cover daemon attach races, controller recovery, -provider disconnects, conflicting listings, old SSH relays, malformed SSH -claim results, cancellation at physical provider seams, pre-publication native -spawn failures, exact provisional handoff, early exit before registration, and -exit-driven durable retirement. - -## Future extensions - -### Host-owned automatic sleep checkpoints - -Automatic remote sleep/resume should be added only as a host transaction: - -1. persist a random, generation-bound checkpoint before stopping; -2. publish a non-connectable transition state; -3. stop and verify the exact owner; -4. commit sleeping state only after the owner is gone; -5. consume the checkpoint atomically during ensure. - -Until this exists, renderer-local records may trigger resume intent but cannot -authorize a second owner on a claim-capable route. Compatibility-selected -legacy mode keeps the pre-change behavior. - -### Durable fresh-operation journal - -If fresh-launch exactly-once behavior must survive runtime restart, replace the -memory ledger with a profile-scoped durable journal. It must persist the caller, -operation ID, request fingerprint, canonical result/tombstone, and retention -deadline before returning success. Capacity must reject rather than evict an -unexpired tombstone. - -### Verified SSH execution namespaces - -Direct or nested SSH agent-session authority requires relay-attested machine, -principal, container, and provider-root identity plus a separately versioned -claim capability. Connection labels or target aliases are not proof. Until -that attestation exists, the host requests exact legacy fallback before spawn; -v1 does not claim deduplication for that route. - -### Multi-process coordination - -Supporting multiple Orca main processes against one profile requires an -OS-held coordinator lease around claim and journal mutation. The current v1 -contract coordinates clients of one runtime/controller process and its daemon -or registered relays. diff --git a/docs/reference/resource-manager-memory-metrics.md b/docs/reference/resource-manager-memory-metrics.md deleted file mode 100644 index c8c6627bebb..00000000000 --- a/docs/reference/resource-manager-memory-metrics.md +++ /dev/null @@ -1,54 +0,0 @@ -# Resource Manager memory metrics - -Resource Manager reports two different kinds of memory data. Process totals describe Orca and -locally managed terminal processes; host totals describe the machine running Orca. They are not -interchangeable. - -## Process memory - -Each snapshot declares one `processMemoryMetric`: - -| Platform | Metric | Source | -| ------------ | ------------- | ---------------------------------------------------------- | -| macOS, Linux | `rss` | `ps` resident set size | -| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback | - -Orca walks each registered local PTY subtree and claims every PID at most once. App, session, -worktree, history, and snapshot memory values are sums of those per-process samples. - -RSS and working set are not unique physical-memory measurements. Shared pages can appear in more -than one process, and macOS can count the same resident page through multiple mappings in one -process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the -metric as a sum and must not present it as a percentage of system RAM. - -Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until -the executing host supplies resource data. Folder workspaces use the same registered-PTY -attribution as Git worktrees. - -## Host memory - -`HostMemory` keeps immediate free memory separate from memory available without material pressure: - -- `freeMemory` is Node's host free-memory value. -- `availableMemory` is the best bounded platform value. -- `usedMemory` is `totalMemory - availableMemory`. -- `memoryUsagePercent` is derived from `usedMemory`. -- `availableMemorySource` identifies how availability was measured. - -The sources are: - -| Platform | Preferred source | Fallback | -| -------- | ----------------------------------------- | ---------------- | -| macOS | `memory_pressure -Q` available percentage | Node free memory | -| Linux | `/proc/meminfo` `MemAvailable` | Node free memory | -| Windows | Node free memory | Node free memory | - -This percentage describes host availability. It is not derived from the summed process metric. - -## Bounded collection - -Resource Manager polls while its popover is open, so every snapshot must remain bounded under a -large process tree. macOS `footprint` provides a more physical process metric, but collecting it -across dozens of processes is too expensive for this path. A future physical-footprint backend -must use a bounded host API or helper and declare a distinct metric before product copy treats it -as physical memory. diff --git a/docs/refresh-github-issues-after-create.md b/docs/refresh-github-issues-after-create.md deleted file mode 100644 index 94b2931be4e..00000000000 --- a/docs/refresh-github-issues-after-create.md +++ /dev/null @@ -1,138 +0,0 @@ -# Refresh GitHub Issues After Create - -## Problem - -Issue https://github.com/stablyai/orca-internal/issues/101 reports that the GitHub issues list can stay stale after creating a new issue. - -- `src/renderer/src/components/TaskPage.tsx:3891` bumps `taskRefreshNonce` after successful issue creation, intending to refetch the list. -- `src/renderer/src/store/slices/github.ts:1644` honors `force` only for the renderer cache and in-flight dedupe. -- `src/renderer/src/store/slices/github.ts:1666` calls `window.api.gh.listWorkItems` without telling main to bypass the GitHub CLI cache. -- `src/main/github/client.ts:821` and `src/main/github/client.ts:846` use `gh api --cache 120s` for the recent issues and PR REST paths, so a forced renderer refresh can still receive a pre-create response for up to two minutes. -- `src/main/runtime/rpc/methods/github.ts:10` and `src/main/runtime/rpc/methods/github.ts:283` do not accept or forward a cache-bypass flag for SSH/runtime clients. - -## Root Cause - -The post-create flow forces only Orca's renderer-side work-item cache. It does not bypass the GitHub CLI REST cache used by the main-process recent work-item fetch, so the refreshed request can reuse stale `gh api --cache 120s` data. - -## Non-Goals - -- Replace normal list caching or reduce default cache TTLs. -- Change query/search filtering behavior. -- Change GitLab, Linear, project view, or PR detail caches. -- Add polling after issue creation. -- Add new UI controls or visible copy. - -## Design - -1. Add an optional `noCache` flag to the renderer work-item fetch options and the GitHub work-items list contract: - - `FetchOptions` in `src/renderer/src/store/slices/github.ts`; - - preload API type and implementation for `gh.listWorkItems`; - - IPC handler args for `gh:listWorkItems`; - - web preload routing, which forwards `gh.listWorkItems` to `github.listWorkItems` for web/remote clients; - - runtime RPC schema and handler for `github.listWorkItems`; - - `OrcaRuntime.listRepoWorkItems`; - - `listWorkItems` and the internal recent-list helper in `src/main/github/client.ts`. - -2. Keep `force` and `noCache` separate. `force` means "bypass renderer cache and in-flight dedupe"; `noCache` means "bypass `gh api --cache`". In TaskPage, pass `{ force: forcedFetch || shouldProbeOnLanding, noCache: forcedFetch }` so nonce-triggered refreshes and preference invalidation bypass the GitHub CLI cache, while the one-time landing probe still behaves like today's background revalidation. Today `taskRefreshNonce` is shared by create, manual refresh, retry, filtering, preset changes, and PR merge refresh, so the implementation should either accept that whole nonce-triggered set as the no-cache scope or split create/manual refresh intent into a separate signal before narrowing it. - -3. When `fetchWorkItems(..., { noCache: true })` calls `window.api.gh.listWorkItems`, pass `noCache: true`; otherwise omit it or pass `false`. - -4. Track `noCache` alongside `force` in `inflightWorkItemsRequests`. A request with `noCache: true` must not dedupe onto an existing request with `noCache: false`, even if that existing request is forced; wait for the existing request to settle and issue a fresh no-cache request. This preserves the create path when it races a landing probe, which is `force: true` but intentionally cacheable. - -5. In `listRecentWorkItems`, build REST args with `[]` when `noCache` is true and `['--cache', '120s']` otherwise. Apply this only to the REST `gh api` issue/PR list calls that currently use the cache. Keep fallback `gh issue list` / `gh pr list` and queried paths unchanged because they do not use this REST cache. - -6. Preserve current force semantics: - - non-forced loads keep using the 120-second CLI cache; - - forced loads still wait out non-forced in-flight requests before issuing a fresh request; - - no-cache loads also wait out cacheable in-flight requests before issuing a fresh request; - - force continues to refresh all selected repos through the existing TaskPage effect. - -7. Add regression coverage: - - renderer store: `force + noCache` sends `noCache: true`; `force` without `noCache` and non-force calls omit it; - - renderer store: `force + noCache` does not dedupe onto an in-flight `force` request that lacks `noCache`; - - TaskPage or a focused equivalent: post-create/manual nonce path sets `noCache`, but landing probe does not; - - desktop IPC: `gh:listWorkItems` forwards `noCache`; - - web preload: `gh.listWorkItems` forwards `noCache` through the runtime route; - - main GitHub client: `listWorkItems(..., { noCache: true })` omits `--cache 120s` on recent REST issue/PR calls; - - runtime RPC: `github.listWorkItems` accepts and forwards `noCache`. - -## Data Flow - -- User creates GitHub issue in Tasks. -- `handleCreateNewIssue` bumps `taskRefreshNonce`. -- TaskPage effect computes `forcedFetch=true`. -- `fetchWorkItemsAcrossRepos` calls `fetchWorkItems` with `{ force: true, noCache: true }` for the nonce-triggered refresh. -- `fetchWorkItems` bypasses renderer cache and, when `noCache` is set, calls `gh.listWorkItems({ noCache: true })`. -- Desktop IPC or runtime RPC forwards `noCache`. -- `listRecentWorkItems` omits `gh api --cache 120s` for that fetch. -- GitHub returns a fresh recent issue list; cache is repopulated with the new issue. - -## Edge Cases - -- Multiple selected repos: all selected repos refresh through the existing fan-out; `noCache` applies only to `forcedFetch`, not the landing probe. -- Fork/upstream issue source: source resolution stays unchanged, and the cache bypass applies to whichever source is selected. -- SSH/runtime repo: runtime RPC accepts and forwards `noCache`, so remote clients do not retain the stale-cache bug. -- In-flight non-forced request: existing force logic waits for the stale request to settle, then issues a fresh no-cache request. -- In-flight forced landing probe: a nonce-triggered no-cache request must not dedupe onto the cacheable landing probe; otherwise create can still repaint from `gh api --cache 120s`. -- One-time landing probe: it still uses `force` to bypass renderer freshness, but must not set `noCache`; otherwise merely opening Tasks with cached rows would spend uncached GitHub API requests. -- Search query active: queried paths already use `gh issue list` / `gh pr list` rather than cached REST calls, so no behavior change is required. -- Pagination: next-page fetches use queried/cursor paths and do not populate the renderer work-items cache, so `noCache` is page-0-only. -- Concurrent windows: the creating window refreshes immediately; other renderer windows keep their own cache until their next refresh, landing probe, or TTL expiry. This change should not introduce cross-window invalidation. -- External GitHub mutations: external issue changes still rely on existing TTL/manual refresh behavior; this fix only guarantees freshness for Orca-originated create flows. -- Network/auth errors: existing partial-failure handling and banners remain unchanged. - -## Test Plan - -- Unit: extend `src/renderer/src/store/slices/github.test.ts` or add focused coverage for `fetchWorkItems` `force`/`noCache` IPC args and the no-cache-vs-cacheable-in-flight dedupe case. -- Unit: cover the TaskPage nonce path or isolate the option computation so nonce-triggered refreshes set `noCache` and the landing probe does not. If implementation narrows no-cache to a new create/manual-refresh signal, cover that narrower signal explicitly. -- Unit: extend `src/main/ipc/github.test.ts` to assert `gh:listWorkItems` forwards `noCache` to the client. -- Unit: extend `src/renderer/src/web/web-preload-api.test.ts` to assert web/remote `gh.listWorkItems` preserves `noCache`. -- Unit: extend `src/main/github/client-issue-source.test.ts` or `src/main/github/client-work-items.test.ts` to assert recent no-cache requests omit `--cache 120s` while normal recent requests keep it. -- Unit: extend `src/main/runtime/rpc/methods/github.test.ts` and/or `src/main/runtime/orca-runtime.test.ts` for `noCache` schema/forwarding. -- Typecheck: `pnpm typecheck`. -- Lint: `pnpm lint`. -- Electron validation: create an issue only in a throwaway/test repo if available; otherwise validate the refresh behavior with mocked/local unit tests and capture the Tasks issue list state without mutating live data. - -## UI Quality Bar - -Not UI-visible. The existing issue list UI and create-issue dialog should look unchanged; only freshness after a forced refresh changes. - -## Review Screenshots - -1. GitHub Tasks issue list after refresh/create path is reachable. -2. Create issue dialog before submission, if validation can use a throwaway repo. -3. Post-create issue detail/list state, only if validation can use a throwaway repo without mutating live user data. - -## Rollout - -1. Add `noCache` to renderer fetch options plus shared/preload/web/runtime IPC contracts. -2. Thread `noCache` through web routing, runtime, and desktop main-process handlers. -3. Apply `noCache` to the recent REST work-item list args. -4. Pass `noCache` from nonce-triggered renderer work-item fetches, but not landing probes. -5. Add regression tests. -6. Run typecheck, lint, and focused tests. - -## Lightweight Eng Review - -- Scope: reduced to force-refresh cache bypass for the existing work-item list path; no polling, UI changes, or TTL changes. -- Architecture/data flow: the renderer already owns refresh intent, while main owns GitHub CLI args. Thread `noCache` as explicit request metadata across preload, web preload, desktop IPC, runtime RPC, and SSH-aware runtime methods; do not infer it from every `force` call because landing probes also use `force`. -- Failure modes covered: - - stale `gh api --cache 120s` response after create; - - forced fetch deduping onto a non-forced in-flight request; - - runtime/SSH clients lacking the cache-bypass argument; - - upstream/origin issue-source selection still resolving before fetch; - - queried path accidentally changing despite not using REST cache. -- Test coverage required: - - renderer store IPC args for `force`/`noCache` combinations; - - TaskPage option computation for nonce-triggered refresh vs landing probe; - - desktop IPC and web preload forwarding; - - main GitHub client recent-list REST args with and without `noCache`; - - runtime RPC schema/handler forwarding `noCache`; - - focused typecheck/lint. -- Performance/blast radius: low when `noCache` is limited to nonce-triggered refreshes and preference invalidation. Normal loads and landing probes keep the CLI cache; the no-cache path doubles the fresh REST calls for repos that have both issue and PR sources, so avoid broadening it to every renderer `force`. -- UI quality bar: not UI-visible; UI should remain unchanged apart from fresher rows. -- Required review screenshots: - 1. Tasks GitHub issues list reachable after implementation. - 2. Create issue dialog reachable, if a throwaway repo is available. - 3. Post-create or post-refresh list state, if validation can avoid mutating live data. -- Residual risks: validating the actual create flow may be skipped unless a safe throwaway GitHub repo is available; cross-window freshness and external GitHub mutations remain bounded by existing refresh/TTL behavior. diff --git a/docs/remote-web-paste-activity-fixes.md b/docs/remote-web-paste-activity-fixes.md deleted file mode 100644 index dca8ee5bed6..00000000000 --- a/docs/remote-web-paste-activity-fixes.md +++ /dev/null @@ -1,108 +0,0 @@ -# Remote Web Paste And Activity Fixes - -## Problem - -- Paired browser image paste reads a clipboard image in `src/renderer/src/web/web-preload-api.ts:120`, converts it to PNG base64, and sends the whole string through `clipboard.saveImageAsTempFile` at `src/renderer/src/web/web-preload-api.ts:1613`. `src/main/runtime/rpc/ws-transport.ts` sets `maxPayload` to 1 MiB, and the browser RPC client encrypts JSON requests into base64 text frames. A screenshot well below the existing 24 MiB clipboard schema limit can still exceed the encrypted WebSocket frame cap and close the socket as `Remote Orca runtime connection interrupted`. -- Paired browser workspace activity is inflated. `src/renderer/src/lib/worktree-status.ts` and `src/renderer/src/lib/worktree-activity-state.ts` currently treat any mirrored web terminal surface ID as active, even when `src/renderer/src/runtime/web-session-tabs-sync.ts` only has a pending host terminal with no ready PTY handle. - -## Root Cause - -- The clipboard RPC accepts up to 24 MiB of base64 in `src/main/runtime/rpc/methods/clipboard.ts`, but the web transport limit applies before the server can validate RPC params. Because the web client encrypts JSON and base64-encodes the encrypted bytes, the safe plaintext chunk is materially smaller than 1 MiB. The existing 512 KiB file-upload chunk size in `src/renderer/src/runtime/runtime-file-client.ts` is safe after JSON plus encryption expansion; a chunk close to 1 MiB is not. -- The file-upload RPCs cannot be reused directly. They write worktree-relative files and then commit a rename; clipboard paste must call `saveClipboardImageBufferAsTempFile`, return a local or SSH temp path, and preserve the terminal repo's `connectionId`. -- Web session-tab sync intentionally mirrors pending host terminal surfaces so the tab model stays in parity, but it writes `ptyIdsByTabId[mirroredTabId]` only from `status: "ready"` surfaces. The status/filter helpers then bypass that liveness map with `isWebTerminalSurfaceTabId`, so pending mirrors look active. - -## Non-Goals - -- Do not raise the global WebSocket `maxPayload`; it protects all runtime traffic, including pre-auth and mobile sockets. -- Do not change sidebar grouping, labels, counts, pairing flow, terminal UI, or clipboard permission UI. -- Do not change local Electron clipboard behavior or the existing one-shot RPC contract. - -## Design - -1. Add bounded chunked clipboard-image RPCs. - - Keep `clipboard.saveImageAsTempFile` for small/old clients. - - Add `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and `clipboard.abortImageUpload`. - - `start` takes `expectedBase64Length` and `connectionId`, rejects values over the existing 24 MiB base64 limit, records the target connection, and returns an unguessable upload ID. - - `append` takes `uploadId`, `offset`, and `contentBase64`; reject unknown IDs, out-of-order offsets, chunks above 512 KiB, invalid base64 characters, and cumulative length beyond `expectedBase64Length`. - - `commit` verifies the received length equals `expectedBase64Length`, validates the full base64 payload with the same rules as the one-shot RPC, calls `saveClipboardImageBufferAsTempFile` with the recorded `connectionId`, and deletes the upload state in `finally`. - - `abort` deletes the upload state and is idempotent. - - Bound server memory with a small max concurrent upload count and a TTL cleanup for abandoned uploads. Upload state is process-local; reconnects restart the paste instead of resuming. - -2. Switch paired browser image paste to the chunked path. - - After `navigator.clipboard.read()` and PNG conversion, return `null` for no image as today. - - Preflight `contentBase64.length` against the 24 MiB limit before starting an upload. - - Send 512 KiB base64 slices. This is below the 1 MiB encrypted frame cap after JSON and E2EE base64 expansion. - - Abort best-effort on append or commit failure. If `start` returns `method_not_found`, fall back to `clipboard.saveImageAsTempFile` only when the payload is below a conservative single-frame threshold; never send a large fallback frame. - -3. Tighten workspace activity liveness. - - Treat terminal workspaces as active only when `tabHasLivePty(ptyIdsByTabId, tab.id)` is true. - - Remove the blanket `isWebTerminalSurfaceTabId` active shortcut from `getWorktreeStatus` and `hasActiveWorkspaceActivity`. - - Keep browser tabs active without terminals. - - Keep fresh/retained explicit agent rows able to promote status to `permission`, `working`, or `done`; this is separate from terminal liveness. - -## Data Flow - -- Paste image: - - Browser paste command -> `navigator.clipboard.read()` -> PNG base64 in web preload memory. - - `clipboard.startImageUpload({ expectedBase64Length, connectionId })` -> upload ID. - - Repeated `clipboard.appendImageUploadChunk({ uploadId, offset, contentBase64 })`. - - `clipboard.commitImageUpload({ uploadId })` -> runtime saves temp image locally or on the SSH target -> terminal receives the temp path. - -- Workspace activity: - - Host `session.tabs.listAll` or subscription snapshot includes terminal surfaces. - - Web sync mirrors tabs but writes live PTY handles only for ready surfaces. - - Sidebar status/filter reads `ptyIdsByTabId` and browser tabs. - - Pending mirrored terminals with no PTY remain visible but do not count as active. - -## Edge Cases - -- Clipboard has no image or browser lacks `navigator.clipboard.read`: return `null`, no upload session. -- Clipboard read/permission/conversion fails: existing terminal paste error path reports the failure; the runtime socket should stay open. -- Non-PNG clipboard images may grow during PNG conversion; validate the post-conversion base64 length. -- Image exceeds 24 MiB base64: reject before upload and enforce again on the server. -- Chunk boundaries must preserve base64 validity; the 512 KiB chunk size is divisible by 4, and the final full payload validation catches padding errors. -- Append retry or concurrent append with the same upload ID: offset validation rejects duplicate, skipped, or out-of-order data. -- Multiple paired browser clients or windows paste at once: upload IDs isolate sessions and the concurrent-upload cap bounds memory. -- Append or commit fails: browser aborts best-effort; server TTL cleans abandoned state. -- SSH connection missing or drops during commit: commit fails, upload state is still deleted, and the paste path reports the error. -- Runtime restarts during upload: pending RPC fails; no resume is attempted. -- Pending host terminal surface later becomes ready: the next snapshot writes PTY handles and the workspace becomes active. -- Host closes a terminal or browser tab: the next snapshot removes the mirrored tab and stale PTY/browser handles before activity is recomputed. - -## Test Plan - -- Unit: `src/main/runtime/rpc/methods/clipboard.test.ts` for start/append/commit/abort, offset validation, invalid base64, size limit, TTL cleanup, commit cleanup on save failure, SSH `connectionId` forwarding, and concurrent upload isolation. -- Unit: `src/renderer/src/web/web-preload-api.test.ts` for chunk sequencing, 512 KiB max chunks, `method_not_found` small-payload fallback, no large fallback frame, and abort on append/commit failure. -- Unit: `src/renderer/src/lib/worktree-status.test.ts` and `src/renderer/src/lib/worktree-activity-state.test.ts` for pending mirrored terminal inactivity, ready mirrored terminal activity, browser-only activity, and explicit agent-row promotion. -- Focused run: `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/clipboard.test.ts src/renderer/src/web/web-preload-api.test.ts src/renderer/src/lib/worktree-status.test.ts src/renderer/src/lib/worktree-activity-state.test.ts`. -- Type/lint: `pnpm typecheck`, `pnpm lint`. -- Paired-client validation: restart the host app, pair a real browser client, paste a screenshot large enough that the old one-shot RPC exceeded the 1 MiB encrypted frame cap into a local terminal and an SSH-backed terminal, confirm the runtime socket stays connected, then compare browser and host sidebar activity. - -## UI Quality Bar - -No layout changes. The paired browser sidebar should show active dots only for workspaces with live terminal PTYs, browser tabs, or explicit agent rows. Image paste should insert the generated temp image path into the terminal without connection-error toasts or DevTools runtime disconnect errors. - -## Review Screenshots - -1. Paired browser sidebar after hydration with inactive Done/Todo workspaces visible but not marked active. -2. Paired browser terminal after image paste, showing the generated temp image path inserted. -3. Host sidebar for the same session, showing matching active workspace state. - -## Rollout - -1. Add chunked clipboard RPC implementation and tests. -2. Switch web preload image paste to chunked upload and tests. -3. Tighten activity/status liveness helpers and tests. -4. Run focused tests, typecheck, and lint. -5. Validate in a paired Electron/browser session, including SSH paste, and capture the review screenshots. - -## Lightweight Eng Review - -- Scope: narrow to web clipboard transport and activity liveness. No global WebSocket limit, sidebar redesign, or local Electron clipboard changes. -- Architecture/data flow: web preload owns browser clipboard read and chunk sequencing; main runtime clipboard RPC owns upload session state and final temp-file save; sidebar helpers stay pure and consume the existing live-PTY map. -- Failure modes: oversized images must fail before a large frame is sent; abandoned uploads expire; failed append/commit paths clean up; pending host terminal mirrors do not inflate activity; SSH commit failures do not leak upload state. -- Tests: cover RPC lifecycle and bounds, web preload sequencing/fallback/abort, status/filter liveness, and real paired-client paste/sidebar parity. -- Performance/blast radius: chunking is not free. A max-size image is dozens of serialized runtime RPC calls and duplicates base64 in browser and main memory. The impact is limited to image paste by TTL, size, and concurrency caps. Activity changes affect sidebar filters, jump palette activity, and status dots. -- UI quality: no new chrome. Judge only sidebar parity, paste result, and absence of runtime disconnect/toast regressions. -- Screenshots: browser sidebar, browser terminal after paste, and matching host sidebar. -- Residual risk: clipboard permission and image conversion behavior are browser-dependent, so paired browser validation is required in addition to unit tests. diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md deleted file mode 100644 index dbf3ee71dd5..00000000000 --- a/docs/renderer-memory-profile-2026-06-01.md +++ /dev/null @@ -1,171 +0,0 @@ -# Renderer Memory Profile, 2026-06-01 - -## Scope - -This profile investigated high Orca renderer memory while working in -`/Users/nwparker/orca/workspaces/orca/goal`. The user suspected the browser -might not actually have an open tab, so the investigation checked browser, -renderer, terminal, and Resource Usage attribution paths separately. - -## Live Evidence - -- Orca runtime was reachable through the packaged CLI fallback. The public - `/usr/local/bin/orca` shim pointed at a removed development app path, so it - failed before contacting the runtime. -- `orca tab list --worktree all --json` returned `tabs: []`. There were no live - Orca browser tabs in the measured session. -- The live packaged app had one renderer process and no separate browser guest - renderer process. A later `ps` sample showed: - - main process: 390 MB RSS - - renderer process: 460 MB RSS, about 40 percent CPU - - GPU process: 145 MB RSS - - network service: 59 MB RSS - - audio service: 48 MB RSS -- `sample` on the renderer showed V8, IPC, and deserialization stacks while the - renderer was busy. It did not show browser guest activity. -- `vmmap -summary` on the renderer showed about 218 MB physical footprint and a - 373 MB peak, while total resident accounting was about 1.7 GB. Most of that - larger number was shared Electron/Chromium mappings, especially read-only - library mappings. -- `orca terminal list --worktree active --json` showed the active Codex terminal - preview retaining repeated status redraw fragments such as repeated - `Working` text. The retained terminal tail buffers were bounded, but the - text normalization path was treating redraw controls as append-only text. - -## Findings - -1. Browser tabs were not the live-session memory source. The session had no - browser tabs and no browser guest renderer process. -2. The browser-pane retention fix is still useful: inactive worktree browser - webviews are now unmounted so Chromium can release guest renderers. Browser - state remains in Orca, and automation-visible webviews stay mounted so - agent-browser can keep driving them. -3. Resource Usage was using `app.getAppMetrics().memory.workingSetSize` for - Orca app buckets. On macOS this can count large shared Electron/Chromium - mappings and make the renderer look much larger than its private footprint. -4. The active terminal path was producing noisy previews from TUI redraws. This - explains the high active renderer churn observed during the profile, even - though the terminal memory buffers were already capped. - -## Changes Made - -- Browser panes now mount their backing webview only when the pane is active or - automation-visible. This sleeps inactive worktree browser guest renderers - without sleeping the main Orca renderer. -- Browser crash breadcrumbs now include webview counts, parked webview counts, - hidden webviews, and registered browser guest counts. -- The memory collector now prefers the existing host process RSS sweep for - Electron app bucket memory, falling back to Electron working-set data only - when a host row is missing. -- Terminal preview retention now applies carriage-return and backspace redraw - controls before appending text to the retained preview tail. - -## Validation - -- Browser overlay, webview registry, and crash diagnostics tests passed. -- Browser tab e2e tests passed. -- Memory collector tests passed, including host RSS preference and fallback - coverage. -- Runtime terminal tests passed for carriage-return and backspace redraw - normalization, plus the existing bounded partial-tail coverage. - -## Remaining Risk - -The current packaged Orca app was not running this worktree's patched code -during the live profile. The fixes are covered by unit and e2e tests, but the -next packaged build should be re-profiled under the same active Codex TUI load -to confirm the Resource Usage display and terminal previews match the expected -lower-churn behavior. - -## Follow-up: CLI Profiling Blocker - -Continuing the profile after this change confirmed the public -`/usr/local/bin/orca` command was still broken because it was a regular -generated launcher file pointing at a removed development build. The CLI -installer previously self-healed stale symlinks, but treated regular files as -conflicts. That meant Settings could not replace an Orca-owned stale launcher, -forcing profiling to use the packaged CLI fallback. - -The follow-up fix teaches the installer to recognize only generated Orca Unix -launcher files as stale and replaceable. Arbitrary regular files at the command -path remain conflicts. - -## Follow-up: Repeatable Memory Diagnostics - -The next profiling blocker was repeatability: collecting a useful memory sample -still required combining Resource Usage IPC, terminal lists, browser tab state, -and host process output by hand. This branch adds `orca diagnostics memory`, -which exposes the existing main-process memory collector through runtime RPC. - -The command returns the same `MemorySnapshot` shape used by Resource Usage when -run with `--json`, including host memory, Orca app process buckets, worktree -terminal memory, per-session process roots, and history samples. Text output -prints a compact point-in-time summary and the top worktrees by retained -terminal memory. - -## Follow-up: Agent-Browser Paintability Guard - -The browser parking fix depends on automation-visible panes staying paintable -without activating the user's worktree. The renderer bridge previously waited -for two animation frames before creating the automation visibility lease, so the -paint wait happened while the parked webview was still hidden. Non-screenshot -agent-browser commands could therefore start immediately after the lease was -created, before React had made the hidden pane paintable. - -The follow-up changes the order: create the automation visibility lease first, -then wait for paint while the pane is actually visible to automation. A -renderer-side timeout releases the lease if paint never arrives, so a hung RAF -does not pin an inactive browser pane indefinitely. - -## Follow-up: Browser Registration Readiness - -One remaining automation race was the wake path for parked or restored browser -tabs. Runtime browser commands asked the renderer to mount a hidden browser -pane, then waited a fixed 500 ms before reading the agent-browser tab registry. -On slow webview startup, that could still race `registerGuest` and make -agent-browser report no tab even though the tab was in the process of mounting. - -The follow-up extends the existing tab-registration wait from page-specific -creation to worktree/global wake flows. Runtime commands now wait for the -renderer's actual `browser:registerGuest` IPC before routing automation, with -the same timeout fallback used by tab creation. - -## Follow-up: Worktree Activation No-Op Fanout - -The next renderer-store check found that repeated activation of an already -active, already-reconciled worktree could still publish a new Zustand root state -because `setActiveWorktree` rebuilt `activeTabTypeByWorktree` even when its -stored value was unchanged. That woke every store subscriber, including session -persistence and runtime graph sync, for a visible no-op. - -The follow-up preserves the existing state reference when all derived active -fields, unread state, and first-activation bookkeeping are unchanged. A -regression test subscribes to the store and asserts that reselecting the -already-active reconciled worktree does not notify subscribers. - -## Follow-up: Activation Helper Visit Writes - -After the store-level no-op fix, the higher-level `activateAndRevealWorktree` -helper could still restamp focus-recency and append navigation history for a -plain reselect of the already-active worktree in terminal view. That path did -not change the visible workspace, but the recency stamp is part of the persisted -session payload and can still wake the session writer. - -The follow-up skips only that true no-op visit write. Activations that switch -repo, leave another app view, or carry startup/setup/default-tab work still -record the visit, and the sidebar reveal still runs for the no-op case. - -## Follow-up: ANSI Terminal Redraw Controls - -The installed app used for the continuation profile still predates the merged -memory diagnostics and terminal-preview fixes, but its live active terminal -preview continued to show redraw noise and there were still no browser tabs. -That kept the remaining source-backed target on retained terminal previews. - -The previous preview fix handled carriage-return and backspace redraws, but -not ANSI CSI erase/cursor sequences commonly emitted by spinner-style TUIs. -The follow-up extends the retained-preview line model to strip formatting/OSC -metadata and apply line erase plus horizontal cursor movement before retaining -the text tail. The regression test covers cursor-left overwrite, erase-line -without a carriage return, SGR/private cursor controls, OSC title metadata, and -the existing terminal read cursor metadata. diff --git a/docs/show-full-tab-title-tooltip.md b/docs/show-full-tab-title-tooltip.md deleted file mode 100644 index 96ea7a0d39f..00000000000 --- a/docs/show-full-tab-title-tooltip.md +++ /dev/null @@ -1,131 +0,0 @@ -# Show Full Tab Title Tooltip - -## Problem - -Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus. - -- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip. -- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip. -- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip. -- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`. -- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one. - -## Root Cause - -The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it. - -## Non-Goals - -- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics. -- Do not add persistence, IPC, or main-process behavior. -- Do not replace the tab bar layout or introduce a new tooltip primitive. -- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful. - -## Design - -1. Add full-title tooltips to non-editing tabs in the existing tab components. - - Terminal: display `tab.customTitle ?? tab.title`. - - Browser: display `getBrowserTabLabel(tab)`. - - Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label. -2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`. - - Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide. - - In non-rename states, wrap the sortable tab root with `...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip. - - Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu. - - Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab. - - Render `full label`. - - `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed. - - Keep tooltip content non-interactive; it is only a label. -3. Keep edit states out of the tooltip path. - - Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip. - - Double-click handlers for rename/pin must keep their current event behavior. -4. Preserve drag, activation, and close controls. - - `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths. - - Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking. - - The outer tab remains the sortable/activation target. -5. Add focused regression coverage for the render contract. - - Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label. - - Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes. - -## Data Flow - -- Store/runtime tab state updates title fields. -- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`. -- Each tab component computes the displayed label. -- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path. -- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay. -- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path. - -## Edge Cases - -- Terminal custom title overrides the runtime title in both visible label and tooltip. -- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props. -- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label. -- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab. -- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges. -- Rename modes must not show stale title tooltips over the input. -- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior. -- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged. -- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes. -- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs. -- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use. -- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted. - -## Test Plan - -- Unit: add or update tab-bar component tests that mock tooltip primitives and assert: - - terminal tab label tooltip content uses custom title when present; - - browser tab label tooltip content uses `getBrowserTabLabel`; - - editor tab label tooltip content uses `getEditorDisplayLabel`; - - the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability; - - rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state. -- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works. -- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label. -- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed. -- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- ` if focused files are added. - -## UI Quality Bar - -- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily. -- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography. -- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport. -- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift. -- Light and dark theme surfaces remain legible. - -## Review Screenshots - -1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip. -2. Browser tab with a long page title or URL hovered, showing the full title tooltip. -3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged. -4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring. - -## Rollout - -1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`. -2. Add focused tests for tooltip label wiring. -3. Run relevant tests, typecheck, and lint. -4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls. - -## Lightweight Eng Review - -- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue. -- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior. -- Failure modes covered: - - Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes. - - Rename mode could show stale tooltip content over an input if the tooltip is not gated out. - - Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it. - - Long tooltip text could overflow the viewport if content width is unbounded. - - Browser fallback labels could drift if tooltip recomputes a different label than the visible span. - - Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row. - - Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy. -- Test coverage required: - - Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components. - - Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability. - - Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels. -- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement. -- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark. -- Required review screenshots: - 1. Long terminal tab tooltip. - 2. Long browser tab tooltip. - 3. Long editor tab tooltip with existing adornments intact. - 4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state. -- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display. diff --git a/docs/source-control-push-failure-ai-recovery.md b/docs/source-control-push-failure-ai-recovery.md deleted file mode 100644 index 136b7e0031a..00000000000 --- a/docs/source-control-push-failure-ai-recovery.md +++ /dev/null @@ -1,368 +0,0 @@ -# Source Control Push Failure AI Recovery - -## Problem - -Push, force-push, publish, and sync push-stage failures flow through the shared -remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94` -and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716` -and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or -lint hook is not an auth, protected-branch, or transport problem, so generic -remote guidance sends the user in the wrong direction. - -PR #7787 proves the behavior is useful, but the prototype needs tightening before -it is maintainable: - -- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and - `src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat - push-failure predicates and feed live branch/file state into a prompt that says - "at failure time". -- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63` - imports push detection again instead of consuming one derived recovery model. -- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24` - and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24` - are copied launch flows with action-specific copy. -- `src/shared/source-control-commit-failure-agent-command.ts:6` and - `src/shared/source-control-push-failure-agent-command.ts:6` are identical - command-template builders. -- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21` - already contains a reusable split button, while `SourceControl.tsx:6415` - carries a second inline version. -- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats - any `isSync` error as push-like for hook detection. `syncBranch` calls the - formatter for fetch, upstream-status, pull, and push failures, so the renderer - cannot prove a sync error came from the push stage without an explicit marker. -- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2` - imports prompt helpers through the large React module instead of shared prompt - modules. - -## Goal - -Add first-class recovery for pre-push hook failures without growing a parallel -push-only subsystem: - -1. Toasts for push, force-push, publish, and sync push-stage failures say - "blocked" for detected pre-push or lint-hook output. -2. Source Control shows a concise Push blocked panel with Details and AI Fix - only for a failure snapshot that is both push-like and hook-like. -3. AI Fix launches the configured `fixPushFailure` Source Control action with a - safe, provider-neutral prompt. -4. Commit-failure recovery and push-failure recovery share launch, - command-template, split-button, and dense error-panel primitives. - -## Non-goals - -- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side - protected-branch failures from this entry point. -- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a - PR, or assume GitHub-specific terminology. -- Do not add app-wide persistence for this transient failure panel. -- Do not redesign the Source Control panel or the Source Control AI settings - model. -- Do not fold unrelated launch surfaces such as checks recovery into this change - unless a tiny shared helper is already needed for commit/push recovery. - -## Design - -1. Keep push-hook detection and prompt building in shared code. - - `src/shared/source-control-push-failure.ts` owns normalization, bounded - scanning, summary text, Details eligibility, prompt truncation, prompt - rules, and changed-file list bounding. - - Detection must stay conservative: explicit `pre-push`/`prepush`, - `hook declined to push`, hook-runner output in push context, or lint output - in push context. Auth, non-fast-forward, protected branch, pre-receive, - submodule, and generic transport failures stay on the existing remote-error - path. - - Keep the output scan bounded at 64 KiB and the prompt failure-output - section bounded. Also cap prompt file lines to a small constant with an - omitted-count line; do not rely on the git-status cap, which is far too high - for prompt context. - -2. Make remote-error formatting sync-stage aware. - - Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for - sync, for example `isSyncPushStage`. - - Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or - `isSyncPushStage`; do not use bare `isSync` as evidence of a push failure. - - In `syncBranch`, pass the push-stage flag only inside the two inner - `pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull - catches still pass `isSync` for Sync-shaped generic copy but must not render - blocked hook copy. - - Carry the same push-stage fact back to the renderer, either by tagging the - thrown `Error` with a narrow exported marker or by wrapping it in a typed - error that preserves the original message/cause. `SourceControl` must set - `syncPushStage` only from that marker, not from `kind === 'sync'` plus - hook-like stderr. - - Keep submodule push messages before hook detection so submodule guidance - remains more specific than AI recovery. - -3. Replace one-off command builders with one shared recovery command builder. - - Add a shared builder that accepts `{ actionId, promptOverride, - commandInputTemplate, basePrompt }` and renders the existing Source Control - AI command template for launch actions. - - Keep compatibility exports for commit and push helpers if nearby tests or - call sites still use those names, but make them wrappers over the generic - builder. - - Move prompt-builder tests to import from shared modules, not - `SourceControl.tsx`. - -4. Capture and derive push recovery state once. - - Extend `SourceControlActionError` with the raw error, a push-stage marker - for sync failures, the branch name at failure time, a bounded status-entry - snapshot at failure time, and a per-worktree sequence token. - - `runRemoteAction` clears the worktree's previous error and increments its - sequence before starting. A catch may write an error only if its sequence - still owns that worktree, so a slow failure cannot overwrite a newer retry - or success. - - Add a focused renderer helper that accepts the captured - `SourceControlActionError` and the current branch name, then returns either - `null` or a model with raw/sanitized detail text, summary, details flag, - kind label, and AI prompt. - - The helper returns `null` unless the operation is `push`, `force_push`, - `publish`, or `sync` with the sync push-stage marker and - `isPushHookFailure(rawError)` passes. - - If the current branch is known and differs from the captured branch, hide or - clear the model instead of launching an agent with wrong branch context. - - Pass that single model to `useSourceControlAi` and `CommitArea`; neither - prop construction nor `CommitArea` should repeat the push-hook predicate. - -5. Use one generic recovery launcher for commit and push failures. - - Replace the separate commit and push launch files with - `source-control-ai-recovery-launch.ts`, or keep the old files as one-line - wrappers if import stability is cheaper. - - The generic launcher owns connection resolution, SSH/local agent discovery, - saved-agent validation, agent-args validation, agent selection, terminal - launch, focus, and success/failure toasts. - - Resolve connection without collapsing local and unresolved states: read - `const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it - is a string or `null`, and only fall back to `sourceRepoConnectionId` when - the worktree lookup returns `undefined`. If the fallback is also - `undefined`, show the workspace-connection error instead of launching - locally. `null` means proven local; a string means SSH/remote. - - Validate saved CLI args before agent detection or terminal creation. On - Windows, keep using PowerShell-safe planning through - `planAgentCliArgsSuffix(..., 'powershell')`. - - Action-specific data is limited to action id, base prompt, empty-prompt - copy, unavailable-agent copy, and success copy. - -6. Keep the recovery hook thin. - - `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the - already-derived push recovery prompt/model, keeps independent loading - flags, and calls the generic launcher. - - It should not import push-hook detection, resolve agents, or duplicate - launch plumbing. - -7. Extract and reuse the recovery UI. - - Reuse or replace the existing - `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx` - rather than adding another split-button component. - - Move the dense recovery notice and Details dialog into focused renderer - modules named for source-control recovery. Avoid `helpers`, `utils`, and new - max-lines suppressions. - - Reuse the same notice/dialog component for commit and push recovery with - action-specific labels, summary, details, prompt, saved recipe, and launch - callback. - - Keep normal remote errors filtered out when a push recovery model is - rendered, so the user does not see both Push blocked and generic remote - error copy for the same failure. - -8. Retain `fixPushFailure` in the existing Source Control AI action model. - - The prototype already adds `fixPushFailure` to - `src/shared/source-control-ai-actions.ts`; keep it as a launch action like - `fixCommitFailure`. - - Verify default settings, normalizers, global settings rows, repository - override rows, labels, descriptions, and variable chips all include Push - failure fixes. - - Do not create a push-specific settings store or migration. Existing - `normalizeSourceControlAiSettings` hydrates missing action defaults. - -## Data Flow - -- User clicks Push, Force Push, Publish Branch, or Sync. -- `runRemoteAction` clears that worktree's previous remote error, records a new - sequence token, and starts the editor-store remote action. -- Git push fails and throws. -- `pushBranch` or sync's inner push-stage catch calls - `resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull, - and upstream-status failures do not pass the push-stage flag. -- Sync's inner push-stage catch marks the rethrown error as push-stage so - `runRemoteAction` can preserve `syncPushStage` in the captured snapshot. -- The formatter classifies hook output before auth/transport fallbacks only for - push-like options and shows blocked toast copy. -- `SourceControl` catches the same error, and if the sequence still owns the - worktree, stores `{ kind, rawError, message, syncPushStage, branchName, - entriesSnapshot }`. -- The push recovery helper derives one `pushRecovery` model from that snapshot. -- `CommitArea` renders normal remote errors or the Push blocked recovery notice. -- AI Fix builds the `fixPushFailure` command input and launches the selected - agent in the owning local or SSH runtime. - -## Edge Cases - -- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and - transport failures must not show the Push blocked panel. -- Submodule push errors keep their existing specialized messages before - push-hook detection. -- Sync failures from fetch, upstream-status, or pull stages must not show Push - blocked, even if stderr contains words like "lint" or "hook". -- Create PR intent remote failures use the same `runRemoteAction` plumbing; if - CommitArea is not visible, blocked toast copy is still required but no hidden - panel work is needed. -- A newer remote operation for the same worktree must clear stale recovery state - immediately and prevent an older in-flight failure from writing after it. -- Switching worktrees or branches must not show another worktree or branch's - push failure. -- External edits or another Orca window may make the failure stale. Do not add - cross-window persistence for this feature; rely on local retry/remount/branch - mismatch clearing and list this as residual risk. -- Source Control AI hidden for a repo still shows the blocked notice and Details; - it hides AI Fix. -- SSH worktrees must detect and launch agents on the owning connection, not a - local fallback. -- Windows launch planning must continue to use PowerShell-safe agent args. -- Very large hook output and very large changed-file sets must be bounded before - prompt generation. -- ANSI/control output must not leak into summaries or Details comparison logic. - Details may show sanitized text while preserving enough line breaks for - debugging. -- Empty custom command templates must stay empty so the launcher rejects them - with a clear settings error. -- Prompt text must treat file paths, branch names, and hook output as data, not - instructions. - -## Test Plan - -- Shared unit tests: - - `src/shared/source-control-push-failure.test.ts` covers detection positives, - auth/protected/pre-receive/non-fast-forward negatives, ANSI/control - stripping, bounded scanning, details comparison, prompt output truncation, - prompt file-list capping, and provider-neutral prompt rules. - - Shared command-template tests cover the generic recovery builder plus - commit/push wrappers, including empty templates and prompt overrides. - - `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure` - label, default template, variables, normalization, and action-list inclusion. -- Remote formatter and store tests: - - `src/renderer/src/lib/source-control-remote-error.test.ts` covers push, - force-push, publish, and sync push-stage blocked copy plus sync non-push - stage negatives, auth/non-fast-forward/protected/pre-receive/submodule - regressions. - - `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast - path and both sync push-stage and sync non-push-stage toasts. -- Renderer model and UI tests: - - A focused push recovery derivation test covers operation kind, sync - push-stage marker, branch mismatch, stale sequence ownership, snapshot file - entries, summary, details flag, prompt contents, and ordinary remote errors. - - `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix - visible/hidden, ordinary remote errors, no duplicate remote error when a push - model exists, and unchanged commit-failure recovery rendering. - - Settings tests cover global and repository Source Control AI action rows - showing Push failure fixes. -- Launcher tests: - - Generic recovery launcher tests cover commit and push action ids, invalid CLI - args before detection, empty template rejection, unavailable saved agent, - local versus SSH agent detection, successful launch/focus, and success copy. -- Checks: - - Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and - `pnpm check:max-lines-ratchet`. - -## UI Quality Bar - -The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome, -quiet, dense, token-based, and consistent with adjacent commit-failure recovery. -Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide -icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action -recipe row patterns. The Push blocked notice should keep the summary readable at -narrow right-sidebar widths, avoid nested cards, avoid new color values, show -stable split-button geometry while launching, and keep Details as progressive -disclosure for long hook output. Dialogs and dropdowns must remain usable in -light/dark mode and under SSH latency. - -## Review Screenshots - -1. Source Control after a simulated pre-push hook failure: Push blocked notice - with summary, AI Fix, and Details. -2. Push failure Details dialog showing hook output and Fix with AI. -3. AI Fix customize-launch dialog for `fixPushFailure`. -4. Ordinary push auth/protected/remote failure state: regular remote error, no - Push blocked notice. -5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push - blocked notice. -6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI - Fix. -7. Source Control AI settings showing the Push failure fixes launch recipe. -8. Adjacent commit-failure recovery notice still rendering correctly after the - shared UI extraction. - -## Rollout - -1. Tighten shared push-failure classifier/prompt bounds and add the generic - recovery command builder. -2. Make remote formatter/store sync-stage aware and update toast tests. -3. Add the `SourceControlActionError` snapshot/sequence data and one push - recovery derivation helper. -4. Replace duplicated commit/push launchers with the generic recovery launcher - and thin hook wiring. -5. Extract/reuse source-control recovery UI components, including the existing - split-button module. -6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels. -7. Update targeted tests and run typecheck, lint, and max-lines ratchet. -8. Validate rendered Electron states with screenshots before opening the PR. - -## Lightweight Eng Review - -- Scope: Reduced from PR #7787's push-specific parallel path to a shared - recovery layer. Keep the feature limited to local pre-push/lint-hook detection, - blocked copy, an inline recovery notice, Details, and AI launch. No persistence - or provider-specific recovery. -- Architecture/data flow: Shared code owns classification, prompt text, and - command-template rendering; the editor store owns stage-aware toast formatting; - `SourceControl` owns transient worktree-scoped failure snapshots and sequence - invalidation; the generic launcher owns local/SSH agent discovery and terminal - launch. -- Failure modes covered: - - Sync stage ambiguity: only the inner push stage may classify as hook-blocked. - - Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay - out of push recovery. - - Branch or worktree switches suppress stale recovery models. - - Per-worktree sequence tokens prevent older in-flight failures from - overwriting newer retries or successes. - - Empty custom templates, invalid CLI args, unavailable saved agents, missing - worktree context, SSH host detection, and Windows shell planning fail before - terminal launch. - - Large or hostile hook output and file lists are bounded and treated as data - in prompts. -- Test coverage required: - - Shared classifier/prompt/command/action-model tests in `src/shared`. - - Remote formatter and editor-store tests for push, force-push, publish, sync - push-stage, and sync non-push-stage paths. - - Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix, - hidden AI actions, stale branch/sequence, ordinary remote errors, and - existing commit failure recovery. - - Generic launcher tests for local/SSH, invalid args, saved-agent unavailable, - empty template, and success launch. - - Settings tests for global and repo Push failure fixes rows. - - Electron validation for all required screenshots; no separate E2E is - required if renderer tests cover the model and Electron validates the real UI. -- Performance/blast radius: No polling, watchers, migrations, or startup work. - Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt - output, and a bounded file snapshot only when a remote action fails. Largest - blast radius is the shared recovery UI and launcher used by existing commit - failures, so commit recovery needs tests and a screenshot. -- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density: - token-based error state, stable split-button geometry, progressive Details - disclosure, no nested cards, concise copy, light/dark compatibility, and - visible disabled/loading state under SSH latency. -- Required review screenshots: - 1. Push blocked notice after pre-push hook failure. - 2. Push failure Details dialog with Fix with AI. - 3. `fixPushFailure` customize-launch dialog. - 4. Ordinary push auth/protected/remote failure with no Push blocked panel. - 5. Sync non-push-stage failure with no Push blocked panel. - 6. Source Control AI hidden: Push blocked without AI Fix. - 7. Source Control AI settings showing Push failure fixes. - 8. Existing commit-failure recovery notice after shared UI extraction. -- Residual risks: Classifier false positives remain possible for unusual local - hook-runner output that mentions push context; keep tests biased toward - preserving auth, protected-branch, and non-fast-forward behavior. Failure - panels are renderer-local, so another Orca window or an external terminal can - fix the problem while the old panel remains visible until local retry, remount, - or branch/worktree change. Electron validation may need a mocked or temporary - repo scenario to trigger a real pre-push hook without mutating user data. diff --git a/docs/ssh-config-target-compatibility.md b/docs/ssh-config-target-compatibility.md deleted file mode 100644 index 07817dc41f7..00000000000 --- a/docs/ssh-config-target-compatibility.md +++ /dev/null @@ -1,61 +0,0 @@ -# SSH Config Target Compatibility - -## Problem - -- `src/main/ssh/ssh-connection-utils.ts:108` resolves OpenSSH config with `ssh -G`, but then prefers the persisted `target.host` over `resolved.hostname`. -- `src/main/ssh/ssh-connection-utils.ts:109` treats persisted port `22` as an explicit override, so a config-host target can ignore a resolved non-default `Port`. -- `src/main/ssh/ssh-config-parser.ts:210` imports config aliases with `host` set to the alias when the concrete `Host` block lacks an inline `HostName`; later `ssh -G` may know the real host, but the connection path ignores it. -- `src/renderer/src/components/settings/SshPane.tsx:63` requires both host and username, even though OpenSSH config aliases can resolve the user and users commonly paste `user@host:port` targets. -- `src/main/ssh/ssh-config-parser.ts:330` parses `ForwardAgent`, but `src/main/ssh/ssh-connection-utils.ts:112` did not pass it into ssh2, so remote git commands that rely on the local agent could fail. - -## Goal - -Make Orca behave like a mature SSH client for common config-host flows: aliases imported from `~/.ssh/config`, aliases with inherited `HostName`/`Port`, and pasted SSH targets should connect without users re-entering information that OpenSSH can resolve. - -## Non-goals - -- Do not add persistent secret storage for SSH passwords or key passphrases. -- Do not redesign the whole SSH settings page. -- Do not change relay deployment or remote PTY lease semantics. -- Do not add a known-host trust UI in this patch. - -## Design - -1. Preserve explicit target overrides while letting config aliases use resolved values. - - In `buildConnectConfig`, prefer `resolved.hostname` only when the persisted host is blank, the same as `configHost`, or the same as the label. - - Prefer `resolved.port` when the target is a config-host target still on the default `22`; keep non-default target ports as explicit overrides. - - Continue using target username first, then resolved user. - -2. Honor resolved agent forwarding where ssh2 can support it. - - Set `agentForward` only when resolved config requested forwarding and an agent is actually configured. - - Leave system-SSH transport unchanged because it already delegates to OpenSSH config. - -3. Normalize settings form drafts before save. - - Accept `ssh://user@host:port`, `user@host:port`, and plain aliases in the Host field. - - Auto-fill username and port from pasted inputs only when the dedicated fields are still empty/default. - - Allow username to be omitted; `ssh -G` can provide it during connect. - -4. Keep UI changes small. - - Rename copy only where needed to avoid implying username is mandatory. - - Render username-less targets without a leading `@`. - - Do not introduce new colors, typography, or layout patterns. - -5. Cover behavior with focused tests. - - Add connection-config tests for config-host resolved hostname/port precedence. - - Add connection-config tests for `ForwardAgent yes`. - - Add renderer utility tests for pasted SSH target normalization. - -## Edge Cases - -- Explicit non-default port in Orca still wins over `ssh -G`. -- Empty or unparsable host input remains invalid. -- IPv6 bracket syntax is accepted for `ssh://` URLs and preserved conservatively for scp-like inputs. -- Plain config aliases remain valid even without a username. - -## Rollout - -1. Add the renderer draft-normalization helper and tests. -2. Wire the SSH settings form save path and labels to the helper. -3. Update `buildConnectConfig` precedence/agent-forwarding and tests. -4. Clean up username-less target display. -5. Run focused tests and typecheck/lint where feasible. diff --git a/docs/ssh-handler-reregistration-port-forwards.md b/docs/ssh-handler-reregistration-port-forwards.md deleted file mode 100644 index f45e0120151..00000000000 --- a/docs/ssh-handler-reregistration-port-forwards.md +++ /dev/null @@ -1,243 +0,0 @@ -# SSH Handler Re-registration Port Forwards - -## Problem - -Issue #2932 reported that macOS window reactivation can re-run -`attachMainWindowServices`, which calls `registerSshHandlers` again -([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)). - -Before this change, `registerSshHandlers` removed and re-added IPC handlers but -also replaced the module-level `connectionManager` and `portForwardManager` -([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438), -[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)). -`activeSessions` remains module-global -([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)), -so live relay sessions kept references to the old port-forward manager while -new IPC handlers read a fresh empty one. - -The visible failure is: - -1. Connect an SSH target. -2. Add a local port forward. -3. Close all windows on macOS while the app process remains alive. -4. Reactivate Orca, causing SSH handlers to register again. -5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot - remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can - fail because the fresh connection manager has no live connection, and - `ssh:disconnect` does not close the old SSH connection or local listener. - Re-adding the same local port fails because the old server remains bound. - -## Root Cause - -SSH handler registration mixes two lifetimes: - -- Process-lifetime session state: active SSH connections, relay sessions, port - listeners, relay lost backoff, reset/connect in-flight maps. -- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers. - -The previous re-registration path preserved `activeSessions` but replaced the -managers that sessions and IPC handlers must share. Port-forward IPC operations -use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992), -[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047), -[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)), -and disconnect/terminate cleanup also uses that variable -([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750), -[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)). -After replacement, those operations no longer targeted the manager that owns the -live local servers. Replacing `connectionManager` also strands the live -`SshConnection` objects: existing relay sessions still hold their current -connection, but new IPC handlers and `getSshConnectionManager()` see an empty -manager. - -## Non-goals - -- Do not change relay protocol, remote deployment, or SSH transport behavior. -- Do not redesign port-forward persistence or enrichment. -- Do not change renderer UI. -- Do not introduce a second SSH service layer. -- Do not force-dispose live SSH sessions merely because a window was recreated. - -## Design - -1. Preserve process-lifetime managers across handler re-registration. - Instantiate `SshConnectionManager` and `SshPortForwardManager` only when - absent; later `registerSshHandlers` calls reuse the existing instances. - -2. Refresh every live callback owner on re-registration. This is required; a - plain `connectionManager ??= new SshConnectionManager(callbacks)` is not - enough. - - `SshConnectionManager` must update callbacks used by both future and - existing `SshConnection` objects, either via explicit `setCallbacks` methods - on manager/connection or via a stable callback proxy whose implementation - is mutable. - - Existing `SshRelaySession` objects must refresh `getMainWindow`, store, - runtime, and detected-port callback references. Event handlers must call - the current callback at event time; do not capture the old `getMainWindow` - in long-lived provider callbacks. - - The credential-request tracking set must not be per-registration if live - connections can switch callbacks during an in-flight `ssh:connect`. - -3. Re-register IPC handlers and dependent global listeners on every call. - `ipcMain` handlers, advertised URL refresh, credential IPC, browse handler, - and power-monitor listeners are window-registration concerns and should still - point at the latest window. - -4. Preserve existing explicit teardown behavior. - `ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and - double-connect cleanup must still remove forwards through the shared manager - before detaching or disposing sessions. - -5. Add regression tests in `src/main/ipc/ssh.test.ts`. - Connect a target, add a mocked port forward, call `registerSshHandlers` - again, then assert: - - `ssh:listPortForwards` still returns the original forward. - - `ssh:removePortForward` can remove the original id. - - `ssh:addPortForward`/`ssh:updatePortForward` still use the original live - connection. - - A second re-registration followed by `ssh:disconnect` still calls - `removeAllForwards` and `disconnect` on the original shared managers. - - State, credential, PTY, and detected-port callbacks from an existing live - session publish to the newest window after re-registration. - -## Data Flow - -- First registration: - - `registerSshHandlers(store, getWindowA)` creates store wrapper, connection - manager, port-forward manager, handlers, listeners, and current callback - environment. - - `ssh:connect` creates a relay session with the shared port-forward manager. - - `ssh:addPortForward` stores a local server in that same manager. - -- Window reactivation: - - `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers. - - Existing managers are reused. - - Existing connection and relay-session callback owners are refreshed to the - latest store/runtime/window environment. - - New handlers close over `getWindowB` and call the same managers. - -- Cleanup: - - `ssh:removePortForward` and `ssh:disconnect` operate on the same manager - that owns the live forward, then broadcast through the latest window. - -## Edge Cases - -- Re-registration while a target is connected and has active port forwards. -- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect, - or disconnect is in flight. The operation must not split credential tracking - or create a session that holds stale callbacks. -- Re-registration while no targets are connected. -- Re-registration after the store object changes. Either update existing relay - sessions to use the new store/runtime or document and test the stronger - invariant that production re-registration always passes the same process - store/runtime. -- Re-registration after the window changes. All broadcasts, credential prompts, - PTY events, detected-port events, advertised URL refreshes, relay-loss state - changes, and terminal relay errors must use the newest `getMainWindow`. -- Disconnect after re-registration must release old local ports. -- `ssh:connect` after window reactivation must be idempotent when the existing - session is already ready and healthy: return the connected state without - tearing down forwards. Explicit reset/reconnect or non-ready replacement paths - must still await old port teardown before restoring forwards. -- `getSshConnectionManager()` consumers must continue to see live connections - after re-registration. -- Test isolation must not depend on module-singleton state leaking between - tests. Add explicit reset/teardown support if preserving managers makes - `beforeEach(registerSshHandlers)` insufficient. -- SSH and relay paths must keep working for remote targets; the fix must not - assume local filesystem or local-only execution. -- Windows/Linux remain unaffected: re-registration can still happen during - development or future window lifecycles, and the fix must avoid path or - platform assumptions. - -## Test Plan - -- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts` - - Add regression coverage for list/remove/disconnect after handler - re-registration. - - Add coverage that add/update after re-registration uses the still-live - connection manager connection, not a fresh empty manager. - - Add coverage that existing connection/session callbacks publish to a second - mock window after re-registration. - - Add an in-flight connect or credential-request test if callback refresh uses - mutable callback objects. - - Existing connect, disconnect, reset, relay-loss, and terminate tests cover - adjacent lifecycle behavior. -- Typecheck: `pnpm typecheck`. -- Lint: `pnpm lint`. -- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if - available in the running app, add a disposable local port forward, trigger - window/service re-registration by closing and reopening the main window on - macOS, then verify the forward remains listed and removable. IPC/unit tests - are supporting evidence only; if the golden path cannot be exercised safely, - halt before PR and report the missing evidence. - -## UI Quality Bar - -Not UI-visible. No layout, copy, or visual styling changes are expected. The -only user-visible expectation is that existing SSH port-forward rows remain -present and actionable after window reactivation. - -## Review Screenshots - -1. SSH target connected with a port forward listed before re-registration. -2. Same SSH target after window reactivation, showing the same port forward - still listed. -3. Same SSH target after removing the port forward, showing it gone without an - error. - -## Rollout - -1. Add the focused regression test to prove the current lifecycle bug. -2. Change SSH handler registration to reuse process-lifetime managers. -3. Run the focused test, then typecheck and lint. -4. Validate in Electron against an SSH target if feasible; otherwise halt - before PR if the golden-path SSH UI cannot be exercised. - -## Lightweight Eng Review - -- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence - redesign is needed because the broken boundary is manager replacement during - handler re-registration. -- Architecture/data flow: process-lifetime managers stay module-level and are - reused; window-lifetime IPC handlers/listeners are refreshed; existing - connection and relay-session callback owners must also be refreshed or proxied - so live events target the current BrowserWindow. -- Failure modes covered: - - Active forwards becoming invisible after re-registration. - - `ssh:removePortForward` missing the old forward id. - - `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty - connection manager. - - `ssh:disconnect` failing to close old SSH connections and local listeners - after re-registration. - - Store/window/runtime callback refresh after re-registration. - - Re-registration during in-flight connect/reset/reconnect. - - No-session re-registration continuing to work. -- Test coverage required: - - Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across - `registerSshHandlers` calls. - - Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after - re-registration. - - Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the - newest window after re-registration. - - Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test - teardown/reset of module singletons. - - Existing lifecycle tests for reset, terminate, relay loss, and sleep remain - adjacent coverage. -- Performance/blast radius: no material startup or IPC cost. Reusing managers - avoids leaked runtime state and does not add polling, watchers, or - cross-process calls. Callback refresh is O(number of live SSH connections and - sessions) per registration, which should be tiny. -- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state - rather than changing layout or copy. -- Required review screenshots: - 1. Connected SSH target with active port forward before re-registration. - 2. Connected SSH target with same port forward after re-registration. - 3. Connected SSH target after removing that forward. -- Feasibility: one-time manager creation is feasible only with callback refresh - for existing `SshConnection` and `SshRelaySession` instances. If that refresh - proves larger than expected, prefer a stable callback proxy over recreating - managers; do not dispose live sessions just to make callback ownership easier. -- Residual risks: Electron validation may be constrained by availability of an - existing SSH target and by avoiding live-user port collisions. If the golden - path cannot be exercised safely, stop before opening a PR and report the - missing manual evidence. diff --git a/docs/terminal-close-confirmation.md b/docs/terminal-close-confirmation.md deleted file mode 100644 index da6ce20f2bf..00000000000 --- a/docs/terminal-close-confirmation.md +++ /dev/null @@ -1,114 +0,0 @@ -# Terminal Close Confirmation - -## Problem - -- `CloseTerminalDialog` repeats the action users already requested with "Close Terminal?" and a generic "process will be killed" warning (`src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx:30`). -- `Cmd/Ctrl+W` closes only the focused split pane, or the tab when it is the last pane; the guard exists so tab-level close does not kill every pane by accident (`src/renderer/src/components/terminal-pane/keyboard-handlers.ts:364`). -- The running-process guard probes the PTY over the active runtime/SSH path and shows the dialog only when child processes exist (`src/renderer/src/components/terminal-pane/TerminalPane.tsx:781`). -- There is no way for power users to say "I understand, close it next time" even though similar destructive workflows persist skip-confirm settings (`src/shared/types.ts:2522`, `src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx:58`). - -## Goal - -Make the terminal close confirmation communicate the consequence, respect focused-pane scope, and allow users to disable future running-process close confirmations from the dialog or Settings. - -## Non-goals - -- Do not change window-close behavior; whole-window shutdown intentionally bypasses the child-process dialog. -- Do not change idle-shell behavior; idle shells still close immediately. -- Do not add bulk-close UI for this change. -- Do not introduce provider-specific agent kill logic; closing still uses the existing terminal close path. -- Do not add telemetry. - -## Design - -1. Add a persisted `skipCloseTerminalWithRunningProcessConfirm` boolean to `GlobalSettings`, defaulting to `false`. -2. Keep the existing child-process probe. If the new setting is true, close immediately after the probe reports child processes instead of showing the dialog. -3. Track the pending close as `{ paneId, copyKind }`, where `copyKind` is `agent` only when `agentStatusByPaneKey[makePaneKey(tabId, leafId)]` has a live non-unknown `agentType`; otherwise it is `command`. -4. Update dialog copy: - - command: title `Stop running command?`, body `Closing this terminal will stop the command running inside it.`, destructive button `Stop and Close`. - - agent: title `Stop this agent?`, body `Closing this terminal will stop the agent's current work.`, destructive button `Stop Agent`. -5. Add a checkbox: `Don't ask again for running terminals`. When checked and confirmed, persist `skipCloseTerminalWithRunningProcessConfirm: true` before closing the pane. -6. Add a Terminal Interaction settings switch: `Ask Before Closing Running Terminals`, checked when the skip flag is false. -7. Add the new setting to terminal settings search so "confirm", "close", "running", "agent", and "command" find it. - -## Data flow - -- `Cmd/Ctrl+W` or pane close action -- `TerminalPane.handleRequestClosePane(paneId)` -- Get `ptyId`; no PTY closes immediately -- `inspectRuntimeTerminalProcess(settings, ptyId)` -- No child processes closes immediately -- Child processes + skip setting closes immediately -- Child processes + confirmation enabled opens `CloseTerminalDialog(copyKind)` -- Confirm optionally persists skip flag, then calls `executeClosePane(paneId)` - -## Edge cases - -- If process inspection rejects, preserve the existing fallback: close the pane instead of trapping the shortcut. -- If the pane is removed before the dialog confirms, `executeClosePane` already no-ops when the manager cannot close it. -- For split panes, only the active pane gets the prompt and closes. -- For last-pane tabs, confirming still delegates to `onCloseTab`. -- Agent copy appears only from live pane status. Freshly launched agents that have not emitted hooks yet may use command copy; that is acceptable because the consequence is still accurate. -- SSH/runtime-host terminals still use the existing runtime process inspection; the setting lives in global renderer settings and is passed through the same update path. -- The skip flag affects only terminal running-process close confirmations, not workspace deletion, automation deletion, window close, or future bulk-close prompts. - -## Test plan - -- Unit/component: - - `CloseTerminalDialog` renders command copy, agent copy, checkbox, and reports the checked state on confirm. - - Settings search includes the running-terminal confirmation entry. - - Default settings include `skipCloseTerminalWithRunningProcessConfirm: false`. -- Integration/lightweight: - - Verify `TerminalPane` opens agent copy when live pane status has `agentType` and command copy otherwise. - - Verify checked confirm persists the skip flag before closing. -- Electron: - - Running command + default setting shows command confirmation. - - Running command + checkbox checked confirms and future close skips the dialog. - - Agent pane with live status shows agent confirmation copy. - - Idle shell closes without confirmation. - -## UI quality bar - -- Dialog uses existing shadcn `Dialog` and `Button` primitives, token colors, and current compact modal sizing. -- Copy names the destructive consequence first and avoids implying every terminal/tab/window will close. -- Checkbox is visually subordinate to the message and aligned with existing dense dialog spacing. -- Settings row matches neighboring Terminal Interaction switch rows and is searchable. -- No layout shift, clipping, or button text overflow at the current modal width. - -## Review screenshots - -1. Running-command confirmation dialog. -2. Running-agent confirmation dialog. -3. Terminal Interaction settings row for `Ask Before Closing Running Terminals`. - -## Rollout - -1. Add shared setting type/default. -2. Add dialog copy modes and checkbox. -3. Wire `TerminalPane` to derive copy kind, honor skip flag, and persist "don't ask again" on confirm. -4. Add Terminal settings row and search entry. -5. Add targeted tests. -6. Force-add this design doc when staging because root `.gitignore` treats new `docs/**` files as local-only by default. - -## Lightweight Eng Review - -- Scope: kept focused on the existing running-process confirmation; no new close routing, bulk-close behavior, or native window-close changes. -- Architecture/data flow: renderer-only UI setting rides the existing settings persistence path; process detection remains owned by `inspectRuntimeTerminalProcess` so SSH/runtime compatibility does not fork. -- Failure modes covered: - - process-inspection rejection preserves current close fallback - - stale pane between prompt and confirm no-ops through existing manager guard - - split-pane close remains pane-scoped - - missing/stale agent status falls back to generic command copy - - skip flag is scoped to terminal running-process confirmations only -- Test coverage required: - - component test for `CloseTerminalDialog` copy/checkbox - - shared default/type coverage via existing typecheck plus default-setting assertion - - settings search test for new discoverable entry - - focused TerminalPane behavior test if practical; otherwise Electron validation covers prompt routing -- Performance/blast radius: no polling, IPC, startup, or renderer-jank impact; only an extra settings boolean read during an already user-triggered close path. -- UI quality bar: Electron validation should judge the modal and Terminal settings row against `docs/STYLEGUIDE.md`, existing `Dialog`/`Button`/`SettingsSwitchRow`, and adjacent Terminal Interaction density. -- Required review screenshots: - 1. Running-command confirmation dialog - 2. Running-agent confirmation dialog - 3. Terminal Interaction settings row -- Residual risks: agent-specific copy depends on live hook status, so newly launched or manually run agents can still receive generic command copy; the design doc is ignored by default and must be force-staged for the PR. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md deleted file mode 100644 index e3e7a125988..00000000000 --- a/docs/terminal-main-owned-state.md +++ /dev/null @@ -1,99 +0,0 @@ -# Terminal Main-Owned State - -This document covers the hidden-output recovery slice. The broader terminal -model/view boundary is defined in -[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). - -## Problem - -Hidden and background terminal panes cannot rely on renderer memory as the only -place that terminal output exists. Chromium may throttle a hidden Electron -document while the PTY continues producing bytes. If the renderer retains every -hidden byte until xterm can parse it, a noisy terminal can pin large strings in -renderer memory and stall or crash the app. - -The renderer must keep a hard memory bound, but the user-visible terminal state -should still be recoverable when the pane becomes visible again. - -## Reference Pattern - -Use a host-owned terminal model as the recovery source and treat the renderer as -a view: - -- The host process receives PTY bytes first and appends them to a bounded - headless terminal model. -- The renderer writes visible output directly for low latency. -- Hidden renderer queues are bounded. When they overflow, the renderer marks its - xterm as stale and drops further hidden bytes instead of retaining them. -- On visibility resume, the renderer asks the host for a serialized snapshot, - clears its xterm, replays that snapshot, then resumes live writes. -- Live output racing with restore carries a monotonic sequence number, so bytes - already included in the snapshot are not written twice. - -This gives the foreground path the same latency profile as today, bounds hidden -renderer memory, and preserves terminal state from the host-owned model instead -of depending on an unbounded renderer backlog. - -## Requirements - -- Renderer hidden-output memory is capped per terminal. -- A hidden flood must not grow the renderer by retaining strings or chunk arrays - past the cap. -- Restoring a stale renderer must use main/runtime state for local, daemon, and - SSH PTYs. -- Remote runtime PTYs that do not have local main-owned state must keep the - existing warning fallback rather than pretending recovery is available. -- Restore must avoid xterm query auto-replies reaching the shell. -- Clear, resize, exit, and pane disposal must clean up recovery state. -- The SSH path must participate in the same sequencing and snapshot behavior as - local PTYs. - -## Chosen Design - -The existing runtime headless terminal is the main-owned model. Every PTY byte -already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for -local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated -and can serialize it. - -Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default -on — see the contract's Architecture Status), main drops hidden renderer-bound -bytes after model ingestion and emits an out-of-band restore marker, so a -gated hidden pane accumulates no renderer backlog at all. The overflow path -below is the fallback for kill-switch-off mode and for hidden PTYs with an -active delivery-interest sidecar. - -The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: - -1. The scheduler replaces the queued backlog with a small warning fallback. -2. The terminal connection marks that pane as needing main-state recovery. -3. Further hidden bytes for that stale pane are not enqueued in the renderer. -4. When the pane/document becomes visible, the connection requests a main-owned - snapshot, clears xterm, replays the snapshot under the replay guard, and - sends the normal post-reattach reset. -5. Live foreground chunks that arrive while restore is in flight are retained in - a small bounded queue. After snapshot replay, sequence numbers decide which - chunks were already included and which still need to be written. - -If the main snapshot is unavailable, the small warning fallback remains the -visible behavior. That is the expected fallback for surfaces without local -main-owned terminal state. - -## Non-Goals - -- This does not add a durable full byte log. The host-owned model is bounded - terminal state, not an infinite transcript. -- This does not throttle the PTY producer. Producer backpressure can be added - later with ACKs if we need to reduce host-side work during extreme floods. -- This does not change foreground terminal write latency. - -## Verification - -- Unit-test scheduler overflow callback behavior and chunk-count bounding. -- Unit-test renderer recovery so hidden overflow drops renderer backlog, fetches - the main snapshot on visibility/foreground resume, and does not duplicate - sequenced live output. -- Unit-test main IPC snapshot sequencing. -- Run terminal scheduler and PTY connection tests. -- Run typecheck. -- Exercise the Electron hidden-flood repro and confirm renderer memory stays - bounded while the recovered terminal shows the host-owned terminal state. diff --git a/docs/terminal-scroll-intent-architecture.md b/docs/terminal-scroll-intent-architecture.md deleted file mode 100644 index b686f240546..00000000000 --- a/docs/terminal-scroll-intent-architecture.md +++ /dev/null @@ -1,329 +0,0 @@ -# Terminal Scroll Intent Architecture - -## Problem - -Terminal panes can jump or jitter when a user switches workspaces while a TUI -pane is scrolled near, but not exactly at, the bottom. The most visible case is a -Codex-style alternate-screen TUI in a split pane: - -1. The user scrolls slightly up from the bottom. -2. The user switches to another workspace. -3. The user switches back. -4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a - wrong position, or restores an older viewport. - -The current code tries to repair the viewport after layout, fit, split -reparenting, hidden-output replay, and visibility resume. Those repairs compete -with each other because none of them owns the user's scroll intent. A delayed -restore can replay an old state after the user has already scrolled, while a -follow-output path can force the bottom even when the user is reading scrollback. - -## Reference Investigation Summary - -The useful reference pattern is not a timer-based restore loop. It is an -explicit pinned-to-bottom model owned by the terminal frontend: - -- The frontend stores bottom-following state separately from xterm's transient - `viewportY`. -- `xterm.onScroll` is not used as user intent. In xterm, that event can be - content-driven and can briefly report bottom during fast output or layout work. -- User wheel and keyboard scroll commands update the intent. -- `scrollToBottom()` is an explicit command that sets the intent back to - follow-output. -- Every write snapshots the intent before writing. If the terminal was following - output, it scrolls to bottom after the write. If the user was pinned to a - viewport, it preserves the prior viewport line. -- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise - preserve the current viewport. -- Alternate-screen state is tracked from `buffer.active.type`, but it is not a - reason to infer user scroll intent from `onScroll`. - -The reference implementation also patches private xterm internals to disable -xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll -only from explicit owner paths. That gives a clean ownership boundary, but it is -an escalation point for Orca because private internals increase xterm upgrade -risk. - -## Current Orca Risk Points - -The current Orca branch already has several independent scroll actors: - -- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`, - `baseY`, bottom state, and sometimes a marker, then restores using immediate, - rAF, and timeout paths. -- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores - scroll around `safeFit()`. -- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split - reparent restores across rAF and timeout phases. -- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts` - listens to `terminal.onScroll` and stores snapshots while visible. -- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output - through foreground, background, hidden-output skip, snapshot replay, and - restore paths. -- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can - write immediately, enqueue foreground writes, coalesce synchronized output, or - drain hidden/background chunks later. - -The biggest architectural mismatch is the visibility memory hook's use of -`terminal.onScroll` as a snapshot trigger. That event is not a reliable user -scroll signal. During workspace switching, output parsing, hidden replay, or fit -can make it persist transient positions that later appear as "older position" -restores. - -## Design Goal - -Make terminal viewport movement a result of explicit scroll intent, not a side -effect of visibility, output, or layout timing. - -The terminal should have one active scroll intent per pane: - -- `followOutput`: the terminal is logically pinned to the live output bottom. - New output, explicit focus-follow requests, and fits may keep it at bottom. -- `pinnedViewport`: the user is reading a specific viewport. New output, - workspace switches, hidden-output replay, and fits must not move the viewport - except when scrollback pruning or buffer replacement makes the exact line - impossible. - -## State Model - -Add a focused module, for example: - -`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts` - -Suggested state: - -```ts -export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' - -export type TerminalScrollIntent = { - kind: TerminalScrollIntentKind - bufferType: 'normal' | 'alternate' - viewportY: number - baseY: number - capturedAt: number -} -``` - -The state should be keyed by the live `Terminal` or by pane leaf identity where -it needs to survive pane replacement. For normal workspace switches that keep -the same xterm instance, the live terminal-keyed state should be authoritative. - -## Intent Transitions - -Only these events should change scroll intent: - -- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write - callbacks or rAF can pull the viewport back down. -- User wheel scrolls downward: after xterm applies the scroll, recompute whether - the viewport reached bottom. If yes, set `followOutput`; otherwise keep - `pinnedViewport`. -- User keyboard scroll commands: apply the same rules as wheel. -- Explicit `scrollToBottom()`, "follow output", or focus command with - follow-output semantics: set `followOutput`. -- Programmatic `scrollToTop()` or page/line scroll commands: set - `pinnedViewport`, unless the resulting viewport is bottom. -- Buffer change to alternate screen: record `bufferType`, but do not infer - follow-output from `onScroll`. -- Buffer return to normal screen: recompute from current viewport only if no - stronger user intent exists for the normal buffer. -- Scrollback prune: clamp a pinned viewport to the nearest valid line without - changing intent. -- Terminal remount/replay: restore from durable fallback only if the live xterm - instance was actually replaced. - -Do not use xterm `onScroll` to set intent. It may still be useful as a passive -diagnostic signal, but it should not persist authoritative scroll state. - -## Write Contract - -All PTY output that reaches xterm should pass through one wrapper that enforces -intent around the actual `terminal.write` call. - -Suggested owner: - -`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` - -Contract: - -1. Capture intent and current `viewportY` before the write is scheduled. -2. Execute the write through the existing foreground/background/coalescing path. -3. After the write has parsed enough for xterm buffer state to be meaningful: - - If intent is `followOutput`, scroll to bottom. - - If intent is `pinnedViewport`, clamp and restore the saved viewport line. -4. Do not schedule multi-frame retries for normal output. A single post-write - enforcement point should be enough for standard writes. - -For foreground synchronized-output holds, the enforcement point should run after -the held/coalesced frame is released, not for each partial cursor-hide chunk. - -For background writes, preserve `pinnedViewport` if the pane is hidden or -inactive. Hidden output should not silently repin the terminal. - -## Fit And Resize Contract - -`safeFit()` should stop using generic delayed scroll restoration as its normal -behavior. - -New rule: - -1. If dimensions do not change, do nothing. -2. Before fit, capture current intent and viewport. -3. Fit. -4. If intent is `followOutput`, scroll to bottom. -5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the - new `baseY`. - -Avoid fitting hidden or zero-geometry panes. Existing geometry guards should -remain because fitting to transient workspace-switch geometry can send bad sizes -to the PTY and trigger TUI redraw churn. - -## Visibility And Workspace Switch Contract - -Workspace switching should not be a scroll operation. - -On hide: - -- Capture the current intent once. -- Do not run scroll restore. -- Do not update intent from `onScroll`. -- Continue hidden-output throttling/snapshot behavior as today. - -On show: - -- Reattach or refresh renderer resources as needed. -- Flush only the bounded amount of hidden output required for the active pane. -- Apply intent once after the visible output catch-up: - - `followOutput` goes to bottom. - - `pinnedViewport` stays pinned. -- Do not run repeated rAF/timeout scroll restores. - -If the underlying terminal instance was replaced, use durable fallback state. -If the instance stayed alive, its live scroll intent is authoritative. - -## Hidden Output And Snapshot Replay - -Hidden-output snapshot replay is a true fallback path because it clears and -reconstructs xterm content. It needs scroll handling, but it should still obey -intent: - -- Before snapshot replay, capture intent and viewport. -- Replay serialized content. -- Fit only if needed and only with valid visible geometry. -- Reapply the captured intent once: - - `followOutput`: bottom. - - `pinnedViewport`: previous viewport line, clamped. -- Do not let replay set follow-output just because the replayed buffer ends at - bottom. - -If hidden output arrives while the user is pinned, background catch-up should not -move the visible viewport on activation. - -## Split Reparenting - -DOM reparenting can reset browser scroll state and WebGL resources. This is a -legitimate place for a fallback restore, but it should be scoped: - -- Capture intent before reparent. -- Reparent. -- Reattach renderer if required. -- Apply intent once after DOM settles. -- Avoid restoring alternate-screen scrollback, because alternate screen has no - normal scrollback and a TUI owns its cursor. - -The current split-specific rAF/timer code should become a local fallback for DOM -reparenting only, not a general model copied into workspace switching. - -## Performance Constraints - -The design must preserve Orca's terminal performance priorities: - -- Do not keep all hidden terminals hot-rendering. -- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering - work and throttle hidden output as today. -- Avoid per-output layout reads. Intent enforcement should read xterm buffer - fields, not DOM geometry. -- Avoid multi-frame restore loops. They cause visible jitter and keep the - renderer busy after activation. -- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes - can catch up over later frames. -- Do not send resize/SIGWINCH unless dimensions actually changed and the - renderer is the authoritative size owner. - -The intended steady-state cost is one small buffer-state capture per xterm write -batch, not per byte and not per animation frame. - -## Implementation Plan - -1. Add `terminal-scroll-intent.ts`. - - Track `followOutput` vs `pinnedViewport`. - - Provide helpers for user scroll, explicit bottom, write capture, fit - capture, and intent enforcement. - -2. Replace visibility-memory `onScroll` ownership. - - Remove authoritative snapshot updates from `terminal.onScroll`. - - Add capture-phase wheel listeners and keyboard scroll command hooks. - - Keep `onScroll` only for diagnostics if needed. - -3. Route explicit scroll commands through intent helpers. - - `scrollToBottom()` sets `followOutput`. - - `scrollToTop()`, page up, and line up set `pinnedViewport`. - - Downward commands recompute after the command. - -4. Wrap output writes. - - Add an intent-aware writer boundary in the output scheduler or in a narrow - wrapper used by the scheduler. - - Ensure foreground coalescing applies intent after the coalesced frame. - - Ensure background drains preserve pinned viewports. - -5. Convert `safeFit()` to intent-aware fit. - - Capture intent before fit. - - Apply once after fit. - - Remove generic deferred scroll restore from normal fit. - -6. Limit restore fallbacks. - - Keep fallback restore only for true remount/replay/reparent cases. - - Remove workspace-switch rAF/timeout restore loops once intent enforcement is - in place. - -7. Add focused tests. - - User scroll up sets `pinnedViewport`. - - Output while pinned preserves viewport. - - Output while following scrolls to bottom. - - Fit while pinned preserves viewport. - - Fit while following stays at bottom. - - Hidden-output replay while pinned does not repin. - - Workspace hide/show does not change intent. - - `onScroll` does not mutate intent. - -8. Add an E2E reproduction. - - Use the existing `scroll-primary` and `scroll-secondary` worktrees. - - Top pane in `scroll-primary` contains the TUI with enough scrollback. - - Scroll slightly above bottom, switch to `scroll-secondary`, then switch back. - - Assert the terminal remains within a small viewport tolerance and does not - visit top/bottom during the transition. - -## Acceptance Criteria - -- Switching away and back does not move a pinned TUI viewport. -- No visible flash to top or bottom during activation. -- Scrolling all the way to bottom re-enables follow-output and does not later - restore an older pinned position. -- Hidden output in inactive workspaces does not change visible scroll position - until the user explicitly follows output or reaches bottom. -- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts - their cursor or scroll region. -- The active pane remains responsive during heavy hidden-output catch-up. - -## Open Decisions - -- Whether Orca should patch private xterm bottom-scroll internals. The reference - pattern does this for strict ownership, but Orca should first try public API - enforcement at write/fit boundaries and only escalate if xterm continues to - auto-follow independently. -- Whether scroll intent should be stored only by live `Terminal` instance or also - mirrored by leaf ID for remount fallback. The initial implementation should - use live instance state plus a leaf-keyed fallback for true replacement. -- Whether alternate screen needs a separate intent record from normal screen. - The first version can use one record with `bufferType`; a separate normal vs - alternate record is justified only if testing shows mode switches overwrite - user intent. diff --git a/docs/windows-secure-file-acl-hardening.md b/docs/windows-secure-file-acl-hardening.md deleted file mode 100644 index f5036fe1c72..00000000000 --- a/docs/windows-secure-file-acl-hardening.md +++ /dev/null @@ -1,104 +0,0 @@ -# Windows Secure-File ACL Hardening - -## Problem - -Credential files Orca writes on Windows (runtime env auth store, device registry, -e2ee keypair) must end up readable only by the current user, SYSTEM, and -Administrators. On POSIX this is a one-line `chmodSync`, but `writeFileSync`'s -`mode` option is a no-op on Windows, so the NTFS ACL has to be rewritten by -shelling out to PowerShell (`Get-Acl` / `Set-Acl`). PowerShell cold-start is -~1–1.5 s. - -`readEnvironmentStore` calls `hardenExistingSecureFile` on every read. The -env-store parent directory's mtime churns constantly (every secure write updates -it), so an mtime-keyed idempotency cache never matches and a blocking PowerShell -spawn fires on every call. After the remote-runtime tab-sync polling change, the -store is read ~2×/s, turning sporadic mtime misses into a continuous main-thread -storm (~1.8 powershell.exe spawns/sec) that saturates the Electron main thread -and times out `runtimeEnvironments:call`. See #4901 / #5006. - -## Model - -`src/shared/secure-file.ts` applies two different caching + execution strategies, -chosen by whether the target is a directory and whether it is on the write path: - -- **Directories — async + path-cached for the process lifetime.** - A directory's required ACL does not change when its mtime changes, so once a - directory has been hardened in this process it is trusted for the rest of the - process. Directory hardening uses fire-and-forget `execFile` so it never blocks - the main thread. This is the change that kills the #4901 storm. - - _Known limitation:_ a directory that is deleted and recreated mid-process is - not re-hardened until the next restart. The `.orca` secure dirs are not - deleted at runtime, so this is acceptable. - -- **Credential files on the write path — synchronous, cache only on success.** - Because `writeFileSync({ mode })` is a no-op on Windows, a freshly written file - carries the parent directory's inherited (broader) ACL. `writeSecureFile` must - therefore restrict the file's ACL **synchronously** (via `execFileSync`) on the - temp file and on the renamed target before it returns — otherwise the function - would return with the credential briefly readable under inherited ACLs during - the ~1–1.5 s PowerShell cold-start window. The write path is infrequent, so the - synchronous cost is acceptable. The path is cached as hardened **only on - confirmed success**, so a failed apply is retried on the next write. - -- **Existing files on the read path — async + metadata-cached.** - `hardenExistingSecureFile` re-asserts the ACL on an already-existing file at - most once per process (keyed on inode/size/timestamps so post-rename inode - changes are detected). Async is safe here because it only re-asserts an ACL on a - file that already exists; new files are hardened synchronously on the write path - above. Because it fires at most once per file, it does not storm. - -The net effect: the frequent read path never blocks the main thread, while the -infrequent write path closes the async window so credential files are never -published with a broader-than-intended ACL. - -## Requirements - -- Read-path directory and existing-file hardening must not spawn PowerShell more - than once per path per process, regardless of mtime churn. -- `writeSecureFile` must apply the credential file's ACL synchronously before - returning; the file ACL must not be left to a background process. -- A failed synchronous file-ACL apply must not crash the write and must not be - cached as hardened (so it retries). -- No PowerShell is spawned on non-win32 platforms. - -## Manual Windows end-to-end test plan - -The automated e2e harness (`pnpm test:e2e`, Playwright `electron-headless`) runs -on `ubuntu-latest`, where `applySecurePathRestriction` short-circuits to -`chmodSync` and never reaches the PowerShell path. The ACL storm therefore cannot -be reproduced in the cross-platform e2e harness; verify it manually on Windows. - -Pre-req: a Windows client paired to a remote `orca serve` runtime. - -Watcher (PowerShell, run before launching Orca): - -```powershell -while ($true) { - $n = (Get-CimInstance Win32_Process -Filter "Name='powershell.exe'").Count - "{0} powershell.exe count = {1}" -f (Get-Date -Format HH:mm:ss), $n - Start-Sleep -Milliseconds 500 -} -``` - -Steps: - -1. Launch the **stock v1.4.52/v1.4.53** build and open the remote workspace. - - **Before fix:** the watcher oscillates ~1–2 powershell.exe processes/sec - continuously through the load window; the app is unresponsive and the - `[web-session-tabs-sync] … RemoteRuntimeClientError: Timed out` error - appears in the console. -2. Launch the **fixed** build and open the same remote workspace. - - **After fix:** the watcher stays at `0` (no continuous powershell churn); the - env-store directory is hardened at most once; the app loads without the - session-tabs timeout. -3. Write a credential (e.g. sign in / register a device so a secure file is - written), then immediately inspect the file ACL: - - ```powershell - icacls "$env:APPDATA\orca\orca-environments.json" - ``` - - - **Expected:** only the current user, `SYSTEM`, and `Administrators` have - access the instant the write completes (no inherited entries), confirming the - synchronous file-ACL apply closed the async window. diff --git a/docs/workspace-space-scan-resource-bounds.md b/docs/workspace-space-scan-resource-bounds.md deleted file mode 100644 index 81510e3cfea..00000000000 --- a/docs/workspace-space-scan-resource-bounds.md +++ /dev/null @@ -1,238 +0,0 @@ -# Workspace Space Scan Resource Bounds - -## Problem - -Resource Manager scans every non-prunable worktree to calculate workspace disk usage. A fleet with -hundreds of worktrees can make Orca and the host unresponsive while the scan is active. - -Two independent resource failures are possible: - -- macOS and Linux run recursive `du` processes for several worktrees at once. Competing full-tree - metadata walks can saturate local storage and stall unrelated applications. -- Windows, unsupported `du` variants, timeouts, and filesystem errors use a portable recursive - walker. The old walker allocated a promise and retained result node for every entry without a - capacity limit, so a large worktree could exhaust the main or relay process heap. - -The July 27 incident occurred with 298 Orca worktrees. Renderer telemetry remained far below its -heap limit, while quitting Orca immediately restored host responsiveness. The installed binary also -contained the uncapped portable fallback. - -## Root Cause - -`src/main/workspace-space-analysis.ts` admitted up to three worktree scans per repository while -scanning two repositories concurrently. That allowed as many as six local `du` traversals. - -Both the desktop portable walker and `src/relay/workspace-space-scan.ts` recursively called -`Promise.all(entries.map(...))`. Filesystem operation limiters capped active `stat` and `readdir` -calls, but they did not cap queued promises, retained paths, directory arrays, or aggregate result -nodes. A failed `du` scan then repeated the same traversal through this higher-memory path. - -Shared fixed-worker traversal and capacity primitives already exist, but the active desktop and -relay scanners did not use them. - -## Goals - -- Keep the host responsive while scanning hundreds of local worktrees. -- Bound portable traversal memory before admitting filesystem entries. -- Preserve size totals, symlink behavior, top-level compaction, partial failures, cancellation, and - progress reporting. -- Apply equivalent bounds to local, folder-workspace, WSL, and SSH relay paths. -- Preserve the current WSL-aware Git worktree listing and abort signals. - -## Non-Goals - -- Make a 298-worktree scan finish quickly. -- Change Resource Manager UI, progress copy, deletion rules, or scan persistence. -- Exclude `node_modules`, build output, or other directories from reported size. -- Change Git worktree discovery or cleanup behavior. -- Introduce polling, caching, or background automatic scans. - -## Design - -### Local disk admission - -Create one scan-wide limiter for local worktree traversal. Repository discovery and remote scans can -remain concurrent, but every local worktree must acquire the single local slot before invoking -`du` or the portable walker. - -The slot is global to one Resource Manager scan, not per repository. This changes the maximum local -full-tree traversal count from six to one. Cancellation rejects queued admissions and stops the -active child process through the existing `AbortSignal`. - -### Remote fallback admission - -The desktop-side request-by-request SSH fallback runs its traversal inside the desktop main -process, so its budget is charged against Orca's heap rather than the remote host. Repository and -worktree concurrency alone would let six of these traversals hold six independent budgets at once. -A second scan-wide limiter admits at most two, capping aggregate admission at 2 × 64 MiB instead of -6 × 64 MiB. Bulk relay scans stay outside this limiter: their traversal memory lives on the remote -host, one hard capacity per request. - -### Fixed-worker portable traversal - -Use `scanWorkspaceSpaceEntryTree` for desktop local fallback, desktop remote-provider fallback, and -relay fallback scans. The traversal: - -- owns only the configured number of live entry jobs; -- preserves source order and aggregate sizes; -- uses iterative directory frames instead of recursive promise fanout; -- stops on cancellation and removes its abort listener; -- treats unreadable or disappearing entries as partial failures; -- does not follow symlink targets. - -Top-level `du` result processing uses `mapWithConcurrency` so unusually wide workspace roots do not -allocate one live operation per entry. - -### Capacity admission - -Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention: - -- maximum entries in any one directory listing: 100,000; -- maximum estimated live scan state per worktree traversal: 64 MiB. - -The entry cap is per listing rather than per traversal because only a single directory's width is -fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a -listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100 -files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted. -Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share. - -The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object -overhead, and each listing's parent path once. The parent path is charged per listing rather than -per entry because a listing's entries all share a single parent-path string; charging it per entry -multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live -heap and rejected the layouts above once the worktree path passed ~58 characters. These are -admission limits, not post-allocation observations. - -The budget measures what the traversal is holding **right now**, not what it has ever seen. A -directory listing is charged when admitted and released once every one of its entries has been -dispatched, so the caps bound the widest concurrent frontier rather than total tree size. This -distinction decides real workspaces: a cumulative counter charged an ordinary 76,788-entry Orca -worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch -name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state. - -Neither cap may depend on where a user checks out their worktrees, so the estimate charges each -absolute path once per listing rather than once per entry. A live cap depends only on directory -shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries) -and nothing else. - -Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not -fall through into the portable walker, because that would repeat an already rejected traversal. - -### Failure behavior - -- Desktop local scan: return an unavailable worktree row with the capacity error. `classifyError` - maps `WorkspaceSpaceScanCapacityError` to `unavailable` rather than `error`, because the workspace - is intact and readable, just too large to size safely. The Resource Manager renders that row as - "Unavailable" instead of "Failed". -- SSH relay scan: reject the bulk scan; the desktop provider converts it to an unavailable row. -- Generic `du` failure: use the bounded portable fallback. -- Cancellation: propagate the scan-cancelled error rather than converting it to a row failure. -- Missing or unreadable entries below the capacity limit: preserve existing partial-result - behavior. - -### Platform and workspace parity - -- macOS/Linux: one local `du` at a time; bounded Node fallback. -- Windows: one local bounded Node traversal at a time. -- WSL: retain `getLocalProjectWorktreeGitOptions` and its selected distro for worktree discovery; - UNC/native filesystem traversal uses the same local admission and capacity bounds. -- SSH: prefer the bulk relay scan; both the relay and the request-by-request compatibility fallback - use the shared capacity model. -- Folder workspaces: the synthetic main worktree passes through the same local limiter and scanner. - -## Data Flow - -1. Resource Manager starts one deduplicated scan and creates an abort controller. -2. Repository workers list worktrees with existing host-specific Git options and the scan signal. -3. Remote worktrees continue through provider concurrency. Local worktrees wait for the scan-wide - local slot. -4. POSIX local or relay scans try `du`. -5. Top-level entries are admitted against the scan budget and projected with bounded concurrency. -6. If `du` fails generically, the fixed-worker portable traversal scans within the same limits. -7. Capacity failures become unavailable rows; successful results keep existing compaction and - progress behavior. - -## Alternatives Considered - -### Only reduce `du` concurrency - -This protects local storage but leaves Windows, SSH compatibility fallback, and failed `du` scans -capable of unbounded heap growth. - -### Only add traversal capacity limits - -This prevents OOM but still permits several recursive `du` processes to compete for local storage, -which does not address the observed whole-host stall. - -### Remove the portable fallback - -This would fail Windows scans and POSIX environments where `du -d` is unavailable or errors during -active filesystem churn. - -### Skip dependency and build directories - -This would make Resource Manager under-report the directories users most often want to reclaim. - -## Measurement and Performance Budget - -The deterministic regression measurements are: - -- peak simultaneous local `du` calls across repositories: exactly one; -- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two; -- portable traversal live entry jobs: no more than its configured worker count; -- portable entries in any one directory listing: at most 100,000; -- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree - peaks between 4 MiB and 8 MiB; -- progress and final row counts remain unchanged for scans below the limits. - -A follow-up diagnostic span should record strategy (`du` or portable), worktree duration, fallback -reason, admitted entry count, estimated retained bytes, and peak local scan concurrency. It must -record counts and identifiers rather than raw paths or directory trees. - -## Test Plan - -- Desktop integration: force the portable path over its entry budget and assert an unavailable row. -- Relay integration: force the portable path over its entry budget and assert a capacity failure on - both the Windows/portable and POSIX `du` entry points. -- Remote fallback concurrency: hold six SSH fallback traversals across two repositories and assert - no more than two run at once. -- Concurrency: start local worktrees from two repositories, hold the first `du`, and assert the - second does not start until the first completes. -- Shared traversal: verify worker peak, source order, exact-limit success, over-limit failure, deep - trees, partial failures, and cancellation cleanup. -- Existing desktop coverage: local results, symlinks, progress, cancellation, WSL routing, SSH bulk - scans, disconnected SSH, `du` timeout fallback, and IPC deduplication. -- Run `pnpm run typecheck` and `pnpm run build`. - -## Rollout - -1. Connect the shared fixed-worker traversal and capacity budget to desktop and relay scanners. -2. Add the scan-wide single local traversal slot. -3. Add focused capacity and concurrency regression tests. -4. Ship without a migration or feature flag; behavior below the limits is unchanged. -5. Monitor capacity failures and scan duration before considering a higher local concurrency. - -## Risks - -- Scanning hundreds of worktrees takes longer because local work is serialized. The feature already - streams progress and allows users to leave the page, and host responsiveness takes priority over - scan throughput. -- A legitimate worktree above the capacity limit is reported unavailable instead of partially - sized. Failing closed avoids presenting an incomplete size as safe deletion evidence. With a live - cap this now requires a pathological directory rather than merely a large repository. -- Because the local slot is acquired inside the per-repository worker pool, a local worktree waiting - on the slot still occupies one of the six in-flight scan slots. On fleets mixing local and SSH - repositories this can delay remote repositories behind serialized local work, even though remote - scans never contend for the local disk. -- Remote hosts can still process concurrent worktrees, but each relay request has an independent - hard capacity and remote concurrency does not contend with the user's local disk. - -## Validation - -- Focused tests: 36 passed across desktop analysis, IPC, relay, shared traversal, scan budget, and - concurrency primitives. -- TypeScript: all node, CLI, and web projects passed. -- Production build: relay targets, CLI, Electron, renderer, web projection, and macOS native - components completed successfully. -- No live 298-worktree rescan is required for correctness validation; the concurrency and capacity - properties are deterministic tests. diff --git a/docs/worktree-delete-preflight.md b/docs/worktree-delete-preflight.md deleted file mode 100644 index 1b8263762cf..00000000000 --- a/docs/worktree-delete-preflight.md +++ /dev/null @@ -1,78 +0,0 @@ -# Worktree Delete Preflight - -## Problem - -- Local delete paths kill PTYs before git deletion: -- `worktrees:remove` IPC (`src/main/ipc/worktrees.ts`) -- `removeManagedWorktree` runtime/RPC (`src/main/runtime/orca-runtime.ts`) -- On non-force failures (dirty/untracked is common), the worktree stays on disk but terminals are already gone. -- PTY teardown is intentionally destructive and best-effort (`src/main/runtime/worktree-teardown.ts`), so non-force deletability must be checked first. - -## Ground Truth From Code - -- There is no dry-run remove path currently used in git helpers (`src/main/git/worktree.ts`). -- Errors shown to users are normalized through `formatWorktreeRemovalError` (`src/main/ipc/worktree-logic.ts`). -- SSH-backed repos already delegate deletion to provider APIs and should remain provider-owned. -- Current orphan cleanup (`is not a working tree` handling + prune + metadata cleanup) lives in IPC/runtime remove catch blocks and must not regress. - -## Non-goals - -- Do not change force-delete semantics; force may still kill PTYs before git remove. -- Do not add new renderer confirmation states or copy. -- Do not attempt to predict every possible git remove failure. -- Do not change SSH provider teardown ownership. - -## Design - -1. Add local preflight helper in `src/main/git/worktree.ts`. -- Export `assertWorktreeCleanForRemoval(worktreePath: string, force = false): Promise`. -- If `force`, return immediately. -- Run `git status --porcelain --untracked-files=all` in `cwd = worktreePath`. -- If output is non-empty, throw a dedicated error (dirty/untracked). -- If command fails, rethrow original error. - -2. IPC local delete ordering (`worktrees:remove`). -- Keep canonicalization and protected-path validation first (`getRegisteredDeletableWorktree`). -- Keep SSH provider branch unchanged. -- Keep archive hook and symlink cleanup before preflight, so preflight checks the exact post-hook/post-symlink state that `git worktree remove` will see. -- Run preflight. -- If preflight throws an orphan/missing-worktree style error, continue to existing remove path so current orphan cleanup behavior still executes. - Treat at least these as orphan-compatible for preflight: `is not a working tree`, `not a git repository`, and missing-path (`ENOENT`) failures from running status in a removed directory. -- For other preflight failures, throw via `formatWorktreeRemovalError(...)`. -- Only after successful preflight: run `killAllProcessesForWorktree(...)`, then `removeWorktree(...)`. - -3. Runtime/RPC local delete ordering (`removeManagedWorktree`). -- Keep SSH branch unchanged. -- Keep current hook behavior (archive optional via `--run-hooks`, warning when configured but skipped). -- Run preflight after hook handling. -- Preserve orphan compatibility exactly as in IPC: preflight must not short-circuit existing orphan cleanup semantics (including `not a git repository`/`ENOENT` preflight failures that should fall through to the existing remove/catch path). -- On successful preflight: run PTY teardown, then `removeWorktree`. -- Route failures through existing formatted error surface. - -4. Failure-class behavior contract. -- Dirty/untracked (non-force): fail before PTY teardown. -- Preflight subprocess/tooling failures: fail before PTY teardown, formatted. -- Orphan/missing-worktree conditions (`is not a working tree`, `not a git repository`, `ENOENT`): retain current cleanup-and-metadata-removal behavior by running the existing remove/catch flow without PTY teardown. -- Force deletes: no preflight; keep current teardown-before-remove order. - -5. Tests. -- Update ordering assertions for non-force local deletes to `preflight -> kill -> git`. -- Add IPC/runtime tests proving dirty non-force failures happen before any PTY kill. -- Add IPC/runtime tests proving preflight error formatting uses `formatWorktreeRemovalError` path. -- Add IPC/runtime regression tests proving orphan cleanup still runs when preflight encounters orphan-like failures. -- Keep force ordering tests (`kill -> git`). -- Keep SSH tests proving local PTY teardown is not used for SSH-backed repos. - -## Concurrency, Consistency, Limits - -- Preflight narrows, but does not close, the race window: external edits can occur after preflight and before `git worktree remove`. -- Multi-window and out-of-band mutation races remain possible between canonicalization, hooks/symlink cleanup, preflight, kill, and remove. -- IPC has symlink cleanup before preflight; runtime does not. That asymmetry remains unless runtime gains equivalent cleanup. -- Cost is one additional git subprocess per non-force local delete; this is acceptable for a user-initiated destructive action. - -## Rollout - -1. Implement `assertWorktreeCleanForRemoval` + unit tests in `src/main/git/remove-worktree.test.ts`. -2. Wire IPC ordering and failure mapping in `src/main/ipc/worktrees.ts`; update `src/main/ipc/worktrees.test.ts`. -3. Wire runtime ordering and failure mapping in `src/main/runtime/orca-runtime.ts`; update `src/main/runtime/orca-runtime.test.ts`. -4. Run focused tests, then `pnpm typecheck` and `pnpm lint`. diff --git a/docs/worktree-sidebar-drag-autoscroll.md b/docs/worktree-sidebar-drag-autoscroll.md deleted file mode 100644 index 4ae6822c5a8..00000000000 --- a/docs/worktree-sidebar-drag-autoscroll.md +++ /dev/null @@ -1,139 +0,0 @@ -# Worktree Sidebar Drag Autoscroll - -## Problem - -The left sidebar supports reordering worktree rows, but holding a drag near the top or bottom of the scrollable sidebar does not continue scrolling. - -- `src/renderer/src/components/sidebar/WorktreeList.tsx:399` keeps active worktree drag state and cached group rects. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:757` computes the drop from `clientY`, the current sidebar `getBoundingClientRect()`, and `scrollTop`. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1400` updates the custom pointer drag only after pointer events. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1660` uses the native drag path for card drag start/over/drop. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1920` renders the scroll container as `[data-worktree-sidebar]`. - -## Current Behavior - -The custom pointer path promotes to an active drag after `SIDEBAR_POINTER_DRAG_THRESHOLD_PX`, creates a fixed preview, stores a `WorktreeDragSession`, and schedules `flushWorktreePointerDrag()` on pointer movement. Drop still uses `computeWorktreeDrop()` and `onReorderWorktrees()`. If `computeWorktreeDrop()` returns null, pointer drops may instead pin or move the worktree to a workspace status target. - -The native HTML5 handlers still exist: they store the same `WorktreeDragSession` on `onCardDragStart`, compute the reorder preview in `onDragOver`, commit through `onDrop` or the capture-phase document drop handler, and clear on `onCardDragEnd` through `WorktreeCard`. In the current list rendering, `WorktreeCard` is passed `nativeDragEnabled={false}`, so the active user-facing worktree reorder path is the custom pointer path. If the native handlers remain in place, keep them consistent; workspace status/pin drops are already handled by `useWorkspaceStatusDocumentDrop()`. - -Scroll bookkeeping already exists. `handleScroll()` calls `markScrollMovement()`, which suppresses virtualizer measurement correction. Direct wheel/touch/scrollbar input additionally calls `markDirectScrollInput()`, which blocks anchor restoration retries. Autoscroll must mark programmatic movement, not direct user input. - -## Root Cause - -Both drag paths are event-driven. When the pointer is held at a sidebar edge, no frame loop changes `scrollTop`; without a scroll change, the virtualized list does not reveal farther rows and `computeWorktreeDrop()` keeps evaluating against the same viewport. - -There is also a virtualization constraint: `computeWorktreeDrop()` only considers `worktreeDragSessionRef.current.rects`, which are a snapshot of the source group's mounted rows at drag start. Autoscroll would reveal new rows but still reject drops outside the original snapshot unless the active session refreshes its source-group rects from the current DOM. Refreshing rects must stay limited to the same `sourceGroupKey`; ordering still commits through the existing `worktreeDragUnitGroups`, `getFullDropIndexForWorktreeDragUnit()`, and `onReorderWorktrees()` path. - -## Non-Goals - -- Do not change manual ordering, rank persistence, group membership, lineage expansion, selection, pinning, or workspace status semantics. -- Do not change the workspace board drawer drag behavior. -- Do not add settings, controls, new visual affordances, IPC, persistence, SSH, filesystem, or git-provider behavior. - -## Design - -1. Add a concrete helper for sidebar drag autoscroll, for example `worktree-sidebar-drag-autoscroll.ts`. It should compute a bounded next `scrollTop` from: - - latest pointer `clientX`/`clientY`; - - container bounds; - - `scrollTop`, `scrollHeight`, and `clientHeight`; - - elapsed frame time, clamped so a throttled/background frame cannot jump the list. -2. The helper must return no-op when the pointer is outside the container horizontally, the pointer is outside the vertical edge zones, or the container is already at the relevant scroll bound. -3. Add a small session refresh helper that replaces `worktreeDragSessionRef.current` with the latest `getWorktreeDragRectsForGroup(scrollRef.current, session.sourceGroupKey)` result and revalidates the latest groups: `worktreeDragGroups` must still contain the source group and `session.draggingWorktreeId`, and `worktreeDragUnitGroups` must still contain `session.reorderUnitDraggedIds` for the same `sourceGroupKey`. Do not require every `session.reorderDraggedIds` entry to be in the source group, because the existing multi-select/lineage reorder path filters those ids during commit. If the source group or reordered unit ids disappear, clear the drag instead of committing stale ids. -4. Track autoscroll animation frame ids separately from `WorktreePointerDrag.frameId`. The existing `frameId` is for preview/drop flushing; sharing it would make it easy to cancel the wrong work or skip a needed preview update. -5. Pointer drag path: - - start autoscroll only after `beginWorktreePointerDrag()` promotes the drag to active; - - each autoscroll frame reads the latest `drag.currentX/currentY`; - - apply `container.scrollTop = nextScrollTop` only when it changes; - - call `markScrollMovement()` before or immediately after the scroll write instead of `markDirectScrollInput()`; - - refresh source-group rects from the mounted DOM before recomputing a drop; if the virtualizer has not rendered the newly exposed rows yet, keep the current null-drop behavior for that frame; - - schedule `scheduleWorktreePointerDragFrame(drag)` after a scroll write so the preview line is recomputed against the new `scrollTop` and refreshed rects. -6. Native drag path, if it is kept or re-enabled: - - store the latest native drag-over `clientX/clientY` in a small ref while a `WorktreeDragSession` exists; - - run a separate autoscroll frame loop from `onDragOver`; - - after a scroll write, refresh source-group rects, recompute the native drop preview from the stored `clientY`, and update `worktreeDragState`; - - set `event.dataTransfer.dropEffect = 'move'` only inside real `dragover`/`drop` event handlers when the current drop is valid. A RAF callback cannot update the native drag cursor/effect; it can only update sidebar preview state until the next native drag event. -7. Cleanup must cancel both pointer and native autoscroll loops on drop, document drop, dragend, pointerup, pointercancel, document visibility loss, component unmount, and `clearWorktreeDrag()`. - -Keep the implementation local to the renderer sidebar. The scroll container remains `scrollRef.current`; do not query arbitrary sidebars globally. - -## Data Flow - -Pointer: - -- Pointer down snapshots source-group rects and arms a non-active pointer drag. -- Pointer move beyond the threshold promotes to active, creates the preview, stores the drag session, and starts autoscroll. -- Autoscroll frames update `scrollTop` when the latest pointer point is in an edge zone. -- After each scroll write, the active session refreshes source-group rects from the current DOM and the normal pointer drag frame recomputes the drop preview using the existing `computeWorktreeDrop()`. -- Pointer up commits through the existing `onReorderWorktrees()` path, or through the existing pin/status fallback when no reorder drop is valid. - -Native: - -- If enabled, native drag start stores the drag session and cached source-group rects. -- Native drag over stores the latest point, computes the current drop, and starts autoscroll. -- Autoscroll frames reuse the latest stored point to update scroll, refresh source-group rects, and recompute preview. They do not try to set `DataTransfer.dropEffect`. -- Native drop/document drop commits through the existing reorder path; dragend clears state. - -## Edge Cases - -- Top and bottom bounds: do not keep writing the same `scrollTop`. -- Horizontal outside: no sidebar autoscroll if `clientX` is left or right of the scroll container. -- Vertical outside: allow scrolling when `clientY` is at or slightly beyond the top/bottom edge, but cap speed. -- Threshold: custom pointer autoscroll must not start before the existing drag threshold. -- Native event sparsity: continue from the last known drag-over point while events pause, but stop on dragend/drop/visibility loss. -- Drop validity while scrolling: if refreshed source-group rects still do not cover the pointer after scroll, preserve the current null-drop behavior rather than guessing a new index. -- Virtualized rows: rows can mount/unmount while scrolling. Refresh rects from `[data-worktree-drag-id]` for the source group only, and tolerate a frame where the virtualizer has not mounted newly visible rows yet. -- Source mutations: if the dragged worktree, source group, or reorder unit disappears during drag, clear the drag instead of committing against stale ids. -- Concurrent ordering changes: final drop should use the latest `worktreeDragGroups`/`worktreeDragUnitGroups` already captured by React callbacks, but the source group and reordered unit ids must be revalidated before commit. Do not rederive a different dragged set mid-drag. -- Multi-window/external mutations: this is renderer-local UI state. Do not persist autoscroll state or broadcast it; external list mutations should only invalidate or clear the active drag. -- Workspace status and pin targets: preserve pointer fallback behavior and do not make native reorder autoscroll steal document-level status/pin drops. The capture-phase document reorder handler should only prevent default/stop propagation when `computeWorktreeDrop()` returns a valid reorder drop. -- Unmount/remount: cancel animation frames and remove previews/styles in the existing cleanup path. - -## Test Plan - -- Unit: add tests for the autoscroll calculation helper covering top edge, bottom edge, middle no-op, horizontal outside, scroll bounds, capped speed, and elapsed-time scaling. -- Unit: add tests for the rect/session refresh helper with same-group refresh, missing source group, missing dragged ids, and empty mounted rects. -- Unit: add tests for any extracted native/pointer loop coordinator only if it can run without a full virtualized React render. -- Unit: keep `worktree-manual-order.test.ts` and `worktree-drag-units.test.ts` passing to prove rank and drag-unit semantics did not change. -- Typecheck/lint: run `pnpm run typecheck` and `pnpm run lint`. -- Focused tests: run `pnpm test -- src/renderer/src/components/sidebar/worktree-manual-order.test.ts src/renderer/src/components/sidebar/worktree-drag-units.test.ts` plus the new autoscroll helper tests. -- Electron/manual: validate a long worktree list by dragging near the bottom until the list scrolls, dropping, then dragging near the top until it scrolls back. Also smoke native drag if it is reachable in the current app configuration. - -## UI Quality Bar - -Follow `docs/STYLEGUIDE.md` and the existing sidebar tokens. This change should not introduce new colors, shadows, controls, or copy. The only visible behavior change is smooth scrolling while dragging near the sidebar edge. The existing floating preview, insertion line, row opacity, status/pin highlights, focus ring, and sidebar scrollbar styling should remain unchanged. - -No clipping, flicker, stuck insertion line, unexpected row jump, or preview lag should appear during autoscroll. - -## Review Screenshots - -Required evidence: - -1. Sidebar precondition with enough worktrees to scroll. -2. Pointer drag held near the bottom edge after autoscroll advances the list, with preview and insertion line visible. -3. Pointer drag held near the top edge after autoscroll moves back upward, with preview and insertion line visible. -4. Final dropped order visible in the sidebar. - -Optional evidence if native drag is enabled in the tested build: - -5. Native drag autoscroll in progress or a note explaining why native drag could not be exercised. - -Do not commit evidence images. - -## Rollout - -1. Add `worktree-sidebar-drag-autoscroll.ts` and focused unit tests. -2. Add session rect refresh/revalidation and use it before drag preview/drop recomputation during active autoscroll. -3. Wire custom pointer-drag autoscroll with separate frame cleanup. -4. If the native handlers remain supported or are re-enabled, wire native drag-over autoscroll with latest-point storage and document dragend/drop cleanup, keeping `dropEffect` writes inside drag events. -5. Run typecheck, lint, focused sidebar tests, and Electron validation with screenshots. - -## Lightweight Eng Review - -- Scope: correctly limited to the worktree sidebar drag paths. The plan must not touch persistence, ordering algorithms, workspace board drawer drag, SSH, filesystem, or git-provider code. -- Architecture/data flow: renderer-only helper is appropriate. Pointer and native drag should share the scroll calculation and rect refresh/revalidation, not necessarily the same animation-frame state, because their preview/drop update paths differ. -- Failure modes: frame leaks, stale last drag-over points, redundant bound writes, stale session ids after external mutations, virtualization gaps, document-level status/pin drops, invalid `dropEffect` assumptions, and unmount cleanup are in scope. -- Tests: pure scroll helper and rect-refresh tests are required. Full virtualized pointer behavior is not a good jsdom target; use focused unit tests plus Electron/manual validation. Existing manual-order tests are regression tests only, not evidence that autoscroll works. -- Performance/blast radius: one RAF loop per active drag path is acceptable only while a drag session exists. Avoid layout thrash by reading container rect once per frame before writing `scrollTop`, and avoid React state writes when the recomputed preview is unchanged. -- UI quality: no new design surface. Verify smoothness, insertion-line accuracy, existing preview styling, and absence of row jumps against the style guide. -- Screenshot requirement: screenshots are required for review evidence but must not be committed. -- Residual risks: native drag event behavior differs across Electron/platforms, and virtualized rows may lag one frame behind programmatic scroll writes. Validate the main pointer path first and explicitly document any native limitation found during implementation. diff --git a/docs/wrapped-terminal-file-link-fragments.md b/docs/wrapped-terminal-file-link-fragments.md deleted file mode 100644 index ac1e68ec54a..00000000000 --- a/docs/wrapped-terminal-file-link-fragments.md +++ /dev/null @@ -1,115 +0,0 @@ -# Wrapped Terminal File-Link Fragments - -## Problem - -A file path hard-wrapped between terminal rows is not clickable when the continuation row also contains sibling content. In the reported three-link line, the middle path ends the first row and continues at the start of the second, while the first and third paths remain clickable. - -The provider builds hard-wrap candidates in `wrapped-terminal-link-ranges.ts:172-224`, and both hover and direct modifier-click consume them through `terminal-link-handlers.ts:106-135` and `terminal-file-link-hit-testing.ts:100-109`. - -## Root cause - -`buildHardWrappedPathLogicalLineCandidates` trims and joins whole physical rows. A continuation row is accepted only when the entire trimmed row is a path fragment (`wrapped-terminal-link-ranges.ts:195-203`). A row such as `transparent-...png · validation-screenshots/03-after-light-theme.png` therefore stops reconstruction. Orca probes the two incomplete middle fragments separately, rejects both as nonexistent, and retains only the complete first and third paths. - -## Non-goals - -- Changing file-path parsing, filesystem existence semantics, tooltip copy, or open routing. -- Joining arbitrary prose, spaced paths, or multiple sibling links into one path. -- Relying on xterm soft-wrap metadata for output that was hard-wrapped by an agent or TUI. -- Adding a new IPC method, bypassing the existing existence cache, or changing local/SSH/runtime routing. - -## Design - -1. Add a focused regression using the exact three-link/two-row shape. Make the existence stub return true only for the three complete paths, then assert that provider calls for either physical row return the same middle link and exact multi-row range. Assert that no candidate/link spans either `·` separator. -2. Reuse the existing conservative hard-wrap fragment alphabet. From a possible first row, slice its maximal fragment suffix; append zero or more continuation rows only while their whole trimmed text is a fragment; then slice the maximal fragment prefix from the first mixed-content row and stop. The only suffixes accepted without a path-name character are an exact POSIX root (`/`), one backslash for the first half of a UNC root, a bare ASCII drive prefix such as `C:`, and the complete relative prefixes `./`, `../`, and `~/`. A boundary candidate is emitted only when it covers the requested row, has at least two non-empty row fragments, and the fully joined text passes the existing path-start predicate. It may end at the first proper prefix slice of a mixed row or at the last available whole-fragment row; the latter is limited to whitelisted incomplete starts and is skipped when whole-row reconstruction already emitted the same text. Existing whole-row candidates remain responsible for ordinary/deep hard wraps, including a mixed starting row followed only by whole-fragment rows. -3. Slice `columns` with each fragment so ranges retain the original xterm cells. Build the async-staleness fingerprint from each source row's full translated text and metadata as well as the selected slice; changing a sibling token must invalidate an in-flight result even if the reconstructed path is unchanged. -4. Generate at most one boundary candidate per scanned start row—never every suffix/prefix combination. Preserve the existing bounds of 20 possible start rows and 20 rows per candidate, logical-line deduplication, and longest-non-overlapping-link selection. The existing builder can emit up to 210 whole-row candidates in its all-fragment worst case; this change may add at most 20 boundary candidates, not another quadratic set. -5. Keep existence validation in the provider's current local/SSH/runtime path and cache. The valid reconstructed path necessarily adds its desired existence lookup compared with the broken behavior; do not add probes for arbitrary suffix/prefix combinations or change existing overlap/cache behavior in this focused fix. -6. Verify direct modifier-click fallback from both halves. This path shares the candidate builder but remains synchronous and uses its existing cache/known-root preference before `openDetectedFilePath` performs normal routing checks. - -## Data flow - -- xterm buffer row under hover/click -- bounded hard-wrap start/candidate windows and conservative endpoint slicing -- whole-row candidates plus at most one boundary candidate per start row -- existing terminal file-link parser -- existing local/SSH/runtime path resolution and existence cache -- mapped multi-row xterm range -- hover tooltip or modifier-click open - -## Edge cases - -- A row boundary immediately after POSIX `/`, drive prefix `C:`, the first `\` of a UNC path, or complete `./`, `../`, and `~/` prefixes must reconstruct the complete path. Other punctuation-only suffixes and bare prose tokens remain ineligible, and the joined text must independently satisfy the full path-start predicate. -- A continuation prefix may end before a separator (`·`), prose, or a sibling path; none of that suffix may enter the reconstructed candidate. -- A starting suffix may begin after prose or a sibling path; its original xterm column must be retained. -- Rows containing only one path fragment must keep the existing deep (up to 20 rows) reconstruction behavior. -- Soft-wrapped rows, Unicode/multi-code-unit column mappings, known worktree roots, and spaced paths must remain unchanged. In particular, this change does not broaden fragment extraction to whitespace-containing paths. -- Full-row source fingerprints must reject stale async results when text inside or outside the selected fragment changes. -- Provider calls for remote paths must still use the owning pane's runtime environment or SSH connection and the connection-scoped existence-cache key; fragment extraction itself must not assume a local filesystem. -- Incomplete fragment combinations remain filtered by the existing filesystem existence check. - -## Test plan - -- Candidate/range unit: cover the exact suffix/prefix slices, original xterm columns, full-row fingerprint changes, at most one added boundary candidate per start row, and no candidate spanning a sibling separator. -- Provider integration: add the exact reported three-link regression to `terminal-link-handlers.test.ts`; across provider calls for both physical rows, assert all three complete links, the same middle range from each call, no incomplete or giant merged link, and that the complete middle path reaches the normal existence check. -- Direct click: exercise hit-testing on the first and second physical halves of the middle path and assert both route the same complete path. -- Compatibility: cover a backslash/drive-letter wrapped path and a remote-runtime or SSH existence call, proving the reconstructed path keeps the owning connection/environment. -- Regression: run `wrapped-terminal-link-ranges.test.ts`, `terminal-link-handlers.test.ts`, and terminal-link parser tests. -- Static: run formatter/check, web typecheck, lint, max-lines ratchet, and relevant repository checks. -- Electron: render the exact text at the reproduced 133-column terminal width; verify pointer/tooltip and modifier-click from both middle fragments, then smoke-test the first and third links. - -## UI quality bar - -No visual styling changes. The exact same terminal text and layout must render without overlap, clipping, or altered wrapping. The only visible behavior change is that both physical halves of the middle path show the same pointer affordance and tooltip and activate the same file, consistent with the first and third links and `docs/STYLEGUIDE.md` interaction guidance. - -## Review screenshots - -1. Before, on the base revision: full Electron window hovering the broken middle path at the reproduced width (no tooltip/link affordance). -2. After: full Electron window hovering the first physical half of the middle path, with tooltip visible. -3. After: full Electron window hovering the continuation half, with the same tooltip/path visible. -4. After adjacent-feature smoke: full Electron window hovering the first or third complete sibling link. - -## Rollout - -1. Add the failing exact-shape regression and range/click assertions. -2. Implement boundary-fragment candidate extraction and cell mapping. -3. Run focused tests and static checks. -4. Validate the exact scenario in Electron and capture screenshots. -5. Open an unmerged PR. - -## Lightweight Eng Review - -- Scope: limited to hard-wrapped path candidate reconstruction; parser, routing, cache, and UI styling stay unchanged. -- Architecture/data flow: the shared candidate builder remains the single boundary for hover and click behavior, so local, daemon, SSH, and remote runtime flows receive identical ranges before their existing existence checks. -- Failure modes covered: - - sibling links accidentally merged into one spaced path - - only one physical half hit-tests - - incorrect xterm columns after slicing - - Windows separators rejected - - deep single-fragment rows regress - - extra remote/local existence probes on hover -- Test coverage required: - - exact three-link provider regression from both hovered rows - - exact range boundary assertions - - direct click from both halves - - existing deep-wrap, Unicode, stale-result, parser, SSH/runtime tests -- Performance/blast radius: preserve the current 20-start-row/20-rows-per-candidate bounds (up to 210 existing whole-row candidates). Boundary extraction is linear per examined row and adds at most 20 candidates, only where a mixed continuation stops whole-row reconstruction. Resolving the previously missing complete path adds the intended cached existence check; the change adds no IPC method and does not alter local/remote routing or cache keys. -- UI quality bar: unchanged rendering and style; consistent pointer, tooltip, and activation across both middle fragments, checked in the real Electron terminal against the style guide. -- Required review screenshots: - 1. exact three-link full-window baseline - 2. middle first-half hover/tooltip - 3. middle continuation-half hover/tooltip - 4. first/third sibling hover smoke -- Residual risks: local macOS is the available live Electron environment; Windows separator and SSH/runtime behavior require automated coverage and shared-code review. - -## Terminal Reliability Proof - -- Reliability class: `terminal-link.path-boundary-reconstruction`; the broader manifest entry `xterm-addon.boundary-containment` is related but does not register file-link correctness, so this remains an explicit accepted manifest gap rather than changing that gate's scope in a bug fix. -- Product change type: renderer runtime hardening with deterministic regression coverage. -- Invariant: one logical hard-wrapped file path maps to the same original xterm cells and owning local/SSH/runtime context from either physical row, without absorbing sibling text. -- Failure source: the reproduced three-link line where only the first and third links were clickable. -- Oracle: both provider row calls return the same complete middle path/range; direct hit-testing on either half opens that path; root-, drive-, and UNC-boundary candidates retain their exact xterm ranges; no emitted boundary candidate contains `·`. -- Provider/platform matrix: local and SSH provider behavior covered; Windows separators/cell mapping covered; daemon and remote-runtime use the same builder and routing but are not live-tested; Linux, Windows, WSL, mobile/relay are accepted live-validation gaps. -- Performance budget: the scan stays capped at 20 starts and 20 rows per candidate, emits at most one boundary candidate per start, and rejects non-path starts before reading possible continuation rows. No polling, timers, listeners, subprocesses, or new IPC methods are added; only the newly valid path reaches the existing cached existence probe. -- Diagnostics: the full source-row fingerprint rejects stale async results; deterministic range/provider tests are the regression breadcrumb. No new product telemetry or raw terminal logging is warranted. -- Gate status: no manifest entry added or promoted. Revisit only if this parser grows beyond bounded local row reconstruction or the regression recurs outside the covered provider/platform matrix. -- Rollback/demotion rule: revert boundary-fragment reconstruction if Electron shows sibling-path merging, incorrect hit regions, or material hover latency; keep the exact red regression as the behavioral oracle. diff --git a/docs/wsl-osc7-sleep-wake-cwd.md b/docs/wsl-osc7-sleep-wake-cwd.md deleted file mode 100644 index 24d46b70660..00000000000 --- a/docs/wsl-osc7-sleep-wake-cwd.md +++ /dev/null @@ -1,153 +0,0 @@ -# WSL OSC 7 Sleep/Wake CWD - -## Problem - -On native Windows, a WSL terminal can persist a fake UNC current working directory and then fail to wake. The deterministic trigger is a WSL shell emitting `file:///home/...` before sleep: - -- `osc7-file-uri.ts` treats every non-local authority as a UNC server when parsing Win32 paths. -- `Session` constructs its daemon `HeadlessEmulator` without the WSL execution context, so checkpoints can store `\\\home\...`. -- `OrcaRuntimeService` likewise gives every local Windows PTY Win32 OSC 7 semantics; `remotePosixAuthority` only protects POSIX SSH PTYs. -- `HistoryReader.restoreFromIncrementalLog` creates a third context-free emulator. A log containing the OSC 7 can therefore re-create the bad CWD even if the base checkpoint is valid. -- `DaemonPtyAdapter.doSpawn` prefers `restoreInfo.cwd` over the requested worktree CWD. `pty-subprocess.ts` later validates the fake UNC and rejects it before spawning WSL. - -Reproduction on Windows 11 build 26200, WSL 2.3.26.0, Ubuntu 24.04.1 LTS, and Orca 1.4.144-rc.4: - -1. Open a terminal in `\\wsl.localhost\Ubuntu\home\\...`. -2. Emit `OSC 7;file:///home//...`. -3. Sleep for 38-69 seconds, then wake. - -Both triggered attempts failed. The saved CWD became `\\\home\\...`; the original WSL UNC remained valid. A 60-second control sleep without the hostname OSC 7 passed. - -## Root cause and invariant - -OSC 7 authority semantics follow the PTY's execution environment, not Electron's host OS. A native Windows shell may legitimately mean `\\server\share` by `file://server/share`; a WSL shell means a POSIX pathname inside its already-known distro. Orca currently classifies local WSL PTYs as generic Win32 PTYs. - -The fix must establish one immutable `wslDistro: string | null` per PTY incarnation before any OSC 7 bytes or recovered history are parsed. The same value must drive daemon live parsing, incremental-history replay, runtime parsing, and legacy CWD recovery. URI authority must never select a distro. - -Resolve that value with the same precedence already used to launch WSL: - -1. distro in an explicit WSL UNC `cwd`; -2. distro in the worktree encoded by the daemon session ID; -3. trimmed `terminalWindowsWslDistro` selected for the spawn. - -Centralize this resolution with the existing WSL session-context code so the parser and subprocess cannot disagree. Do not infer local WSL context for `connectionId`/SSH PTYs. - -## Non-goals - -- Do not change native Windows UNC, POSIX SSH, or remote Windows drive-path semantics. -- Do not redesign terminal history, sleep, renderer link routing, or PTY identity. -- Do not probe WSL, DNS, the filesystem, or the network on the output path. -- Do not infer a distro from an OSC hostname or mutable global/default-distro state. - -## Design - -### 1. Pure conversion and parser context - -Move `toWindowsWslPath` to `src/shared/wsl-paths.ts` and re-export it from `src/main/wsl.ts` for existing callers. Preserve its current rules: lowercase `/mnt/` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\`. `/MNT` and `/mnt/C` are case-sensitive Linux paths, not drvfs aliases. - -Add `wslDistro?: string` to `ParseFileUriPathOptions`. When present, `parseFileUriPathParts` must: - -- decode `url.pathname` exactly once; -- construct the path with `toWindowsWslPath(decodedPath, wslDistro)`, regardless of URI authority; -- continue returning the normalized URI hostname as metadata; -- reject malformed URLs/percent encoding as today and leave the prior CWD unchanged. - -Without `wslDistro`, retain every existing `pathFlavor` and `remotePosixAuthority` branch. - -Thread the option through `TerminalOscCwdTitleScanner` and `HeadlessEmulator`. Scanner options are constructor-scoped, so split sequences retain the same context without global state. - -### 2. Daemon and history ownership - -`TerminalHost` resolves the immutable distro before spawning. Pass it both to `createPtySubprocess` and to a new `SessionOptions.wslDistro`; `Session` passes it to its `HeadlessEmulator`. Store the resolved value on `Session` and return it on create/attach so a later window observes the live session's context instead of reinterpreting it from current settings. Make the response field additive/optional for compatibility with an older preserved daemon. - -Extend `HistoryReader.detectColdRestore` with optional parser context and use it for the scratch `HeadlessEmulator` in `restoreFromIncrementalLog`. Every detect path in `DaemonPtyAdapter`--initial detection, probe/create race recovery, and failed history seeding--must pass the same resolved distro. This is required; fixing only the live `Session` leaves incremental restore able to reproduce the bug. - -### 3. Narrow legacy checkpoint recovery - -Immediately after each cold-restore detection, normalize `restoreInfo.cwd` before it becomes `effectiveCwd` or `coldRestore.cwd`. Recovery runs only on native Windows with a resolved local WSL distro: - -1. Preserve an absolute drive path. -2. Preserve a `\\wsl.localhost\...` or `\\wsl$\...` path only when its distro matches the resolved distro case-insensitively. -3. Convert an absolute POSIX path through the resolved distro. -4. Repair the known legacy shape only when the UNC server equals `os.hostname()` case-insensitively: strip the server, interpret the share and tail as the Linux absolute path, then convert it. Thus `\\HOST\mnt\c\x` becomes `C:\x`. -5. For a mismatched-distro WSL UNC, another UNC server, a relative path, or malformed input, discard the recovered CWD and fall back to the current requested CWD (or the normal spawn default if none was supplied). Do not guess. - -Apply the corrected/fallback value to both subprocess creation and the returned cold-restore payload. This keeps runtime seeding, the sticky restore cache, `initialCwds`, and the next checkpoint consistent. The repair is idempotent and never mutates history files in place. - -### 4. Runtime ownership and races - -Add `wslDistro: string | null` to `RuntimePtyWorktreeRecord`; a boolean `isWsl` is insufficient for multi-distro parsing. Pass the resolved distro from the spawn result into runtime registration. For reconstructed local records, a WSL UNC worktree may supply a fallback distro via `parseWslUncPath`; never do this for SSH records. - -Daemon PTYs can emit output before `provider.spawn` resolves, and cold-restore seeding currently runs before `registerPty`. Register the expected daemon session's execution context before spawn, then replace it with the daemon-returned immutable value before snapshot/cold-restore seeding. If late discovery changes a context after a runtime emulator was created, discard that emulator and re-seed it from the authoritative provider snapshot; never keep a buffer whose CWD was parsed under mixed contexts. - -Clear execution context with the other per-PTY parser maps on exit, pruning, failed spawn, and provider-generation reset. Reusing a PTY ID must not inherit a prior distro. - -## Data flow - -```text -spawn cwd/session/preference - -> resolve one immutable local WSL distro - -> daemon Session scanner + HistoryReader replay scanner - -> decoded OSC 7 pathname (authority retained only as metadata) - -> toWindowsWslPath(pathname, distro) - -> daemon checkpoint + runtime live/headless CWD - -> sleep/wake with a valid WSL UNC or drive CWD -``` - -Legacy recovery is a boundary repair: - -```text -WSL cold restore + old checkpoint CWD - -> exact allowlist normalization - -> corrected spawn and coldRestore payload - -> next checkpoint naturally persists the corrected live CWD -``` - -## Consistency and failure modes - -- Context is per PTY incarnation, not global, so simultaneous Ubuntu and Debian panes cannot contaminate each other. -- The first creator owns a daemon session's immutable context. Concurrent/multi-window attaches consume the stored value; changed settings do not mutate a live session. -- A distro mismatch in externally changed history falls back to the requested worktree CWD instead of launching the wrong distro. -- Atomic checkpoint replacement remains unchanged. Concurrent restore callers may read different complete generations, but each normalizes before spawn and daemon create/attach still selects one live session. -- Missing context deliberately preserves current behavior. Tests must cover every local WSL construction path so this fallback cannot silently remain on the affected path. -- Native UNC and SSH behavior remain isolated because neither receives local WSL context. -- No filesystem/network work is added per output chunk; parsing remains bounded string work on completed OSC sequences. - -## Test plan - -- `osc7-file-uri.test.ts` and shared WSL-path tests: hostname/localhost/empty WSL authorities; hostname metadata; `/home`, `/`, lowercase `/mnt/c`, `/MNT`, `/mnt/C`; spaces/percent decoding; invalid encoding; native UNC; POSIX SSH; Windows SSH drive paths. -- `headless-emulator.test.ts`: WSL context propagation across ordinary and split OSC sequences; two emulators with different distros. -- `terminal-host.test.ts`/`session.test.ts`: resolved context reaches the daemon emulator; attach returns the session's stored context and does not adopt a conflicting later preference. -- `history-reader.test.ts`: incremental-log replay containing the exact hostname OSC 7 yields the correct WSL CWD. -- `daemon-pty-adapter.test.ts`: cover every `detectColdRestore` branch plus sticky-cache output. Repair hostname UNC and POSIX CWDs; preserve matching WSL UNC and drive paths; reject mismatched distro, other UNC, relative, native, and SSH/non-WSL cases. Assert both create/attach CWD and `coldRestore.cwd`. -- `orca-runtime.test.ts` and IPC PTY tests: context exists before early daemon output and before headless seeding; Windows-host worktree with a selected WSL distro; WSL UNC fallback after reconstruction; attach correction; PTY-ID reuse; simultaneous distros; local native and SSH isolation. -- Run focused tests, then `pnpm typecheck` and `pnpm lint`. -- Electron on native Windows: in a real Ubuntu WSL pane, emit the exact hostname OSC 7, sleep/wake twice, run `pwd` after each wake, and verify no `DaemonProtocolError`. Native UNC semantics are covered by deterministic unit tests; a screenshot of an arbitrary UNC string is not meaningful validation. - -## UI quality and review evidence - -There is no UI, layout, copy, or interaction change, so the Stage 5 visual-quality loop is skipped. Terminal content, CWD, and adjacent native/SSH behavior must remain unchanged. - -Electron validation still requires three evidence screenshots for user review: - -1. Before sleep: the WSL terminal shows the printed hostname/emission command and `pwd`. -2. After the first wake: the same pane shows the same `pwd` and no wake error. -3. After the second wake: the same pane again shows the same `pwd` and no wake error. - -## Lightweight engineering review - -- **Scope:** Parser context, immutable context propagation, history replay, and narrow legacy recovery. Runtime/IPC fields are necessary because parsing begins outside the daemon and may precede spawn completion. -- **Architecture/data flow:** One resolved distro drives every parser for one PTY incarnation. The URI hostname remains metadata; it never selects path namespace or distro. -- **Failure modes:** Covers missing/stale context, incremental replay, probe/create races, sticky restore, mismatched external history, split chunks, concurrent attaches, multi-window reuse, multi-distro isolation, PTY-ID reuse, and SSH/native boundaries. -- **Tests:** Requires parser tables, propagation tests at every emulator construction site, all adapter restore branches, runtime early-byte/seed races, and the deterministic Windows Electron reproduction twice. -- **Performance/blast radius:** One optional string per PTY/session and bounded conversion per OSC 7; no probes or new hot-path I/O. Non-WSL behavior is unchanged when the option is absent. -- **UI/screenshots:** No design-review loop. Three Electron screenshots are required as functional evidence; native UNC stays an automated regression test. -- **Residual risk:** Legacy checkpoints created from a non-machine hostname cannot be distinguished safely from a real UNC and therefore fall back to the requested CWD. A renamed/uninstalled distro still fails normally; this change does not guess a replacement. - -## Rollout - -1. Centralize distro resolution and WSL path conversion. -2. Make the parser, daemon Session, and history replay distro-aware. -3. Add allowlisted legacy recovery and keep spawn/cold-restore metadata consistent. -4. Make runtime context available before output/seeding and reset it per incarnation. -5. Add regressions, run static checks, then validate two Windows sleep/wake cycles with review screenshots. From 340faaa839921c85848a2dc1f27240ebbf8dc826 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:40:47 -0700 Subject: [PATCH 10/22] fix(workspaces): use the emojibase shortcode preset for emoji suggestions (#11888) Swap the worktree-name emoji picker from emojibase-data's `github` shortcode preset to `emojibase`, which carries both `flag_kr` and `south_korea` style flag names, and drop the hand-maintained `kr` entry that patched around the gap. Filter skin-tone aliases so they neither crowd the suggestion list nor clobber base-emoji branch names. Search now matches anywhere in the shortcode, ranked exact > prefix > word-start > substring, so `:korea` surfaces both Koreas. Emoji-derived branch names now prefer spelled-out aliases: flags use country names (japan, germany, south-korea) and cryptic stubs are skipped (thumbsdown over no, victory over v). --- src/main/ipc/worktree-logic.test.ts | 6 ++- .../lib/workspace-emoji-shortcodes.test.ts | 31 ++++++++++++-- .../src/lib/workspace-emoji-shortcodes.ts | 34 ++++++++------- src/shared/emoji-shortcode-catalog.ts | 42 ++++++++++++++----- tests/e2e/worktree.spec.ts | 8 ++-- 5 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 5a29189c9b8..ae76b28da0c 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -77,7 +77,8 @@ describe('sanitizeWorktreeName', () => { it('uses readable git-safe shortcodes for known emoji', () => { expect(sanitizeWorktreeName('🚀')).toBe('rocket') expect(sanitizeWorktreeName('👩‍💻✨')).toBe('woman-technologist-sparkles') - expect(sanitizeWorktreeName('🇯🇵')).toBe('jp') + expect(sanitizeWorktreeName('🇯🇵')).toBe('japan') + expect(sanitizeWorktreeName('👎')).toBe('thumbsdown') expect(sanitizeWorktreeName('1️⃣')).toBe('one') }) @@ -86,7 +87,8 @@ describe('sanitizeWorktreeName', () => { }) it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => { - expect(sanitizeWorktreeName('\u{1fae9}')).toBe('workspace') + // Unassigned in Unicode 17, so no emojibase shortcode can cover it yet. + expect(sanitizeWorktreeName('\u{1faeb}')).toBe('workspace') }) it('does not treat arbitrary punctuation as a workspace name', () => { diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts index 83663794307..0f67df13987 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts @@ -11,8 +11,14 @@ describe('workspace emoji shortcodes', () => { expect(searchWorkspaceEmojiShortcodes('wink', 1)).toEqual([{ emoji: '😉', shortcode: 'wink' }]) }) - it('finds the Korean flag by kr shortcode', () => { - expect(searchWorkspaceEmojiShortcodes('kr', 1)).toEqual([{ emoji: '🇰🇷', shortcode: 'kr' }]) + it('finds flags by country name and by ISO code fragment', () => { + expect(searchWorkspaceEmojiShortcodes('south_korea', 1)).toEqual([ + { emoji: '🇰🇷', shortcode: 'south_korea' } + ]) + expect(searchWorkspaceEmojiShortcodes('kr', 1)).toEqual([{ emoji: '🇰🇷', shortcode: 'flag_kr' }]) + expect(searchWorkspaceEmojiShortcodes('germany', 1)).toEqual([ + { emoji: '🇩🇪', shortcode: 'germany' } + ]) }) it('ranks an exact shortcode before longer aliases', () => { @@ -21,6 +27,23 @@ describe('workspace emoji shortcodes', () => { }) }) + it('matches inside a shortcode, ranking word starts above incidental substrings', () => { + expect(searchWorkspaceEmojiShortcodes('korea', 2)).toEqual([ + { emoji: '🇰🇵', shortcode: 'north_korea' }, + { emoji: '🇰🇷', shortcode: 'south_korea' } + ]) + expect( + searchWorkspaceEmojiShortcodes('kr', 3).map((suggestion) => suggestion.shortcode) + ).toEqual(['flag_kr', 'ukraine', 'cockroach']) + }) + + it('omits skin-tone aliases from suggestions', () => { + expect(searchWorkspaceEmojiShortcodes('wave')).toEqual([ + { emoji: '👋', shortcode: 'wave' }, + { emoji: '🌊', shortcode: 'water_wave' } + ]) + }) + it('deduplicates aliases that resolve to the same emoji', () => { const suggestions = searchWorkspaceEmojiShortcodes('wink') expect(new Set(suggestions.map((suggestion) => suggestion.emoji)).size).toBe(suggestions.length) @@ -43,8 +66,8 @@ describe('workspace emoji shortcodes', () => { }) }) - it('replaces the completed kr shortcode', () => { - expect(replaceCompletedWorkspaceEmojiShortcode(':kr:', 4)).toEqual({ + it('replaces a completed flag shortcode', () => { + expect(replaceCompletedWorkspaceEmojiShortcode(':flag_kr:', 9)).toEqual({ value: '🇰🇷', cursor: 4 }) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index a75da33d2c1..3a5efc7e8d1 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -16,19 +16,24 @@ export type WorkspaceEmojiReplacement = { value: string } -const WORKSPACE_EMOJI_SHORTCODE_ADDITIONS: readonly WorkspaceEmojiSuggestion[] = [ - { emoji: '🇰🇷', shortcode: 'kr' } -] - -const SHORTCODE_ENTRIES = [ - ...STANDARD_EMOJI_SHORTCODE_ENTRIES, - ...WORKSPACE_EMOJI_SHORTCODE_ADDITIONS -] - const EXACT_SHORTCODE = new Map( - SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) + STANDARD_EMOJI_SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) ) +// Lower tiers rank first, so `korea` surfaces `south_korea` above `dishwasher`-style incidental hits. +const MATCH_TIER = { exact: 0, prefix: 1, wordStart: 2, substring: 3 } as const + +function matchTier(shortcode: string, query: string): number | null { + const index = shortcode.indexOf(query) + if (index < 0) { + return null + } + if (index === 0) { + return shortcode.length === query.length ? MATCH_TIER.exact : MATCH_TIER.prefix + } + return /[_-]/.test(shortcode[index - 1]) ? MATCH_TIER.wordStart : MATCH_TIER.substring +} + export function searchWorkspaceEmojiShortcodes( query: string, limit = 8 @@ -38,11 +43,12 @@ export function searchWorkspaceEmojiShortcodes( return [] } - const matches = SHORTCODE_ENTRIES.filter(({ shortcode }) => - shortcode.startsWith(normalizedQuery) - ).sort( + const matches = STANDARD_EMOJI_SHORTCODE_ENTRIES.flatMap((entry) => { + const tier = matchTier(entry.shortcode, normalizedQuery) + return tier === null ? [] : [{ ...entry, tier }] + }).sort( (left, right) => - Number(right.shortcode === normalizedQuery) - Number(left.shortcode === normalizedQuery) || + left.tier - right.tier || left.shortcode.length - right.shortcode.length || left.shortcode.localeCompare(right.shortcode) ) diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts index fba621b7a9c..63477e2069a 100644 --- a/src/shared/emoji-shortcode-catalog.ts +++ b/src/shared/emoji-shortcode-catalog.ts @@ -1,25 +1,45 @@ -import emojiShortcodes from 'emojibase-data/en/shortcodes/github.json' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' export type StandardEmojiShortcodeEntry = { emoji: string shortcode: string } +// Skin-tone aliases (`wave_tone3`) are ~40% of the dataset and would drown the suggestion list. +const SKIN_TONE_SHORTCODE = /_tone\d(?:-\d)?$/ + +const CATALOG = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + const shortcodes = (typeof value === 'string' ? [value] : value).filter( + (shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode) + ) + return shortcodes.length > 0 ? [{ emoji: hexcodeToEmoji(hexcode), shortcodes }] : [] +}) + export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] = - Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { - const shortcodes = typeof value === 'string' ? [value] : value - const emoji = hexcodeToEmoji(hexcode) - return shortcodes.map((shortcode) => ({ emoji, shortcode })) - }) + CATALOG.flatMap(({ emoji, shortcodes }) => shortcodes.map((shortcode) => ({ emoji, shortcode }))) const PRIMARY_SHORTCODE_BY_EMOJI = new Map( - Object.entries(emojiShortcodes).map(([hexcode, value]) => { - const shortcodes = typeof value === 'string' ? [value] : value - const shortcode = shortcodes.find((candidate) => /^[a-z]/i.test(candidate)) ?? shortcodes[0] - return [normalizeEmojiLookup(hexcodeToEmoji(hexcode)), shortcode] - }) + CATALOG.map(({ emoji, shortcodes }) => [ + normalizeEmojiLookup(emoji), + primaryShortcode(shortcodes) + ]) ) +/** + * Pick the alias that reads best as a branch or directory name: skip `+1`/`-1` so the name + * starts with a letter, then cryptic stubs (👎 `no`, ✌ `v`) and the `flag_xx` namespacing + * prefix, both of which have a spelled-out alias (`thumbsdown`, `victory`, `germany`). + */ +function primaryShortcode(shortcodes: readonly string[]): string { + const named = shortcodes.filter((candidate) => /^[a-z]/i.test(candidate)) + return ( + named.find((candidate) => candidate.length >= 3 && !candidate.startsWith('flag_')) ?? + named.find((candidate) => candidate.length >= 3) ?? + named[0] ?? + shortcodes[0] + ) +} + const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' }) export function replaceKnownEmojiWithShortcodes(input: string): string { diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 6c2969f53ad..37c9cc9b159 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -190,7 +190,7 @@ test.describe('Create Workspace', () => { } }) - test('enters the Korean flag with the kr shortcode suggestion', async ({ orcaPage }) => { + test('enters the Korean flag with the flag_kr shortcode suggestion', async ({ orcaPage }) => { try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() @@ -198,11 +198,11 @@ test.describe('Create Workspace', () => { const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() - await nameInput.pressSequentially('Launch :kr', { delay: 100 }) + await nameInput.pressSequentially('Launch :flag_kr', { delay: 100 }) const emojiSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') const sourceSuggestions = orcaPage.locator('[data-workspace-source-suggestions="true"]') await expect(emojiSuggestions).toBeVisible() - await expect(emojiSuggestions.getByRole('option', { name: ':kr:' })).toBeVisible() + await expect(emojiSuggestions.getByRole('option', { name: ':flag_kr:' })).toBeVisible() await expect(emojiSuggestions).toHaveAttribute('data-side', 'top') await expect(sourceSuggestions).toBeVisible() await expect(sourceSuggestions).toHaveAttribute('data-side', 'bottom') @@ -211,7 +211,7 @@ test.describe('Create Workspace', () => { await nameInput.pressSequentially(':') await expect(nameInput).toHaveValue('Launch 🇰🇷') - await expect(orcaPage.getByRole('option', { name: /:kr:/i })).toHaveCount(0) + await expect(orcaPage.getByRole('option', { name: /:flag_kr:/i })).toHaveCount(0) await nameInput.pressSequentially(' experiment') await expect(nameInput).toHaveValue('Launch 🇰🇷 experiment') // Keep the asserted result visible in retained proof recordings. From c2e3d13efe84c067b3efb1fc8a6b33eefdfecb2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:40:55 -0700 Subject: [PATCH 11/22] fix(mobile): focus Kimi terminal input after touch (#11865) * fix(mobile): focus terminal input after TUI touch * fix(mobile): defer terminal focus after WebView taps * fix(mobile): reset deferred terminal focus on route blur --- .../app/h/[hostId]/session/[worktreeId].tsx | 39 ++- .../terminal-live-input-affordance.test.ts | 16 +- ...minal-webview-mouse-click-drag-injected.ts | 5 +- .../terminal-webview-mouse-click.test.ts | 1 + .../terminal-webview-scroll-routing.test.ts | 9 +- .../terminal-webview-tap-dispatch-injected.ts | 2 +- .../terminal-webview-tap-routing.test.ts | 34 ++- .../src/terminal/terminal-webview-url-tap.ts | 6 +- .../use-terminal-live-input-focus.test.ts | 252 ++++++++++++++++++ .../terminal/use-terminal-live-input-focus.ts | 86 ++++++ 10 files changed, 413 insertions(+), 37 deletions(-) create mode 100644 mobile/src/terminal/use-terminal-live-input-focus.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-focus.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4ec0e564a79..5d9ba390035 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -117,10 +117,10 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, - focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus' import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' @@ -1082,6 +1082,22 @@ export default function SessionScreen() { activeSessionTabType: activeSessionTab?.type }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false + const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef: liveInputRef, + keyboardHeight, + lifecycleIdentity: client, + lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), + liveInputEnabled, + timerRef: liveInputFocusTimerRef + }) + useFocusEffect( + useCallback(() => { + // Expo retains this route while pushed screens are visible. + return resetLiveInputFocus + }, [resetLiveInputFocus]) + ) const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( @@ -3092,17 +3108,6 @@ export default function SessionScreen() { ) sendLiveTerminalInputRef.current = sendLiveTerminalInput - const focusLiveInput = useCallback(() => { - if (!canSend || !liveInputEnabled) { - return - } - focusTerminalLiveInputTarget(liveInputRef.current, { - keyboardHeight, - refocus: () => - scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) - }) - }, [canSend, keyboardHeight, liveInputEnabled]) - const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() sessionTabActionSheetKeyboardHideSubRef.current = null @@ -3177,16 +3182,6 @@ export default function SessionScreen() { }) }, []) - const handleTerminalTap = useCallback( - (handle: string) => { - if (handle !== activeHandleRef.current) { - return - } - focusLiveInput() - }, - [focusLiveInput] - ) - // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss. const handleFileTapActivationSeqRef = useRef(0) const handleFileTap = useCallback( diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 1ceab18ab60..cc681e2056e 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -13,6 +13,10 @@ const commandInputStylesSource = readFileSync( new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), 'utf8' ) +const liveInputFocusSource = readFileSync( + new URL('./use-terminal-live-input-focus.ts', import.meta.url), + 'utf8' +) function liveInputBarBlock(): string { const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') @@ -35,9 +39,15 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') - expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current') - expect(sessionRouteSource).toContain('keyboardHeight') - expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef') + expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') + expect(sessionRouteSource).toContain('return resetLiveInputFocus') + expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') + expect(liveInputFocusSource).toContain('lifecycleIdentity,') + expect(liveInputFocusSource).toContain('resetLiveInputFocus') + expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight') + expect(liveInputFocusSource).toContain( + 'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)' + ) }) it('makes the live keyboard target visible instead of status-only chrome', () => { diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts index bc9c12aa016..54ee8d430a4 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -179,9 +179,8 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` // Why: a dismissing tap only clears the selection (touch parity); it must // not also open a link or focus the keyboard underneath. if (gesture.dismissedSelection) return; - // Plain click == touch tap: links and file paths win, then tracking-mode - // click reports, then keyboard focus. - notifyTerminalSurfaceTap(e.clientX, e.clientY); + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false); }, true); targetSurface.addEventListener('pointercancel', function(e) { diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts index bb2494b62e7..4c519deeef1 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -18,6 +18,7 @@ describe('terminal WebView external mouse click', () => { expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) }) it('dismisses an existing selection with a click without focusing the keyboard', () => { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 391ccd3a840..32bc6dbd529 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -212,10 +212,12 @@ describe('TerminalWebView scroll routing', () => { "document.addEventListener('touchend'", '}, { capture: true, passive: true });' ) - expect(touchEndBlock).toContain('notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y)') + expect(touchEndBlock).toContain( + 'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)' + ) const tapHandlerBlock = sliceBetween( - 'function notifyTerminalSurfaceTap(originX, originY)', + 'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)', "document.addEventListener('touchstart'" ) expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan( @@ -229,6 +231,9 @@ describe('TerminalWebView scroll routing', () => { ) expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });") expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(tapHandlerBlock).toContain( + 'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))' + ) expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });") }) diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts index ebf36ee1ba4..23036f84dfd 100644 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts @@ -163,7 +163,7 @@ export const TERMINAL_TAP_DISPATCH_JS = ` selMode !== 'select' && Date.now() - tapCandidate.t <= TAP_MAX_MS ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y); + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); } clearLongPress(); tapCandidate = null; diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index 2ab0388068d..13a81ef61a1 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -19,12 +19,12 @@ function bodyMarkup(): string { } // Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells. -function makeTerminal(lineRef: { current: string }) { +function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') { return { cols: 80, rows: 24, options: { fontSize: 13 }, - modes: {}, + modes: { mouseTrackingMode }, element: { scrollWidth: 800, scrollHeight: 360 }, _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }, buffer: { @@ -76,13 +76,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string function boot( line: string, - oscLinks?: OscLinkRange[] + oscLinks?: OscLinkRange[], + mouseTrackingMode = 'none' ): { posted: Posted; setLine: (line: string) => void } { const posted: Posted = [] const lineRef = { current: line } const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } w.Terminal = function () { - return makeTerminal(lineRef) + return makeTerminal(lineRef, mouseTrackingMode) } w.ReactNativeWebView = { postMessage(s: string) { @@ -150,6 +151,31 @@ describe('terminal WebView tap routing', () => { expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') }) + it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { + const { posted } = boot('interactive prompt', undefined, 'drag') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect( + posted + .filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap') + .map((message) => message.type) + ).toEqual(['terminal-input', 'terminal-tap']) + }) + + it('reports a non-mouse touch tap without terminal mouse bytes', async () => { + const { posted } = boot('plain prompt') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() + expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + }) + it('opens the URL even right after a width-change reflow', async () => { // Why: scrollback reflow rewraps the local buffer; the tap path must keep // working afterward (the reflow message must not disturb tap routing). diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index 86f87a44198..f5d416d5797 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -205,7 +205,7 @@ export const URL_TAP_WEBVIEW_JS = ` } catch (e) { return 0; } } - function notifyTerminalSurfaceTap(originX, originY) { + function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { var tappedOscLink = oscLinkAtViewportPoint(originX, originY); if (tappedOscLink && tappedOscLink.kind === 'file') { notify({ @@ -245,7 +245,9 @@ export const URL_TAP_WEBVIEW_JS = ` var clickInput = buildMouseClickInput(originX, originY); if (clickInput) { notify({ type: 'terminal-input', bytes: clickInput }); - } else if (!isClickMouseTrackingMode(getMouseTrackingMode())) { + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { notify({ type: 'terminal-tap' }); } } diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts new file mode 100644 index 00000000000..f1b6c5eadb9 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -0,0 +1,252 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalLiveInputFocusTarget, + TerminalLiveInputFocusTimerRef +} from './terminal-live-input' +import { useTerminalLiveInputFocus } from './use-terminal-live-input-focus' + +type HarnessProps = { + readonly activeHandleRef: RefObject + readonly canSend: boolean + readonly inputRef: RefObject + readonly keyboardHeight?: number + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly liveInputEnabled: boolean + readonly timerRef: TerminalLiveInputFocusTimerRef +} + +type FocusHandlers = ReturnType + +function suppressReactTestRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & { + readonly blur: ReturnType + readonly focus: ReturnType +} { + let focused = initiallyFocused + return { + blur: vi.fn(() => { + focused = false + }), + focus: vi.fn(() => { + focused = true + }), + isFocused: () => focused + } +} + +function createTimerRef(): TerminalLiveInputFocusTimerRef { + return { current: null } +} + +function createHarness(initialProps: HarnessProps): { + readonly handlers: () => FocusHandlers + readonly render: (props: HarnessProps) => void + readonly unmount: () => void +} { + let handlers: FocusHandlers | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(props: HarnessProps): null { + handlers = useTerminalLiveInputFocus({ + ...props, + keyboardHeight: props.keyboardHeight ?? 0 + }) + return null + } + + const restoreWarning = suppressReactTestRendererWarning() + try { + act(() => { + renderer = create(createElement(Harness, initialProps)) + }) + } finally { + restoreWarning() + } + if (!handlers || !renderer) { + throw new Error('terminal live input focus harness did not render') + } + + return { + handlers: () => { + if (!handlers) { + throw new Error('terminal live input focus harness is not mounted') + } + return handlers + }, + render: (props) => { + act(() => renderer?.update(createElement(Harness, props))) + }, + unmount: () => { + act(() => renderer?.unmount()) + handlers = null + } + } +} + +function connectedProps( + inputRef: RefObject, + timerRef = createTimerRef(), + activeHandleRef: RefObject = { current: 'terminal-a' } +): HarnessProps { + return { + activeHandleRef, + canSend: true, + inputRef, + lifecycleIdentity: null, + lifecycleKey: 'host-a:worktree-a:connected', + liveInputEnabled: true, + timerRef + } +} + +describe('terminal live input focus hook', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('defers initial terminal surface focus until the WebView touch has completed', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + expect(input.focus).not.toHaveBeenCalled() + + vi.runOnlyPendingTimers() + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('reacquires focus for mouse-aware and non-mouse terminal tap notifications', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + harness.handlers().handleTerminalTap('terminal-a') + vi.runAllTimers() + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).toHaveBeenCalledTimes(2) + harness.unmount() + }) + + it('cancels focus when navigation reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const inputRef: RefObject = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(inputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + const newInput = createFocusTarget() + inputRef.current = newInput + harness.render({ + ...connectedProps(inputRef, timerRef), + lifecycleKey: 'host-a:worktree-b:connected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + expect(newInput.focus).not.toHaveBeenCalled() + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(newInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('drops pending focus when reconnect reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const oldInputRef = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(oldInputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + harness.render({ + ...connectedProps(oldInputRef, timerRef), + canSend: false, + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:disconnected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + + const replacementInput = createFocusTarget() + oldInputRef.current = replacementInput + harness.render({ + ...connectedProps(oldInputRef, timerRef), + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:connected' + }) + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('cancels focus when a retained screen blurs', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const timerRef = createTimerRef() + const harness = createHarness(connectedProps({ current: input }, timerRef)) + + harness.handlers().handleTerminalTap('terminal-a') + harness.handlers().resetLiveInputFocus() + vi.runOnlyPendingTimers() + + expect(timerRef.current).toBeNull() + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + harness.unmount() + }) + + it('does not let stale handle state focus a replacement terminal', () => { + vi.useFakeTimers() + const firstInput = createFocusTarget() + const inputRef: RefObject = { current: firstInput } + const timerRef = createTimerRef() + const activeHandleRef = { current: 'terminal-a' as string | null } + const harness = createHarness(connectedProps(inputRef, timerRef, activeHandleRef)) + harness.handlers().handleTerminalTap('terminal-a') + + const replacementInput = createFocusTarget() + inputRef.current = replacementInput + activeHandleRef.current = 'terminal-b' + harness.render(connectedProps(inputRef, timerRef, activeHandleRef)) + vi.runOnlyPendingTimers() + + expect(firstInput.focus).not.toHaveBeenCalled() + expect(replacementInput.focus).not.toHaveBeenCalled() + harness.handlers().handleTerminalTap('terminal-b') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('keeps the native focus target immediate outside the WebView tap path', () => { + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().focusLiveInput() + + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts new file mode 100644 index 00000000000..b0ab456b721 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -0,0 +1,86 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' +import { + clearTerminalLiveInputFocusTimer, + focusTerminalLiveInputTarget, + scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, + type TerminalLiveInputFocusTimerRef +} from './terminal-live-input' + +type TerminalLiveInputFocusContext = { + readonly canSend: boolean + readonly keyboardHeight: number + readonly liveInputEnabled: boolean +} + +type UseTerminalLiveInputFocusOptions = + TerminalLiveInputFocusContext & { + readonly activeHandleRef: RefObject + readonly inputRef: RefObject + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly timerRef: TerminalLiveInputFocusTimerRef + } + +type TerminalLiveInputFocusHandlers = { + readonly focusLiveInput: () => void + readonly handleTerminalTap: (handle: string) => void + readonly resetLiveInputFocus: () => void +} + +export function useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef, + keyboardHeight, + lifecycleIdentity, + lifecycleKey, + liveInputEnabled, + timerRef +}: UseTerminalLiveInputFocusOptions): TerminalLiveInputFocusHandlers { + const contextRef = useRef({ + canSend, + keyboardHeight, + liveInputEnabled + }) + useLayoutEffect(() => { + contextRef.current = { canSend, keyboardHeight, liveInputEnabled } + }, [canSend, keyboardHeight, liveInputEnabled]) + + const resetLiveInputFocus = useCallback(() => { + clearTerminalLiveInputFocusTimer(timerRef) + inputRef.current?.blur() + }, [inputRef, timerRef]) + + // Retained Expo routes must not carry focus work across navigation or reconnect scopes. + useLayoutEffect(() => resetLiveInputFocus, [lifecycleIdentity, lifecycleKey, resetLiveInputFocus]) + + const focusLiveInput = useCallback(() => { + const context = contextRef.current + if (!context.canSend || !context.liveInputEnabled) { + return + } + focusTerminalLiveInputTarget(inputRef.current, { + keyboardHeight: context.keyboardHeight, + refocus: () => scheduleTerminalLiveInputFocus(timerRef, focusLiveInput) + }) + }, [inputRef, timerRef]) + + const handleTerminalTap = useCallback( + (handle: string) => { + const context = contextRef.current + if (handle !== activeHandleRef.current || !context.canSend || !context.liveInputEnabled) { + return + } + // WKWebView still owns first responder during its touchend notification. + scheduleTerminalLiveInputFocus(timerRef, () => { + if (activeHandleRef.current === handle) { + focusLiveInput() + } + }) + }, + [activeHandleRef, focusLiveInput, timerRef] + ) + + return { focusLiveInput, handleTerminalTap, resetLiveInputFocus } +} From 169ec8f08d09aa1a2590ccc6d020c5c5ca454351 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:42:27 -0700 Subject: [PATCH 12/22] fix(mobile): refresh folder workspace catalog (#11767) --- mobile/src/worktree/host-worktree-refresh.test.ts | 4 +++- mobile/src/worktree/host-worktree-refresh.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index 38ea6ad4aec..fb7e48a7a48 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -101,12 +101,14 @@ describe('startHostWorktreeRefresh', () => { expect(fetchRepoMetadata).not.toHaveBeenCalled() }) - it('force-refreshes repo metadata on reposChanged', () => { + it('refreshes worktrees and repo metadata on reposChanged', () => { start() + fetchWorktrees.mockClear() fetchRepoMetadata.mockClear() eventListener?.({ type: 'reposChanged' }) + expect(fetchWorktrees).toHaveBeenCalledOnce() expect(fetchRepoMetadata).toHaveBeenCalledOnce() expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) }) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 7fa3161740a..264b3c6e266 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -69,6 +69,8 @@ export function startHostWorktreeRefresh({ return } if (event.type === 'reposChanged') { + // Why: folder workspace mutations publish reposChanged, and worktree.ps owns this catalog. + void fetchWorktrees() void fetchRepoMetadata({ force: true, queueIfInFlight: true }) } else if (event.type === 'worktreesChanged') { void fetchWorktrees() From edb5607e28fb587be44079eb4f77efe90b3841ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:48:24 -0700 Subject: [PATCH 13/22] ci: block new root-level entries (#11903) * ci: guard repository root additions * fix: clear existing type-aware lint warnings --- .../scripts/check-root-directory-entries.sh | 37 +++++++ .github/workflows/pr.yml | 20 ++++ .../check-root-directory-entries.test.mjs | 99 +++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../terminal-cold-park-resource-bench.mjs | 9 +- .../terminal-cold-park-reveal-bench.mjs | 9 +- tests/tools/win-update-e2e/app-driver.mjs | 4 +- .../tools/win-update-e2e/installer-steps.mjs | 2 +- 8 files changed, 172 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/check-root-directory-entries.sh create mode 100644 config/scripts/check-root-directory-entries.test.mjs diff --git a/.github/scripts/check-root-directory-entries.sh b/.github/scripts/check-root-directory-entries.sh new file mode 100755 index 00000000000..c07d7d40163 --- /dev/null +++ b/.github/scripts/check-root-directory-entries.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +base_sha=$1 +head_sha=$2 +git rev-parse --verify "${base_sha}^{tree}" >/dev/null +git rev-parse --verify "${head_sha}^{tree}" >/dev/null + +declare -A base_entries=() +while IFS= read -r -d '' entry; do + base_entries["$entry"]=1 +done < <(git ls-tree -z --name-only "$base_sha") + +blocked_entries=() +while IFS= read -r -d '' entry; do + if [[ -z "${base_entries[$entry]+present}" ]]; then + blocked_entries+=("$entry") + fi +done < <(git ls-tree -z --name-only "$head_sha") + +if (( ${#blocked_entries[@]} == 0 )); then + echo "Root directory guard passed: no new root-level files or folders." + exit 0 +fi + +echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page." +echo "Root directory guard failed." +echo "New root-level files or folders are not allowed because they bloat the GitHub landing page." +echo "Move each new entry under an existing top-level directory." +printf 'Blocked entries:\n' +printf ' %s\n' "${blocked_entries[@]}" +exit 1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9ae0aa2a659..c2c3119456b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -93,6 +93,23 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements + root_directory_guard: + name: root directory guard + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA" + typecheck: runs-on: ubuntu-latest @@ -377,6 +394,7 @@ jobs: if: always() needs: - static_analysis + - root_directory_guard - typecheck - git_compatibility - shell_contracts @@ -397,6 +415,7 @@ jobs: - name: Require successful checks env: STATIC_ANALYSIS: ${{ needs.static_analysis.result }} + ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} @@ -406,6 +425,7 @@ jobs: run: | for result in \ "$STATIC_ANALYSIS" \ + "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs new file mode 100644 index 00000000000..e8d3bb9bc4c --- /dev/null +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh') +const tempDirs = [] + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function makeFixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-')) + tempDirs.push(root) + git(root, ['init', '--quiet']) + git(root, ['config', 'user.email', 'root-directory-guard-test@example.com']) + git(root, ['config', 'user.name', 'Root Directory Guard Test']) + mkdirSync(join(root, 'config'), { recursive: true }) + writeFileSync(join(root, 'config', 'base.txt'), 'base\n') + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'base']) + return { root, base: git(root, ['rev-parse', 'HEAD']) } +} + +function commitFiles(root, files) { + for (const [relativePath, contents] of files) { + const target = join(root, relativePath) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'head']) + return git(root, ['rev-parse', 'HEAD']) +} + +function runGuard({ root, base, head }) { + return spawnSync('bash', [guardScript, base, head], { + cwd: root, + encoding: 'utf8' + }) +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('root directory guard', () => { + it('allows additions inside an existing top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']]) + + const result = runGuard({ ...fixture, head }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('no new root-level files or folders') + }) + + it('rejects a new root-level file with the landing-page message', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('bloat the GitHub landing page') + expect(output).toContain('new-root.md') + }) + + it('rejects a new top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('new-folder') + }) + + it('is wired into the PR verify gate', () => { + const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const guardJob = workflow.jobs.root_directory_guard + const guardStep = guardJob.steps.find( + (step) => step.name === 'Reject new root-level files and folders' + ) + + expect(guardJob.name).toBe('root directory guard') + expect(guardJob.steps[0].with['fetch-depth']).toBe(0) + expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh') + expect(workflow.jobs.verify.needs).toContain('root_directory_guard') + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5afa1107419..d1e90fad17a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -173,6 +173,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'static_analysis', + 'root_directory_guard', 'typecheck', 'git_compatibility', 'shell_contracts', diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index b70fa88a2a7..63b7222a93d 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -28,14 +28,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const PARK_DELAY_MS = 1_500 @@ -454,7 +456,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-res-${args.label}-${stamp}.json` diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index b28591535c6..a01cbf2cc3a 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -34,14 +34,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const scenarioTimeoutMs = 300_000 @@ -582,7 +584,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-${args.label}-${stamp}.json` diff --git a/tests/tools/win-update-e2e/app-driver.mjs b/tests/tools/win-update-e2e/app-driver.mjs index b8d155c5752..72603fba921 100644 --- a/tests/tools/win-update-e2e/app-driver.mjs +++ b/tests/tools/win-update-e2e/app-driver.mjs @@ -217,7 +217,7 @@ export async function captureFailureDiagnostics(page, dir, label) { * An expected tab id prevents post-restore probes from accepting another tab. */ export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTabId = null) { const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() await surface.waitFor({ state: 'visible', timeout: timeoutMs }) @@ -399,7 +399,7 @@ export async function focusActiveTerminal(page, terminalTabId = null) { // focusing so typed commands actually reach the shell. await dismissKnownOverlays(page) const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() const click = surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }) diff --git a/tests/tools/win-update-e2e/installer-steps.mjs b/tests/tools/win-update-e2e/installer-steps.mjs index 2ae7af4b6ed..92ab9f9e1ca 100644 --- a/tests/tools/win-update-e2e/installer-steps.mjs +++ b/tests/tools/win-update-e2e/installer-steps.mjs @@ -85,7 +85,7 @@ export function silentInstall(setupExe, { timeoutMs = 180_000, installDir = null // no other flags for a per-user install. /D, when present, MUST be last. const args = ['/S'] if (installDir) { - args.push(`/D=${installDir}`) + args.push(`/D=${String(installDir)}`) } const proc = spawnSync(setupExe, args, { encoding: 'utf8' }) if (proc.error) { From 3a70078ab9972f61a364c064f21e2301b53f360d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:36:49 -0700 Subject: [PATCH 14/22] fix(daemon): prevent PAM rejection restart cascades (#11911) * fix(daemon): back off transient PAM rejection retirement * fix(daemon): rebaseline PAM evidence after sleep --- config/reliability-gates.jsonc | 80 ++++++++++++++++++- src/main/daemon/daemon-entry.ts | 1 + .../macos-login-session-death-watch.test.ts | 80 ++++++++++++++++--- .../daemon/macos-login-session-death-watch.ts | 45 +++++++++-- 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f6af7d2eaa7..2785645d621 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-31", + "updatedAt": "2026-08-01", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,84 @@ } }, "gates": [ + { + "id": "terminal-provider.login-session-retirement", + "title": "macOS login-session retirement preserves live daemons through transient rejection bursts", + "maturity": "experimental", + "protection": "partial", + "owner": "daemon-terminal", + "layer": "macos-daemon-lifecycle", + "surfaces": [ + "GUI-spawned macOS daemon", + "local PTY survival", + "daemon reconnect and replacement" + ], + "platforms": ["macos"], + "providers": ["local-daemon"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, event-trigger preemption, timer suspension, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11749", + "https://github.com/stablyai/orca/issues/7936" + ], + "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized timer gap resets the rejection window, and client or PTY activity cannot shorten its scheduled backoff.", + "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour without firing the scheduled timer, require rejection evidence to restart after wake, and again recover without retirement. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot" + ], + "testFiles": ["src/main/daemon/macos-login-session-death-watch.test.ts"], + "assertionRefs": [ + { + "file": "src/main/daemon/macos-login-session-death-watch.test.ts", + "assertions": [ + "preserves the daemon when a short PAM rejection burst recovers after wake", + "does not count a suspended timer gap as rejection evidence", + "retires only after sustained conclusive rejections with a degraded resolver", + "suppresses retirement while resolver health is healthy or unknown, then retires on explicit degradation", + "stop prevents an in-flight resolver check from retiring the daemon" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 0.1, + "summary": "The focused state-machine suite passed 19 tests, including the field-shaped 20-second rejection burst, suspended-timer rebaselining, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." + } + ], + "runtimeBudget": { + "p95Seconds": 2, + "scope": "deterministic login-session death-watch state-machine suite" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic suite passes locally; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On origin/main, the field-shaped accepted then three-rejection sequence calls onRetire after 20 seconds and fails the recovery oracle. With the minimum observation window and non-preemptible rejection schedule, the identical oracle preserves the daemon and recovers at the boundary." + }, + "performanceBudget": { + "required": true, + "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Capture field evidence from macOS sleep/wake and a full GUI logout without a false retirement or missed stale-session recovery.", + "Keep the exact probe-count, no-early-resolver, trigger-preemption, acceptance-reset, and shutdown-abort assertions green." + ], + "knownGaps": [ + "A real dead macOS GUI login session cannot be fabricated without ending the runner's login session; sustained-death coverage uses deterministic PAM and resolver oracles.", + "The two-minute production window and one-hour timer suspension are covered by fake-clock tests rather than a real sleep/wake run.", + "No multi-process aggregate throttle is added; each stale daemon independently obeys the same non-preemptible observation window." + ], + "demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault." + }, { "id": "ssh-relay.staged-upload-recovery", "title": "SSH relay uploads remain retryable before the shared install lock", diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 215786fb472..4d98e2a6690 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -210,6 +210,7 @@ async function main(): Promise { timing: { periodicProbeMs: 2_000, rejectionRecheckMs: 500, + minimumRejectionSpanMs: 2_000, ptyExitDebounceMs: 200, clientActivityMinGapMs: 1_000, minProbeGapMs: 100 diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts index 1531a33ab20..fa983508ec5 100644 --- a/src/main/daemon/macos-login-session-death-watch.test.ts +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -38,7 +38,7 @@ class FakeClock { if (!due) { break } - this.nowMs = due.at + this.nowMs = Math.max(this.nowMs, due.at) due.cleared = true due.callback() // Why: probes are async; let their promise chains settle before firing the next timer. @@ -47,6 +47,10 @@ class FakeClock { this.nowMs = target } + suspend(ms: number): void { + this.nowMs += ms + } + pendingCount(): number { return this.timers.filter((t) => !t.cleared).length } @@ -84,6 +88,7 @@ function createWatch( timing: { periodicProbeMs: 120_000, rejectionRecheckMs: 10_000, + minimumRejectionSpanMs: 120_000, ptyExitDebounceMs: 2_000, clientActivityMinGapMs: 30_000, minProbeGapMs: 5_000, @@ -102,9 +107,9 @@ function createWatch( } describe('MacosLoginSessionDeathWatch', () => { - it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => { + it('retires only after sustained conclusive rejections with a degraded resolver', async () => { const { watch, clock, onRetire } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED] + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] }) watch.start() await drainMicrotasks() @@ -113,7 +118,9 @@ describe('MacosLoginSessionDeathWatch', () => { await clock.advance(120_000) // periodic → rejection 1 await clock.advance(10_000) // recheck → rejection 2 expect(onRetire).not.toHaveBeenCalled() - await clock.advance(10_000) // recheck → rejection 3 → retire + await clock.advance(10_000) // recheck → rejection 3 → deferred + expect(onRetire).not.toHaveBeenCalled() + await clock.advance(100_000) // rejection 3 after the observation window → retire expect(onRetire).toHaveBeenCalledWith({ cause: 'pam-rejections', rejections: 3, @@ -121,6 +128,57 @@ describe('MacosLoginSessionDeathWatch', () => { }) }) + it('preserves the daemon when a short PAM rejection burst recovers after wake', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) + + expect(probe).toHaveBeenCalledTimes(4) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + watch.notifyClientActivity() + watch.notifyPtyExit() + await clock.advance(99_999) + expect(probe).toHaveBeenCalledTimes(4) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(5) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + }) + + it('does not count a suspended timer gap as rejection evidence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + clock.suspend(60 * 60 * 1000) + await clock.advance(0) + await clock.advance(10_000) + await clock.advance(10_000) + + expect(probe).toHaveBeenCalledTimes(5) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(100_000) + expect(probe).toHaveBeenCalledTimes(6) + expect(onRetire).not.toHaveBeenCalled() + }) + it('never retires when the session was never conclusively accepted', async () => { const { watch, clock, onRetire } = createWatch({ outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] @@ -183,20 +241,21 @@ describe('MacosLoginSessionDeathWatch', () => { 'suppresses retirement while resolver health is %s, then retires on explicit degradation', async (initialResolverHealth) => { const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] }) setResolverHealth(initialResolverHealth) watch.start() await drainMicrotasks() await clock.advance(120_000) await clock.advance(10_000) - await clock.advance(10_000) // threshold reached but resolver did not corroborate death + await clock.advance(10_000) + await clock.advance(100_000) // threshold reached but resolver did not corroborate death expect(onRetire).not.toHaveBeenCalled() const probesAtSuppression = probe.mock.calls.length setResolverHealth('unhealthy') - await clock.advance(10_000) + await clock.advance(119_999) expect(probe).toHaveBeenCalledTimes(probesAtSuppression) - await clock.advance(110_000) // suppressed states return to the bounded periodic cadence + await clock.advance(1) // suppressed states return to the bounded periodic cadence expect(onRetire).toHaveBeenCalledTimes(1) } ) @@ -334,7 +393,7 @@ describe('MacosLoginSessionDeathWatch', () => { resolveHealth = resolve }) const { watch, clock, onRetire } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED], + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED], readResolverHealth: (signal) => { resolverSignal = signal return resolverHealth @@ -344,7 +403,8 @@ describe('MacosLoginSessionDeathWatch', () => { await drainMicrotasks() await clock.advance(120_000) await clock.advance(10_000) - await clock.advance(10_000) // retirement is now waiting on resolver health + await clock.advance(10_000) + await clock.advance(100_000) // retirement is now waiting on resolver health watch.stop() expect(resolverSignal?.aborted).toBe(true) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index d0cadccac18..ce803ff409b 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -2,12 +2,12 @@ import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' import type { DaemonFileLog } from './daemon-file-log' import type { SystemResolverHealth } from './types' -// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient -// rejection storm (PAM db reload, OS update) from retiring a daemon whose login -// session is still alive; a real logout rejects conclusively on every probe. +// Why: retain count confirmation while the elapsed window below blocks short bursts. const REQUIRED_CONSECUTIVE_REJECTIONS = 3 const PERIODIC_PROBE_MS = 120_000 const REJECTION_RECHECK_MS = 10_000 +// Why: a full periodic window separates logout from short wake/PAM recovery bursts. +const MINIMUM_REJECTION_SPAN_MS = PERIODIC_PROBE_MS const PTY_EXIT_DEBOUNCE_MS = 2_000 // Why: a client hello right after login is the fastest death signal for a stale // daemon, but steady reconnects must not turn hellos into a PAM probe storm. @@ -35,6 +35,7 @@ export type MacosLoginSessionDeathWatchOptions = { timing?: Partial<{ periodicProbeMs: number rejectionRecheckMs: number + minimumRejectionSpanMs: number ptyExitDebounceMs: number clientActivityMinGapMs: number minProbeGapMs: number @@ -65,6 +66,7 @@ export class MacosLoginSessionDeathWatch { private readonly clock: WatchClock private readonly periodicProbeMs: number private readonly rejectionRecheckMs: number + private readonly minimumRejectionSpanMs: number private readonly ptyExitDebounceMs: number private readonly clientActivityMinGapMs: number private readonly minProbeGapMs: number @@ -73,6 +75,7 @@ export class MacosLoginSessionDeathWatch { // a host where the wrapper never worked has no death signal to trust. private armed = false private consecutiveRejections = 0 + private firstRejectionAtMs: number | null = null private lastProbeStartedAtMs: number | null = null private probeInFlight = false private stopped = false @@ -99,6 +102,7 @@ export class MacosLoginSessionDeathWatch { } this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS + this.minimumRejectionSpanMs = opts.timing?.minimumRejectionSpanMs ?? MINIMUM_REJECTION_SPAN_MS this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS @@ -169,9 +173,10 @@ export class MacosLoginSessionDeathWatch { } this.scheduledProbeAtMs = this.clock.now() + delayMs this.scheduledProbeTimer = this.clock.setTimeout(() => { + const scheduledAtMs = this.scheduledProbeAtMs this.scheduledProbeTimer = null this.scheduledProbeAtMs = null - void this.runProbe(trigger) + void this.runProbe(trigger, scheduledAtMs ?? undefined) }, delayMs) } @@ -181,6 +186,9 @@ export class MacosLoginSessionDeathWatch { private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void { const requestedAtMs = this.clock.now() + delayMs + if (this.consecutiveRejections > 0 && this.scheduledProbeAtMs !== null) { + return + } if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) { return } @@ -200,7 +208,7 @@ export class MacosLoginSessionDeathWatch { } } - private async runProbe(trigger: string): Promise { + private async runProbe(trigger: string, scheduledAtMs?: number): Promise { if (this.stopped || this.retired) { return } @@ -209,6 +217,20 @@ export class MacosLoginSessionDeathWatch { this.retainPendingProbe(trigger) return } + if (this.consecutiveRejections > 0 && scheduledAtMs === undefined) { + return + } + const timerGapMs = + scheduledAtMs === undefined ? 0 : Math.max(0, this.clock.now() - scheduledAtMs) + if (this.consecutiveRejections > 0 && timerGapMs > this.minProbeGapMs * 3) { + // Why: sleep/App Nap pauses probes; elapsed wall time is not rejection evidence. + this.consecutiveRejections = 0 + this.firstRejectionAtMs = null + this.log.log('login-session-rejection-window-reset', { + cause: 'timer-gap', + timerGapMs + }) + } const elapsedSinceProbe = this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs if ( @@ -246,6 +268,7 @@ export class MacosLoginSessionDeathWatch { } this.armed = true this.consecutiveRejections = 0 + this.firstRejectionAtMs = null this.scheduleNextProbe(this.periodicProbeMs) return } @@ -254,6 +277,7 @@ export class MacosLoginSessionDeathWatch { this.scheduleNextProbe(this.periodicProbeMs) return } + this.firstRejectionAtMs ??= this.clock.now() this.consecutiveRejections++ this.log.log('login-session-probe-rejected', { trigger, @@ -263,6 +287,17 @@ export class MacosLoginSessionDeathWatch { this.scheduleNextProbe(this.rejectionRecheckMs) return } + const rejectionSpanMs = this.clock.now() - this.firstRejectionAtMs + if (rejectionSpanMs < this.minimumRejectionSpanMs) { + const retryInMs = this.minimumRejectionSpanMs - rejectionSpanMs + this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 + this.log.log('login-session-retire-deferred', { + cause: 'pam-rejections', + retryInMs + }) + this.scheduleNextProbe(retryInMs) + return + } await this.retireIfResolverDegraded(() => { this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 }, abortController.signal) From e531e796b279ab6ad1e399e2ae3c8c764be09f38 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:56:52 -0700 Subject: [PATCH 15/22] fix(relay): chunk fs.changed notifications to fit the client frame capacity (#11917) * fix(relay): chunk fs.changed notifications to fit the client frame capacity emitRelayWatcherEvents published an entire watcher batch as one fs.changed notification on the ordinary lane. A batch runs routinely tens of times over the per-frame producer capacity (12,288 bytes against a Node <=21 socket, 49,152 against Node >=22), and an over-capacity frame there is not queued or trimmed -- notify() fails admission and closes the client, tearing down every in-flight request on that connection. Regressed in 5f7807497e (#11005). The emitter now sizes chunks against the tightest capacity across attached clients, measured in encoded bytes through the same envelope admission measures, and preserves event order. A single event too large for an empty envelope cannot be chunked, so the ordinary-lane close remains its backstop: a silent drop on this lane has no resync contract. * chore(relay): shorten watcher chunking comments --- src/relay/dispatcher.ts | 20 ++ .../relay-filesystem-watch-registry.test.ts | 2 + src/relay/relay-watcher-event-emitter.ts | 42 ++- .../relay-watcher-frame-chunking.test.ts | 337 ++++++++++++++++++ .../relay-watcher-parent-removal.test.ts | 16 +- ...elay-watcher-pending-setup-waiters.test.ts | 7 +- 6 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 src/relay/relay-watcher-frame-chunking.test.ts diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 3809b9f5f80..c6a1a77ec96 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -388,6 +388,26 @@ export class RelayDispatcher { ) } + // notify() broadcasts one frame, so chunks must fit the smallest attached capacity. + broadcastProducerFrameCapacity(): number | undefined { + if (this.disposed) { + return undefined + } + const clients = this.activeClients() + if (clients.length === 0) { + return undefined + } + return Math.min(...clients.map((client) => client.writer.producerFrameCapacity)) + } + + notificationFrameBytes(method: string, params?: Record): number { + return this.estimateFrameBytes({ + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + }) + } + feed(data: Buffer): void { this.feedForClient(this.primaryClient, data) } diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 9a21fb9eace..8c4c0134a2a 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -64,6 +64,8 @@ function createDispatcher() { return { notify: vi.fn(), notifyClient: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detached.add(listener) return () => detached.delete(listener) diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 1a575c360fa..05cdd68c281 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -1,6 +1,14 @@ import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' import type { RelayDispatcher } from './dispatcher' +const FS_CHANGED = 'fs.changed' + +type RelayWatcherEventPayload = { + kind: string + absolutePath: string + isDirectory?: boolean +} + export function emitRelayWatcherEvents( dispatcher: RelayDispatcher, closed: boolean, @@ -9,14 +17,36 @@ export function emitRelayWatcherEvents( if (closed || events.length === 0) { return } - // Watcher floods stay on the bounded producer lane; its cap closes the stale generation. - dispatcher.notify('fs.changed', { - events: events.map((event) => ({ + const frameCapacity = dispatcher.broadcastProducerFrameCapacity() + if (frameCapacity === undefined) { + return + } + // Whole watcher batches exceed the ordinary-lane cap; byte-pack ordered chunks instead. + const envelopeBytes = dispatcher.notificationFrameBytes(FS_CHANGED, { events: [] }) + let chunk: RelayWatcherEventPayload[] = [] + let chunkBytes = envelopeBytes + const flush = (): void => { + if (chunk.length === 0) { + return + } + dispatcher.notify(FS_CHANGED, { events: chunk }) + chunk = [] + chunkBytes = envelopeBytes + } + for (const event of events) { + const payload: RelayWatcherEventPayload = { kind: event.type, absolutePath: event.path, ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) - }) + } + const payloadBytes = Buffer.byteLength(JSON.stringify(payload)) + if (chunk.length > 0 && chunkBytes + 1 + payloadBytes > frameCapacity) { + flush() + } + chunkBytes += payloadBytes + (chunk.length === 0 ? 0 : 1) + chunk.push(payload) + } + flush() } export function emitRelayWatcherOverflow( @@ -25,7 +55,7 @@ export function emitRelayWatcherOverflow( closed: boolean ): void { if (!closed) { - dispatcher.notify('fs.changed', { + dispatcher.notify(FS_CHANGED, { events: [{ kind: 'overflow', absolutePath: rootPath }] }) } diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts new file mode 100644 index 00000000000..5d505fb8aaa --- /dev/null +++ b/src/relay/relay-watcher-frame-chunking.test.ts @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayDispatcher, type RelayClientSinkOptions } from './dispatcher' +import { HEADER_LENGTH, encodeJsonRpcFrame, parseJsonRpcMessage } from './protocol' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' + +// Node >=22 defaults the socket highWaterMark to 64 KiB, Node <=21 to 16 KiB. +const NODE_22_HIGH_WATER_MARK = 64 * 1024 +const NODE_22_PRODUCER_CAPACITY = 49_152 +const NODE_21_HIGH_WATER_MARK = 16 * 1024 +const NODE_21_PRODUCER_CAPACITY = 12_288 + +type WatcherEventPayload = { + kind: string + absolutePath: string + isDirectory?: boolean +} + +type ClientCapture = { + frames: Buffer[] + framedEvents: () => WatcherEventPayload[][] + fsChangedEvents: () => WatcherEventPayload[] +} + +function captureFrames(): { capture: ClientCapture; write: (frame: Buffer) => boolean } { + const frames: Buffer[] = [] + const decode = (frame: Buffer): { method: string; events: WatcherEventPayload[] } => { + const payloadLength = frame.readUInt32BE(9) + const message = parseJsonRpcMessage( + frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) + ) as { method?: string; params?: { events?: WatcherEventPayload[] } } + return { method: message.method ?? '', events: message.params?.events ?? [] } + } + const framedEvents = (): WatcherEventPayload[][] => + frames + .map((frame) => decode(frame)) + .filter((decoded) => decoded.method === 'fs.changed') + .map((decoded) => decoded.events) + return { + capture: { + frames, + framedEvents, + fsChangedEvents: () => framedEvents().flat() + }, + write: (frame) => { + frames.push(Buffer.from(frame)) + return true + } + } +} + +function createDispatcher( + highWaterMark: number, + sinkOptions: RelayClientSinkOptions = {} +): { + dispatcher: RelayDispatcher + capture: ClientCapture + detached: number[] +} { + const { capture, write } = captureFrames() + const detached: number[] = [] + const dispatcher = new RelayDispatcher(write, { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + ...sinkOptions + }) + dispatcher.onClientDetached((clientId) => detached.push(clientId)) + return { dispatcher, capture, detached } +} + +function watcherBatch(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, index) => ({ + type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', + path: `/Users/dev/workspaces/orca/node_modules/.pnpm/@scope+package@1.2.3/dist/chunk-${String(index).padStart(6, '0')}.js`, + isDirectory: false + })) as WatcherProcessEvent[] +} + +// Each path encodes beyond its UTF-16 length, exposing character-based budgets. +function multiByteWatcherBatch(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, index) => ({ + type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', + path: `/Users/dev/プロジェクト/日本語のディレクトリ/файлы/🚀-переклад/"quoted"\\escaped\\chunk-${String(index).padStart(6, '0')}.js`, + isDirectory: false + })) as WatcherProcessEvent[] +} + +function frameBytesFor(events: readonly WatcherEventPayload[]): number { + return encodeJsonRpcFrame({ jsonrpc: '2.0', method: 'fs.changed', params: { events } }, 0, 0) + .length +} + +// Real encodes keep this boundary oracle independent from the emitter's arithmetic. +function batchEncodingTo(targetBytes: number): WatcherProcessEvent[] { + const events = watcherBatch(400) + let fitting = 1 + while ( + fitting < events.length && + frameBytesFor(expectedPayloads(events.slice(0, fitting + 1))) <= targetBytes + ) { + fitting++ + } + const batch = events.slice(0, fitting) + const last = batch[fitting - 1] + const padding = targetBytes - frameBytesFor(expectedPayloads(batch)) + batch[fitting - 1] = { ...last, path: `${last.path}${'a'.repeat(padding)}` } + return batch +} + +function expectedPayloads(events: readonly WatcherProcessEvent[]): WatcherEventPayload[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) +} + +// Moving the next event forward must exceed capacity, catching needless splits. +function expectMaximallyPackedChunks(capture: ClientCapture, capacity: number): void { + const chunks = capture.framedEvents() + expect(chunks.length).toBeGreaterThan(1) + for (let index = 0; index + 1 < chunks.length; index++) { + expect(frameBytesFor([...chunks[index], chunks[index + 1][0]])).toBeGreaterThan(capacity) + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('relay watcher fs.changed frame chunking', () => { + it('delivers a 5000-event batch to a Node<=21 client without closing it', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('delivers a 5000-event batch to a Node>=22 client without closing it', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_22_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('sizes chunks in encoded bytes for multi-byte and JSON-escaped paths', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const events = multiByteWatcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('fills the first chunk to exactly the producer capacity and admits it', () => { + const exact = batchEncodingTo(NODE_21_PRODUCER_CAPACITY) + expect(frameBytesFor(expectedPayloads(exact))).toBe(NODE_21_PRODUCER_CAPACITY) + const events = [...exact, ...watcherBatch(50)] + + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + expect(capture.frames).toHaveLength(2) + expect(capture.frames[0].length).toBe(NODE_21_PRODUCER_CAPACITY) + expect(capture.framedEvents()[0]).toEqual(expectedPayloads(exact)) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('splits one byte past the producer capacity into two admitted frames', () => { + const overshoot = batchEncodingTo(NODE_21_PRODUCER_CAPACITY + 1) + expect(frameBytesFor(expectedPayloads(overshoot))).toBe(NODE_21_PRODUCER_CAPACITY + 1) + + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + try { + emitRelayWatcherEvents(dispatcher, false, overshoot) + + expect(detached).toEqual([]) + expect(capture.frames).toHaveLength(2) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(overshoot)) + } finally { + dispatcher.dispose() + } + }) + + it('sizes chunks against the smallest capacity across attached clients', () => { + const { capture: attachedCapture, write: attachedWrite } = captureFrames() + const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) + const events = watcherBatch(2000) + + try { + dispatcher.attachClient(attachedWrite, { + writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, + writableLength: () => 0 + }) + + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of [...capture.frames, ...attachedCapture.frames]) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + expect(attachedCapture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('still closes the client when a single event cannot fit any frame', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const oversized: WatcherProcessEvent[] = [ + { type: 'update', path: `/workspace/${'p'.repeat(20_000)}.txt`, isDirectory: false } + ] + + try { + emitRelayWatcherEvents(dispatcher, false, oversized) + + expect(detached).toEqual([1]) + expect(capture.frames).toEqual([]) + } finally { + dispatcher.dispose() + } + }) + + it('delivers the prefix then closes when an oversized event appears mid-batch', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const oversized = { + type: 'update', + path: `/workspace/${'p'.repeat(20_000)}.txt`, + isDirectory: false + } as WatcherProcessEvent + const events = [...watcherBatch(300), oversized, ...watcherBatch(50)] + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([1]) + const delivered = capture.fsChangedEvents() + expect(delivered.length).toBeGreaterThan(0) + expect(delivered).toEqual(expectedPayloads(events).slice(0, delivered.length)) + expect(delivered.some((event) => event.absolutePath === oversized.path)).toBe(false) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + } finally { + dispatcher.dispose() + } + }) + + it('keeps the fail-closed kill when retained chunks exhaust the producer queue budget', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + // An unsettled sink models retained relay stdout writes. + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK, { + supportsWriteCallback: true + }) + const emitted: WatcherProcessEvent[] = [] + + try { + for (let round = 0; round < 10 && detached.length === 0; round++) { + const events = watcherBatch(5000) + emitted.push(...events) + emitRelayWatcherEvents(dispatcher, false, events) + } + + expect(detached).toEqual([1]) + const delivered = capture.fsChangedEvents() + expect(delivered.length).toBeGreaterThan(0) + expect(delivered.length).toBeLessThan(emitted.length) + // The kill truncates delivery at a chunk boundary; nothing is reordered or duplicated. + expect(delivered).toEqual(expectedPayloads(emitted).slice(0, delivered.length)) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + } finally { + dispatcher.dispose() + } + }) + + it('does not notify at all when no client is attached', () => { + const { capture, write } = captureFrames() + const dispatcher = new RelayDispatcher(write, { + writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, + writableLength: () => 0 + }) + const notify = vi.spyOn(dispatcher, 'notify') + try { + dispatcher.invalidateClient() + emitRelayWatcherEvents(dispatcher, false, watcherBatch(10)) + + expect(notify).not.toHaveBeenCalled() + expect(capture.frames).toEqual([]) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/relay-watcher-parent-removal.test.ts b/src/relay/relay-watcher-parent-removal.test.ts index 892759204e0..c7736d61102 100644 --- a/src/relay/relay-watcher-parent-removal.test.ts +++ b/src/relay/relay-watcher-parent-removal.test.ts @@ -27,6 +27,16 @@ function context(clientId: number): RequestContext { return { clientId, isStale: () => false } } +function watchDispatcher() { + return { + notify: vi.fn(), + notifyClient: vi.fn(), + onClientDetached: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64) + } +} + describe('RelayFilesystemWatchRegistry parent removal', () => { it.each([ ['/repo', '/repo/nested', '/repo-sibling'], @@ -35,7 +45,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { 'closes descendant watches before deleting parent %s and preserves siblings', async (parent, descendant, sibling) => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool @@ -60,7 +70,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { it('waits for a descendant setup and closes its published subscription before removal', async () => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool @@ -90,7 +100,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { it('waits for an already-retiring descendant before removal', async () => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool diff --git a/src/relay/relay-watcher-pending-setup-waiters.test.ts b/src/relay/relay-watcher-pending-setup-waiters.test.ts index f3e6f8f897d..e2bacc3801e 100644 --- a/src/relay/relay-watcher-pending-setup-waiters.test.ts +++ b/src/relay/relay-watcher-pending-setup-waiters.test.ts @@ -23,7 +23,12 @@ function context(clientId: number, signal?: AbortSignal): RequestContext { describe('RelayFilesystemWatchRegistry pending setup waiters', () => { it('removes ten thousand aborted callers queued behind one teardown anchor', async () => { const pool = new PendingSetupPool() - const dispatcher = { notify: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = { + notify: vi.fn(), + onClientDetached: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64) + } const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool From c79b859758098ac602cf319855c6b25e77367e4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:08:11 -0700 Subject: [PATCH 16/22] fix(browser): prevent window.close guest crashes (#11910) * fix(browser): prevent window.close guest crashes * fix(browser): guard close before inline scripts * fix(browser): preserve explicit window close policy --- electron.vite.config.ts | 2 + src/main/browser/browser-manager.test.ts | 32 ++++ src/main/browser/browser-manager.ts | 9 +- src/main/window/createMainWindow.test.ts | 33 ++++ src/main/window/createMainWindow.ts | 17 +- .../browser-window-close-installation.ts | 15 ++ src/preload/browser-window-close.test.ts | 20 +++ src/preload/browser-window-close.ts | 7 + .../components/browser-pane/BrowserPane.tsx | 1 + .../BrowserPane.webview-preferences.test.ts | 19 ++ .../browser-pane/browser-page-webview.ts | 7 + src/renderer/src/hooks/useIpcEvents.test.ts | 5 + src/renderer/src/hooks/useIpcEvents.ts | 1 + src/renderer/src/store/slices/browser.ts | 17 +- src/shared/browser-window-close-policy.ts | 2 + src/shared/types.ts | 2 + src/shared/workspace-session-schema.ts | 1 + tests/e2e/browser-tab.spec.ts | 162 +++++++++++++++++- 18 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 src/preload/browser-window-close-installation.ts create mode 100644 src/preload/browser-window-close.test.ts create mode 100644 src/preload/browser-window-close.ts create mode 100644 src/shared/browser-window-close-policy.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 02239421ae9..39f923cb215 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -206,6 +206,8 @@ export const electronViteConfig: UserConfig = { external: isExternalMainModule, input: { index: resolve('src/main/index.ts'), + // Why: sandboxed webview preloads cannot load Rollup helper chunks. + 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 286f172a645..b93a0358537 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -1512,6 +1512,38 @@ describe('browserManager', () => { expect(browserManager.getGuestWebContentsId('browser-destroyed-before-register')).toBeNull() }) + it('removes a destroyed primary guest from its tab registration maps', () => { + const guest = { + id: 306, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-destroyed-after-register', + webContentsId: guest.id, + rendererWebContentsId + }) + + const destroyedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + destroyedHandler?.() + + expect(browserManager.getGuestWebContentsId('browser-destroyed-after-register')).toBeNull() + const managerState = browserManager as unknown as { + tabIdByWebContentsId: Map + } + expect(managerState.tabIdByWebContentsId.has(guest.id)).toBe(false) + }) + it('fully unregisters stale guests discovered during authorization', () => { const guest = { id: 305, diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7dafca617f2..66f6ac653bf 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -640,7 +640,6 @@ export class BrowserManager { // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const installClickedLinkRouting = (): void => { @@ -940,11 +939,15 @@ export class BrowserManager { private retireStaleGuestWebContents(previousWebContentsId: number): void { // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. this.cleanupGuestPolicyAttachment(previousWebContentsId) - this.tabIdByWebContentsId.delete(previousWebContentsId) } private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index fc38ca95e77..8a61f157584 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,6 +74,8 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' +import { fileURLToPath } from 'node:url' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -248,6 +250,7 @@ describe('createMainWindow', () => { expect(allowBlankPrefs).toMatchObject({ disableHtmlFullscreenWindowResize: true, partition: 'persist:orca-browser', + preload: expect.stringMatching(/browser-window-close-preload\.js$/), sandbox: true }) @@ -262,6 +265,36 @@ describe('createMainWindow', () => { const guest = { marker: 'guest' } windowHandlers['did-attach-webview']({} as never, guest as never) expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) + + const allowWindowCloseEvent = { preventDefault: vi.fn() } + const allowWindowCloseParams = { + src: 'data:text/html,', + preload: BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + } + const allowWindowClosePrefs = { partition: 'persist:orca-browser' } + windowHandlers['will-attach-webview']( + allowWindowCloseEvent as never, + allowWindowClosePrefs as never, + allowWindowCloseParams as never + ) + expect(allowWindowCloseEvent.preventDefault).not.toHaveBeenCalled() + expect(allowWindowCloseParams.preload).toBeUndefined() + expect(allowWindowClosePrefs).not.toHaveProperty('preload') + + const allowWindowClosePathPrefs = { + partition: 'persist:orca-browser', + preload: fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } + windowHandlers['will-attach-webview']( + { preventDefault: vi.fn() } as never, + allowWindowClosePathPrefs as never, + { src: 'data:text/html,', preload: '' } as never + ) + expect(allowWindowClosePathPrefs).not.toHaveProperty('preload') + + const cliGuest = { marker: 'cli-guest' } + windowHandlers['did-attach-webview']({} as never, cliGuest as never) + expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(cliGuest) }) it('sets platform-specific titlebar and frame options for every desktop platform', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 519e1817051..49c3eb4c125 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -10,6 +10,7 @@ import { screen } from 'electron' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' @@ -18,6 +19,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' import { isCrashReportReason } from '../../shared/crash-reporting' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, @@ -441,6 +443,8 @@ export function createMainWindow( // so register it with the window's other navigation policy. registerPluginPanelNavigationGuard(mainWindow.webContents) + const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') + const browserWindowCloseAllowedPreloadPath = fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' const normalizedSrc = normalizeBrowserNavigationUrl(src) @@ -452,7 +456,18 @@ export function createMainWindow( return } - delete webPreferences.preload + const allowWindowClose = [params.preload, webPreferences.preload].some( + (preload) => + preload === BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD || + preload === browserWindowCloseAllowedPreloadPath + ) + delete params.preload + if (allowWindowClose) { + delete webPreferences.preload + } else { + // Why: preload runs in the page's main world before inline scripts can call window.close(). + webPreferences.preload = browserWindowClosePreload + } // Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge. delete (webPreferences as Record).preloadURL webPreferences.nodeIntegration = false diff --git a/src/preload/browser-window-close-installation.ts b/src/preload/browser-window-close-installation.ts new file mode 100644 index 00000000000..adf2fdb09bf --- /dev/null +++ b/src/preload/browser-window-close-installation.ts @@ -0,0 +1,15 @@ +export function installBrowserWindowCloseGuard(): void { + const ignoreWindowClose = (): void => {} + try { + Object.defineProperty(window, 'close', { + configurable: false, + enumerable: false, + writable: false, + value: ignoreWindowClose + }) + } catch { + try { + window.close = ignoreWindowClose + } catch {} + } +} diff --git a/src/preload/browser-window-close.test.ts b/src/preload/browser-window-close.test.ts new file mode 100644 index 00000000000..1b35a45f8df --- /dev/null +++ b/src/preload/browser-window-close.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' + +describe('browser window close preload', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('installs a non-replaceable window.close no-op in the page world', () => { + const nativeClose = vi.fn() + vi.stubGlobal('window', { close: nativeClose }) + + installBrowserWindowCloseGuard() + + expect(window.close()).toBeUndefined() + expect(window.close).not.toBe(nativeClose) + expect(Reflect.set(window, 'close', nativeClose)).toBe(false) + }) +}) diff --git a/src/preload/browser-window-close.ts b/src/preload/browser-window-close.ts new file mode 100644 index 00000000000..7fb8201377f --- /dev/null +++ b/src/preload/browser-window-close.ts @@ -0,0 +1,7 @@ +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' +import type { ContextBridge } from 'electron' + +// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build. +const { contextBridge } = require('electron') as { contextBridge: ContextBridge } + +contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 1a2c0279543..60cc98d8d7c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -3693,6 +3693,7 @@ function BrowserPagePane({ container, inputLocked: inputLockedRef.current, webviewPartition, + allowWindowClose: browserTab.allowWindowClose === true, resolveContainer: () => ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts index 203fce6fdbf..496b23ec409 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' const registryMocks = vi.hoisted(() => ({ destroyPersistentWebview: vi.fn(), @@ -103,4 +104,22 @@ describe('BrowserPane webview preferences', () => { expect(ensuredWebview?.webview.style.pointerEvents).toBe('none') expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) }) + + it('marks explicitly allowed CLI pages before the guest attaches', () => { + const container = createContainer('cli-page') + + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-cli', + container, + inputLocked: false, + webviewPartition: 'persist:orca-browser', + allowWindowClose: true, + resolveContainer: () => container + }) + + expect(ensuredWebview?.webview.getAttribute('preload')).toBe( + BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + ) + expect(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD).toMatch(/^file:/) + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/browser-page-webview.ts index 1179d0da63b..b8c44d849ad 100644 --- a/src/renderer/src/components/browser-pane/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/browser-page-webview.ts @@ -1,4 +1,5 @@ import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' import { destroyPersistentWebview, registerPersistentWebview, @@ -10,12 +11,14 @@ export function ensureBrowserPageWebview({ container, inputLocked, webviewPartition, + allowWindowClose, resolveContainer }: { browserTabId: string container: HTMLDivElement inputLocked: boolean webviewPartition: string + allowWindowClose?: boolean resolveContainer: () => HTMLDivElement | null }): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null { let webview = webviewRegistry.get(browserTabId) @@ -46,6 +49,10 @@ export function ensureBrowserPageWebview({ webview = document.createElement('webview') as Electron.WebviewTag webview.setAttribute('partition', webviewPartition) + if (allowWindowClose) { + // Why: main consumes and removes this marker in will-attach-webview before the guest sees any preload. + webview.setAttribute('preload', BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } webview.setAttribute('allowpopups', '') // Why: Electron spreads the webpreferences keys verbatim, so the shared // camelCase attribute must stay intact for fullscreen containment to work. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1d956f716cb..0b7ea6ad0af 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1077,6 +1077,11 @@ describe('useIpcEvents browser tab create routing', () => { expect(acquireBrowserAutomationVisibility).toHaveBeenCalledWith('page-new') expect(acquireBrowserAutomationVisibility).not.toHaveBeenCalledWith('page-active') + expect(state.createBrowserTab).toHaveBeenCalledWith( + 'wt-1', + 'https://example.com', + expect.objectContaining({ allowWindowClose: true }) + ) expect(replyTabCreate).toHaveBeenCalledWith({ requestId: 'req-create', browserPageId: 'page-new' diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3c8b19068c2..2a72aae4918 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2266,6 +2266,7 @@ export function useIpcEvents(): void { // Agent/automation opens stay in the background (activate:false) in the active browser group. const workspace = store.createBrowserTab(worktreeId, data.url, { title: data.url, + allowWindowClose: true, targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, sessionProfileId: data.sessionProfileId, sessionPartition: data.sessionPartition, diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 60a71a3eb47..42d0ddbce7b 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -57,6 +57,7 @@ import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktr type CreateBrowserTabOptions = { activate?: boolean title?: string + allowWindowClose?: boolean sessionProfileId?: string | null sessionPartition?: string | null // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. @@ -69,6 +70,7 @@ type CreateBrowserTabOptions = { type CreateBrowserPageOptions = { activate?: boolean title?: string + allowWindowClose?: boolean browserRuntimeEnvironmentId?: string | null } @@ -341,7 +343,8 @@ function buildBrowserPage( worktreeId: string, url: string, title?: string, - browserRuntimeEnvironmentId?: string | null + browserRuntimeEnvironmentId?: string | null, + allowWindowClose?: boolean ): BrowserPage { const normalizedUrl = normalizeUrl(url) return { @@ -357,6 +360,7 @@ function buildBrowserPage( canGoForward: false, loadError: null, createdAt: Date.now(), + ...(allowWindowClose !== undefined ? { allowWindowClose } : {}), ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}) } } @@ -554,7 +558,8 @@ export const createBrowserSlice: StateCreator = worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. const sessionProfileId = @@ -902,13 +907,15 @@ export const createBrowserSlice: StateCreator = activate: true, sessionProfileId, sessionPartition, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + allowWindowClose: firstPage.allowWindowClose }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, title: p.title, + allowWindowClose: p.allowWindowClose, browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId }) } @@ -987,7 +994,8 @@ export const createBrowserSlice: StateCreator = workspace.worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) set((s) => { @@ -1169,6 +1177,7 @@ export const createBrowserSlice: StateCreator = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, activate: true, + allowWindowClose: pageToRestore.allowWindowClose, browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId }) }, diff --git a/src/shared/browser-window-close-policy.ts b/src/shared/browser-window-close-policy.ts new file mode 100644 index 00000000000..2ea28169ee7 --- /dev/null +++ b/src/shared/browser-window-close-policy.ts @@ -0,0 +1,2 @@ +// Why: this marker carries the CLI's explicit close policy across attach before the guest exists. +export const BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD = 'file:///__orca_window_close_allowed__' diff --git a/src/shared/types.ts b/src/shared/types.ts index 475470dd4c4..a03c2420985 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -982,6 +982,8 @@ export type BrowserPage = { canGoForward: boolean loadError: BrowserLoadError | null createdAt: number + // Why: CLI-created pages retain Chromium's native window.close behavior; ordinary embedded pages are guarded. + allowWindowClose?: boolean // Why: remote-owned worktrees can still host client-local fallback browser // pages until headless remote runtimes support real browser panes. browserRuntimeEnvironmentId?: string | null diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index da5fe4ef08e..f5d849d5270 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -225,6 +225,7 @@ const browserPageSchema = z.object({ canGoForward: z.boolean(), loadError: browserLoadErrorSchema.nullable(), createdAt: z.number(), + allowWindowClose: z.boolean().optional(), // Why: explicit null marks a browser page as client-local even when its // worktree is remote-owned; older sessions omit it and keep inferred runtime. browserRuntimeEnvironmentId: z.string().nullable().optional(), diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 7ac10558047..bc8dd51e988 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -29,10 +29,11 @@ async function createBrowserTab( page: Parameters[0], worktreeId: string, url?: string, - title = 'New Browser Tab' + title = 'New Browser Tab', + allowWindowClose?: boolean ): Promise { return page.evaluate( - ({ targetWorktreeId, targetUrl, targetTitle }) => { + ({ targetWorktreeId, targetUrl, targetTitle, allowClose }) => { const store = window.__store if (!store) { return null @@ -44,12 +45,18 @@ async function createBrowserTab( targetUrl ?? state.browserDefaultUrl ?? 'about:blank', { title: targetTitle, - activate: true + activate: true, + allowWindowClose: allowClose } ) return { id: tab.id, pageId: tab.activePageId ?? null } }, - { targetWorktreeId: worktreeId, targetUrl: url, targetTitle: title } + { + targetWorktreeId: worktreeId, + targetUrl: url, + targetTitle: title, + allowClose: allowWindowClose + } ) } @@ -199,6 +206,71 @@ async function startBrowserLinkServer(): Promise<{ } } +async function startBrowserWindowCloseServer(): Promise<{ + url: string + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/source') { + response.end(` + + + Close link source + Open close page + + `) + return + } + response.end(` + + + Window close repro + +

Attempting close…

+ + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + url: `http://127.0.0.1:${port}/window-close`, + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} + +async function readBrowserWindowCloseStatus( + page: Parameters[0], + browserTabId: string +): Promise { + return page.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector(`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript( + 'document.querySelector("#s")?.textContent ?? "status missing"' + )) as string + } catch { + return 'guest lost' + } + }, browserTabId) +} + async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => server.close((error) => { @@ -689,6 +761,88 @@ test.describe('Browser Tab', () => { } }) + test('blocked window.close in a link-created tab does not break tab switching', async ({ + orcaPage + }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const neighboringTab = await createBrowserTab( + orcaPage, + worktreeId, + 'about:blank', + 'Neighboring tab' + ) + const sourceTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Close link source' + ) + expect(neighboringTab?.id).toBeTruthy() + expect(sourceTab?.id).toBeTruthy() + + await clickBrowserLink(orcaPage, sourceTab!.id, '#window-close-link') + let closeTabId: string | null = null + await expect + .poll(async () => { + const tabs = await getBrowserTabs(orcaPage, worktreeId) + closeTabId = tabs.find((tab) => tab.url === closeServer.url)?.id ?? null + return closeTabId + }) + .not.toBeNull() + + await orcaPage.locator(`[data-tab-id="${neighboringTab!.id}"]`).click() + await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') + await expect + .poll(() => readBrowserWindowCloseStatus(orcaPage, closeTabId!), { timeout: 5_000 }) + .toContain('window.close() was blocked') + } finally { + await closeServer.close() + } + }) + + test('explicitly allowed browser tabs retain replaceable window.close', async ({ orcaPage }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const allowedTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Allowed close tab', + true + ) + expect(allowedTab?.id).toBeTruthy() + + await expect + .poll( + () => + orcaPage.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript(`(() => { + window.close = () => 'replacement-called' + return window.close() === 'replacement-called' ? 'replacement-called' : 'blocked' + })()`)) as string + } catch { + return 'guest unavailable' + } + }, allowedTab!.id), + { timeout: 5_000 } + ) + .toBe('replacement-called') + } finally { + await closeServer.close() + } + }) + /** * User Prompt: * - Browser works and also retains state when switching tabs etc. From 76a2317bc0a057a9c4edb910da3db7009d8d316b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:22:36 -0700 Subject: [PATCH 17/22] fix(persistence): stop blocking backup rotation (#11916) Use async profile probes and serialize the current-time due decision plus mutations under one async owner so sync flushes and detached writers cannot double-shift or miss the recovery interval. Add syscall, timer-liveness, parity, and held-I/O interleaving coverage. --- .../persistence-async-write-syscalls.test.ts | 496 ++++++++++++++++++ src/main/persistence.ts | 82 ++- 2 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 src/main/persistence-async-write-syscalls.test.ts diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts new file mode 100644 index 00000000000..6a4491e8b38 --- /dev/null +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const testState = { dir: '' } + +const fsCalls = vi.hoisted(() => { + const blocker = new Int32Array(new SharedArrayBuffer(4)) + const calls = { + recording: false, + dirPrefix: '', + /** When > 0, every recorded sync call parks the main thread this long — a stalled mount in miniature. */ + stallMs: 0, + syncCalls: [] as string[], + asyncCalls: [] as string[], + failAsync: null as ((fn: string, target: string) => NodeJS.ErrnoException | null) | null, + beforeAsync: null as ((fn: string, target: string) => void) | null, + waitAsync: null as ((fn: string, target: string) => Promise | null) | null, + reset(): void { + calls.syncCalls.length = 0 + calls.asyncCalls.length = 0 + calls.stallMs = 0 + calls.failAsync = null + calls.beforeAsync = null + calls.waitAsync = null + }, + inScope(target: unknown): target is string { + return ( + calls.recording && + typeof target === 'string' && + calls.dirPrefix !== '' && + target.startsWith(calls.dirPrefix) + ) + }, + recordSync(fn: string, target: unknown): void { + if (!calls.inScope(target)) { + return + } + calls.syncCalls.push(`${fn}:${target}`) + if (calls.stallMs > 0) { + // Atomics.wait blocks the thread the way an uninterruptible syscall does. + Atomics.wait(blocker, 0, 0, calls.stallMs) + } + }, + recordAsync(fn: string, target: unknown): NodeJS.ErrnoException | null { + if (!calls.inScope(target)) { + return null + } + calls.asyncCalls.push(`${fn}:${target}`) + calls.beforeAsync?.(fn, target) + return calls.failAsync?.(fn, target) ?? null + } + } + return calls +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of Object.keys(actual)) { + const original = (actual as unknown as Record)[name] + if (!name.endsWith('Sync') || typeof original !== 'function') { + continue + } + const fn = original as (...args: unknown[]) => unknown + const wrapper = (...args: unknown[]): unknown => { + fsCalls.recordSync(name, args[0]) + return fn(...args) + } + patched[name] = Object.assign(wrapper, fn) + } + return { ...patched, default: patched } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open']) { + const fn = (actual as unknown as Record unknown>)[name] + patched[name] = async (...args: unknown[]): Promise => { + const failure = fsCalls.recordAsync(name, args[0]) + if (failure) { + throw failure + } + if (typeof args[0] === 'string') { + await fsCalls.waitAsync?.(name, args[0]) + } + return fn(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +// Deterministic cipher so two stores driven through identical mutations produce identical bytes. +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +const PAST_ROTATION_INTERVAL_MS = BACKUP_MIN_INTERVAL_MS * 2 +const SAVE_DEBOUNCE_MS = 1_000 + +const ROTATION_INTERLEAVE_CASES = [ + ['initial access', 'access', ''], + ['oldest removal', 'rm', '.bak.4'], + ['slot access', 'access', '.bak.0'], + ['slot rename', 'rename', '.bak.0'], + ['final copy', 'copyFile', ''] +] as const + +type TestStore = { + updateUI(updates: { sidebarWidth: number }): void + waitForPendingWrite(): Promise + flushOrThrow(): void +} + +async function createStore(dir: string): Promise { + testState.dir = dir + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() as unknown as TestStore +} + +function dataFile(dir: string): string { + return join(dir, 'orca-data.json') +} + +function seedStaleBackup(dir: string): void { + const path = `${dataFile(dir)}.bak.0` + writeFileSync(path, '{"stale":true}', 'utf-8') + const staleSeconds = (Date.now() - PAST_ROTATION_INTERVAL_MS) / 1000 + utimesSync(path, staleSeconds, staleSeconds) +} + +function ringSnapshot(dir: string): Record { + const snapshot: Record = {} + for (const name of readdirSync(dir).sort()) { + if (name === 'orca-data.json' || name.startsWith('orca-data.json.bak.')) { + snapshot[name] = readFileSync(join(dir, name), 'utf-8') + } + } + return snapshot +} + +describe('async persistence write path avoids synchronous fs syscalls', () => { + const dirs: string[] = [] + + function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-async-write-')) + dirs.push(dir) + return dir + } + + beforeEach(() => { + fsCalls.recording = false + fsCalls.dirPrefix = '' + fsCalls.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + fsCalls.recording = false + vi.useRealTimers() + while (dirs.length > 0) { + rmSync(dirs.pop() as string, { recursive: true, force: true }) + } + }) + + async function recordAsyncSave( + store: TestStore, + dir: string, + sidebarWidth: number + ): Promise { + store.updateUI({ sidebarWidth }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + } + } + + it('issues no sync fs syscall under the profile dir when rotation runs with an empty ring', async () => { + const dir = makeDir() + const store = await createStore(dir) + + await recordAsyncSave(store, dir, 301) + + expect(fsCalls.syncCalls).toEqual([]) + // Rotation must actually have happened, else the assertion above is vacuous. + expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) + }) + + it('uses an awaited stat, not statSync, for the backup rotation interval check', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + + await recordAsyncSave(store, dir, 302) + + expect(fsCalls.syncCalls).toEqual([]) + expect(fsCalls.asyncCalls).toContain(`stat:${dataFile(dir)}.bak.0`) + }) + + it('issues no sync fs syscall while rotating a saturated ring', async () => { + const dir = makeDir() + const store = await createStore(dir) + + // One more save than the ring holds, so the oldest slot is evicted and every slot renames. + for (let i = 0; i <= BACKUP_COUNT; i++) { + await recordAsyncSave(store, dir, 310 + i) + } + + expect(fsCalls.syncCalls).toEqual([]) + const ring = ringSnapshot(dir) + expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) + expect(Object.keys(ring)).not.toContain(`orca-data.json.bak.${BACKUP_COUNT}`) + }) + + it('lets a main-thread timer keep firing while a rotating save is in flight', async () => { + // A recorded sync call stalls long enough for the heartbeat to catch it. + vi.useRealTimers() + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + // Generous stall so ordinary scheduler/GC jitter can't reach the threshold on a loaded CI box. + const stallMs = 500 + + let lastTick = Date.now() + let worstGapMs = 0 + const heartbeat = setInterval(() => { + const now = Date.now() + worstGapMs = Math.max(worstGapMs, now - lastTick) + lastTick = now + }, 10) + + try { + store.updateUI({ sidebarWidth: 341 }) + fsCalls.dirPrefix = dir + fsCalls.stallMs = stallMs + fsCalls.recording = true + lastTick = Date.now() + await new Promise((resolve) => setTimeout(resolve, SAVE_DEBOUNCE_MS + 200)) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + clearInterval(heartbeat) + } + + expect(worstGapMs).toBeLessThan(stallMs) + // The save really happened, so a small gap isn't just an absent write. + expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) + }, 20_000) + + it('skips rotation when a sync flush rotated during the rotation-interval await', async () => { + // The acquired owner keeps rotation when the flush skips the ring. + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + + store.updateUI({ sidebarWidth: 361 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + let flushed = false + fsCalls.beforeAsync = (fn, target) => { + if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) { + return + } + flushed = true + store.updateUI({ sidebarWidth: 362 }) + store.flushOrThrow() + } + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + fsCalls.beforeAsync = null + } + + expect(flushed).toBe(true) + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + }) + + it('keeps the due check inside ownership when a second writer starts', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const almostDueSeconds = (Date.now() - (BACKUP_MIN_INTERVAL_MS - SAVE_DEBOUNCE_MS - 100)) / 1000 + utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + const statCall = `stat:${dataFile(dir)}.bak.0` + let releaseRotation!: () => void + const rotationRelease = new Promise((resolve) => { + releaseRotation = resolve + }) + let signalRotation!: () => void + const rotationStarted = new Promise((resolve) => { + signalRotation = resolve + }) + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) { + return null + } + held = true + signalRotation() + return rotationRelease + } + + fsCalls.dirPrefix = dir + fsCalls.recording = true + store.updateUI({ sidebarWidth: 371 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const firstWrite = store.waitForPendingWrite() + try { + await rotationStarted + store.updateUI({ sidebarWidth: 372 }) + store.flushOrThrow() + store.updateUI({ sidebarWidth: 373 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + await store.waitForPendingWrite() + expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) + } finally { + releaseRotation() + await firstWrite + fsCalls.recording = false + fsCalls.waitAsync = null + } + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + }) + + it.each(ROTATION_INTERLEAVE_CASES)( + 'keeps one rotation owner when a sync flush lands during %s', + async (_phase, expectedFn, targetSuffix) => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + const expectedTarget = `${dataFile(dir)}${targetSuffix}` + + store.updateUI({ sidebarWidth: 363 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + let flushed = false + fsCalls.beforeAsync = (fn, target) => { + if (flushed || fn !== expectedFn || target !== expectedTarget) { + return + } + flushed = true + store.updateUI({ sidebarWidth: 364 }) + store.flushOrThrow() + } + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + fsCalls.beforeAsync = null + } + + expect(flushed).toBe(true) + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + } + ) + + it('does not log for absent ring slots even when the mount rejects renames non-ENOENT', async () => { + // Probing keeps degraded mounts from logging one error per absent slot. + const dir = makeDir() + const store = await createStore(dir) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.failAsync = (fn, target) => + fn === 'rename' && target.includes('.bak.') + ? Object.assign(new Error('stale NFS file handle'), { code: 'ESTALE' }) + : null + + try { + await recordAsyncSave(store, dir, 351) + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + } + }) + + it('logs when a ring slot that exists fails to rotate', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.failAsync = (fn, target) => + fn === 'rename' && target.endsWith('.bak.0') + ? Object.assign(new Error('permission denied'), { code: 'EPERM' }) + : null + + try { + await recordAsyncSave(store, dir, 352) + expect(errors).toHaveBeenCalledWith( + '[persistence] Failed to rotate backup', + `${dataFile(dir)}.bak.0`, + '->', + `${dataFile(dir)}.bak.1`, + expect.objectContaining({ code: 'EPERM' }) + ) + } finally { + errors.mockRestore() + } + }) + + it('produces a .bak ring byte-identical to the sync path, at capacity and with ring holes', async () => { + const asyncDir = makeDir() + const syncDir = makeDir() + // Match generated IDs so only rotation behavior can differ. + const seedDir = makeDir() + const seedStore = await createStore(seedDir) + seedStore.updateUI({ sidebarWidth: 320 }) + seedStore.flushOrThrow() + const seed = readFileSync(dataFile(seedDir), 'utf-8') + for (const dir of [asyncDir, syncDir]) { + writeFileSync(dataFile(dir), seed, 'utf-8') + // Holes: slots 1 and 3 occupied, 0 and 2 missing — exercises the per-slot existence branch. + writeFileSync(`${dataFile(dir)}.bak.1`, '{"old":1}', 'utf-8') + writeFileSync(`${dataFile(dir)}.bak.3`, '{"old":3}', 'utf-8') + } + + const widths = [321, 322, 323, 324, 325, 326] + const asyncStore = await createStore(asyncDir) + for (const width of widths) { + asyncStore.updateUI({ sidebarWidth: width }) + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await asyncStore.waitForPendingWrite() + } + + const syncStore = await createStore(syncDir) + for (const width of widths) { + syncStore.updateUI({ sidebarWidth: width }) + syncStore.flushOrThrow() + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + } + + const ring = ringSnapshot(asyncDir) + expect(ring).toEqual(ringSnapshot(syncDir)) + expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) + }) + + it('keeps the sync quit/crash fallback on synchronous syscalls', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + + store.updateUI({ sidebarWidth: 331 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + store.flushOrThrow() + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toContain(`statSync:${dataFile(dir)}.bak.0`) + expect(fsCalls.syncCalls).toContain(`existsSync:${dataFile(dir)}`) + expect(fsCalls.asyncCalls).toEqual([]) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c5e4c2a81a9..e5744973f12 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -11,7 +11,7 @@ import { statSync, realpathSync } from 'node:fs' -import { rename, mkdir, rm, copyFile, open } from 'node:fs/promises' +import { rename, mkdir, rm, copyFile, open, stat, access } from 'node:fs/promises' import { renameDurable, writeFileDurableSync } from './durable-file-write' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' @@ -589,6 +589,14 @@ function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + function buildWorkspaceDirHistoryForUpdate( current: GlobalSettings, updates: Partial @@ -2742,6 +2750,8 @@ export class Store { private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. + private backupRotationInFlight = false // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. @@ -2884,28 +2894,52 @@ export class Store { } } + // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible + // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. + private async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. private async rotateBackupsAsync(dataFile: string): Promise { - if (!existsSync(dataFile)) { + if (this.backupRotationInFlight) { return } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) + this.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) + if (!(await exists(dataFile))) { + return } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.backupRotationInFlight = false } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) } private rotateBackupsSync(dataFile: string): void { @@ -3886,18 +3920,15 @@ export class Store { await rm(tmpFile).catch(() => {}) } } - // Why (issue #1158): rotate backups only after rename succeeded, and let a concurrent flush own rotation. + // Why (#1158): rotate only after the primary rename while this write still owns its generation. if (this.writeGeneration !== gen) { return } - const now = Date.now() - if (this.shouldRotateBackups(now, dataFile)) { - await this.rotateBackupsAsync(dataFile) - } + await this.rotateBackupsAsync(dataFile) } // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean } = {}): void { + private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { if (this.writesFrozen) { return } @@ -3931,7 +3962,7 @@ export class Store { } } const now = Date.now() - if (this.shouldRotateBackups(now, dataFile)) { + if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { this.rotateBackupsSync(dataFile) } } @@ -3946,7 +3977,10 @@ export class Store { // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. this.writeGeneration++ this.pendingWrite = null - this.writeToDiskSync({ force: asyncWriteWasInFlight }) + this.writeToDiskSync({ + force: asyncWriteWasInFlight, + skipBackupRotation: this.backupRotationInFlight + }) } flushActiveViewPreferenceOrThrow(): void { From 1f307afa6d4efb3d23bacb3190e40ae1ad4a1cf7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:26:30 -0700 Subject: [PATCH 18/22] fix(terminal): preserve follow output through streaming refocus (#11915) --- config/reliability-gates.jsonc | 99 ++++++++ .../terminal-visibility-resume.test.ts | 44 ++++ .../terminal-visibility-resume.ts | 5 +- ...erminal-streaming-refocus-viewport.spec.ts | 240 ++++++++++++++++++ 4 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/terminal-streaming-refocus-viewport.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2785645d621..01db646c217 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3048,6 +3048,105 @@ ], "demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-scroll.streaming-refocus-intent", + "title": "Streaming refocus preserves follow-output viewport intent", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit-and-electron-e2e", + "surfaces": [ + "terminal lifecycle", + "window focus recovery", + "hidden-to-visible resume", + "xterm write backlog", + "scrollback" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "Unit coverage proves provider-independent ordering for any PaneManager. Live Electron coverage exercises a local PTY through the daemon on macOS; SSH, WSL, remote-runtime, Linux, and Windows remain unproved for this exact race.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11753", + "https://github.com/stablyai/orca/pull/11915" + ], + "invariant": "When output is queued during a focus or visibility transition, Orca records the pre-flush viewport intent before xterm parses backlog writes, so a follow-output terminal stays at the bottom and a pinned terminal keeps its prior position.", + "oracle": "Unit tests require exactly one intent sync before each queued-output flush. The Electron test injects a transient top-of-buffer xterm wobble during refocus and requires every presented scrollbar frame, including the final rendered output, to remain at the bottom.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "tests/e2e/terminal-streaming-refocus-viewport.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": [ + "window-wake recovery synchronizes viewport intent exactly once before flushing queued output", + "heavy visibility resume synchronizes intent exactly once before flushing queued output" + ] + }, + { + "file": "tests/e2e/terminal-streaming-refocus-viewport.spec.ts", + "assertions": [ + "phase-one scrollback is visibly ready at the bottom without a fixed sleep", + "no presented animation frame moves the scrollbar thumb away from the bottom during refocus", + "the final streamed marker renders with the visible scrollbar still at the bottom" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "result": "passed", + "durationSeconds": 0.096, + "summary": "14 tests passed, including exact sync count and pre-flush ordering for wake and heavy visibility resume." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5", + "result": "passed", + "durationSeconds": 35.2, + "summary": "Five consecutive Electron iterations passed after replacing fixed-time readiness and stale tab capture with deterministic viewport and pane-identity oracles." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused renderer unit test or one Electron E2E iteration" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Five consecutive local Electron iterations passed; CI soak history is still required before promotion." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The controlled xterm viewport wobble reproduces the pinned-top failure with post-flush intent sampling and passes when intent is latched before the queued write flush." + }, + "performanceBudget": { + "required": true, + "evidence": "Wake recovery keeps one O(panes) intent pass before the existing bounded 64 KiB-per-pane flush. Heavy resume removes its second intent pass and keeps the existing bounded 256 KiB-per-pane flush; no polling, timers, subprocesses, IPC, output parsing, fit, or repaint work is added." + }, + "promotionCriteria": [ + "Collect stable CI soak history for the Electron race gate.", + "Run the live oracle on Linux and Windows terminal backends.", + "Add live SSH or remote-runtime coverage for queued output during refocus." + ], + "knownGaps": [ + "The deterministic wobble uses xterm private buffer state and must be updated if that internal contract changes.", + "Live Linux, Windows, SSH, WSL, and remote-runtime execution is not covered for this exact race.", + "The Electron gate proves follow-output behavior; adjacent scroll-intent coverage protects pinned viewport behavior." + ], + "demotionRule": "Demote or quarantine if the Electron oracle flakes without a product bug or harness bug filed to terminal-rendering." + }, { "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 300a381c05a..17271179620 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -151,6 +151,50 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('latches viewport intent before refocus recovery flushes streaming output', async () => { + const terminal = { name: 'streaming-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + const { flushTerminalOutput } = vi.mocked( + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + ) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(flushTerminalOutput).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('does not overwrite pre-reveal intent after queuing hidden output', async () => { + const terminal = { name: 'hidden-streaming-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + const { flushTerminalOutput } = vi.mocked( + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + ) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(flushTerminalOutput).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + it('resets each pane linkifier hover cache on window wake recovery so links recover without a scroll', () => { const first = { name: 'pane-a' } const second = { name: 'pane-b' } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 641c99258bc..5c6b8314545 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -143,6 +143,8 @@ export function recoverVisibleTerminalWindowWake({ }: RecoverVisibleTerminalWindowWakeArgs): void { // Why: macOS screensaver/display wake can leave xterm visible but with a // stale renderer/input surface; Orca's own hidden-state resume never runs. + // Why: backlog writes can expose transient viewport geometry while parsing. + syncTerminalViewportIntents(manager) for (const pane of manager.getPanes()) { requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) @@ -154,7 +156,6 @@ export function recoverVisibleTerminalWindowWake({ resetTerminalLinkifierHoverState(pane.terminal) } } - syncTerminalViewportIntents(manager) manager.resumeRendering() // Why: wake re-attaches WebGL — same transient cell-metric wobble guard as the heavy resume. manager.fitAllRevealedPanes() @@ -192,7 +193,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } - syncTerminalViewportIntents(manager) + // Intent was latched by the caller before queued writes can expose transient geometry. // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume diff --git a/tests/e2e/terminal-streaming-refocus-viewport.spec.ts b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts new file mode 100644 index 00000000000..8376a3188a0 --- /dev/null +++ b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts @@ -0,0 +1,240 @@ +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + getTerminalContent, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection' +import { nodeTerminalCommand } from './terminal-node-command' + +const STREAMING_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/streaming-scrollback-fixture.cjs' +) + +type RevealFrame = { + targetPresented: boolean + thumbTop: number | null + maxThumbTop: number | null +} + +async function closeFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store?.getState().activeModal === 'feature-tips') { + store.getState().closeModal() + } + }) +} + +async function waitForPhaseOneAtBottom(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((targetTabId) => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + const terminal = pane?.terminal + if (!terminal) { + return false + } + const buffer = terminal.buffer.active + let containsMarker = false + for (let line = buffer.baseY; line < buffer.baseY + terminal.rows; line += 1) { + if (buffer.getLine(line)?.translateToString(true).includes('STREAM_PHASE1_DONE')) { + containsMarker = true + break + } + } + const scrollbar = pane.container.querySelector( + '.xterm-scrollbar.xterm-vertical' + ) + const thumb = scrollbar?.querySelector('.xterm-slider') ?? null + return Boolean( + buffer.baseY > 0 && + buffer.viewportY === buffer.baseY && + containsMarker && + scrollbar && + thumb && + scrollbar.clientHeight - thumb.offsetHeight > 1 && + Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2 + ) + }, tabId), + { + timeout: 30_000, + message: 'phase-one output did not render with the visible viewport at the bottom' + } + ) + .toBe(true) +} + +async function injectQueuedWriteAndRefocus( + page: Page, + tabId: string, + paneKey: string +): Promise { + await page.evaluate( + ({ targetTabId, paneKey }) => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + if (!pane) { + throw new Error('Hidden terminal pane unavailable') + } + const terminal = pane.terminal + // Why: fail loudly if xterm moves the private buffer path that models this wobble. + const bufferService = ( + terminal as typeof terminal & { + _core?: { + _bufferService?: { buffer?: { ydisp: number }; isUserScrolling: boolean } + } + } + )._core?._bufferService + const internalBuffer = bufferService?.buffer + if (!internalBuffer || !bufferService) { + throw new Error('xterm internal buffer unavailable') + } + const originalWrite = terminal.write + let wobbleApplied = false + terminal.write = ((data: string, callback?: () => void) => { + terminal.write = originalWrite + wobbleApplied = true + internalBuffer.ydisp = 0 + bufferService.isUserScrolling = true + if (terminal.buffer.active.viewportY !== 0) { + throw new Error('xterm viewport wobble was not observable') + } + originalWrite.call(terminal, data, callback) + }) as typeof terminal.write + const injector = ( + window as Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string) => boolean + } + } + ).__terminalPtyDataInjection + const rows = Array.from( + { length: 400 }, + (_, index) => `REFOCUS_STREAM_ROW_${String(index).padStart(4, '0')}_${'x'.repeat(80)}\n` + ).join('') + if (!injector?.inject(paneKey, `${rows}REFOCUS_STREAM_DONE\n`)) { + throw new Error('PTY data injector unavailable') + } + // Why: focus recovery must flush through terminal.write in this synchronous dispatch. + window.dispatchEvent(new Event('focus')) + if (!wobbleApplied) { + throw new Error('refocus did not flush the queued xterm write') + } + }, + { targetTabId: tabId, paneKey } + ) +} + +async function sampleRevealFrames(page: Page, targetTabId: string): Promise { + return page.evaluate( + (targetTabId) => + new Promise((resolve) => { + const frames: RevealFrame[] = [] + const startedAt = performance.now() + const isPresented = (element: Element | null): boolean => { + if (!(element instanceof HTMLElement)) { + return false + } + for ( + let current: HTMLElement | null = element; + current; + current = current.parentElement + ) { + const style = getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return false + } + } + const rect = element.getBoundingClientRect() + return ( + rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight + ) + } + const sample = (): void => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + const targetXterm = pane?.container.querySelector('.xterm') ?? null + const scrollbar = + targetXterm?.querySelector('.xterm-scrollbar.xterm-vertical') ?? null + const thumb = scrollbar?.querySelector('.xterm-slider') ?? null + frames.push({ + targetPresented: isPresented(targetXterm), + thumbTop: thumb?.offsetTop ?? null, + maxThumbTop: scrollbar && thumb ? scrollbar.clientHeight - thumb.offsetHeight : null + }) + if (performance.now() - startedAt >= 700) { + resolve(frames) + return + } + requestAnimationFrame(sample) + } + sample() + }), + targetTabId + ) +} + +test.describe('terminal streaming refocus viewport', () => { + test('keeps follow-output at the bottom through a queued-write refocus wobble', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await closeFeatureTips(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([STREAMING_FIXTURE_PATH])) + await waitForPhaseOneAtBottom(orcaPage, tabId) + + const framesPromise = sampleRevealFrames(orcaPage, tabId) + await injectQueuedWriteAndRefocus(orcaPage, tabId, paneKey) + const frames = await framesPromise + + expect(frames.filter((frame) => !frame.targetPresented)).toEqual([]) + expect( + frames.filter( + (frame) => + frame.thumbTop === null || + frame.maxThumbTop === null || + Math.abs(frame.maxThumbTop - frame.thumbTop) > 2 + ) + ).toEqual([]) + expect(frames.some((frame) => (frame.maxThumbTop ?? 0) > 1)).toBe(true) + expect( + frames.filter((frame) => (frame.maxThumbTop ?? 0) > 1 && (frame.thumbTop ?? 0) <= 1) + ).toEqual([]) + await expect + .poll(() => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('REFOCUS_STREAM_DONE') + const visibleScrollbar = orcaPage.locator('.xterm-scrollbar.xterm-vertical:visible').first() + await expect(visibleScrollbar).toBeVisible() + expect( + await visibleScrollbar.evaluate((scrollbar) => { + const thumb = scrollbar.querySelector('.xterm-slider') + return Boolean( + thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2 + ) + }) + ).toBe(true) + }) +}) From 33c14bc7164ccfa32fefb89126786a13a464ee5e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:27:42 -0700 Subject: [PATCH 19/22] fix(ssh): fall back to OpenSSH for FIDO2 keys (#11913) Closes #11645 --- src/main/ssh/ssh-auth-resolution.ts | 29 +- src/main/ssh/ssh-connection-utils.test.ts | 30 +- src/main/ssh/ssh-connection.test.ts | 63 +++- src/main/ssh/ssh-connection.ts | 35 +- .../ssh-security-key-identity.test-fixture.ts | 42 +++ .../ssh/ssh-security-key-identity.test.ts | 309 ++++++++++++++++++ src/main/ssh/ssh-security-key-identity.ts | 107 ++++++ src/main/ssh/ssh-system-fallback.test.ts | 18 +- src/main/ssh/ssh-transport-selection.ts | 118 +++++++ src/main/ssh/system-ssh-binary.test.ts | 106 ++++++ src/main/ssh/system-ssh-binary.ts | 46 ++- 11 files changed, 836 insertions(+), 67 deletions(-) create mode 100644 src/main/ssh/ssh-security-key-identity.test-fixture.ts create mode 100644 src/main/ssh/ssh-security-key-identity.test.ts create mode 100644 src/main/ssh/ssh-security-key-identity.ts create mode 100644 src/main/ssh/ssh-transport-selection.ts create mode 100644 src/main/ssh/system-ssh-binary.test.ts diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index 025c8904870..c7e65e26d92 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -8,18 +8,26 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would -// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile. +// fail to authenticate. Probe the regular and FIDO2 OpenSSH default paths. const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss'] +const DEFAULT_SECURITY_KEY_NAMES = ['id_ed25519_sk', 'id_ecdsa_sk'] const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`) +const DEFAULT_IDENTITY_PATHS = [...DEFAULT_KEY_NAMES, ...DEFAULT_SECURITY_KEY_NAMES].map( + (name) => `~/.ssh/${name}` +) const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' // Why: resolved IdentityFile paths are expanded before auth resolution, so they // won't match the ~/... form in DEFAULT_KEY_PATHS. -const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath) +const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_IDENTITY_PATHS.map(resolveSshConfigHomePath) export type PrivateKeyFile = { path: string; contents: Buffer } +export function listDefaultIdentityFilePaths(): string[] { + return [...DEFAULT_IDENTITY_PATHS] +} + export function findDefaultKeyFile(): PrivateKeyFile | undefined { for (const keyPath of DEFAULT_KEY_PATHS) { const resolved = resolveSshConfigHomePath(keyPath) @@ -97,7 +105,10 @@ function resolveExplicitPrivateKeyPaths( return resolvedIdentities } -function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { +export function resolveIdentityFilePaths( + target: SshTarget, + resolved: Pick | null +): string[] { if (isOpenSshConfigBackedTarget(target) && resolved) { return resolved.identityFile } @@ -138,7 +149,7 @@ export function resolvePrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null ): PrivateKeyFile[] { - const keyPaths = resolvePrivateKeyPaths(target, resolved) + const keyPaths = resolveIdentityFilePaths(target, resolved) if (keyPaths.length > 0 || resolved || target.identityFile) { return readPrivateKeys(keyPaths) } @@ -174,16 +185,6 @@ export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | un return undefined } -function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { - if (isOpenSshConfigBackedTarget(target) && resolved) { - return resolved.identityFile - } - if (target.identityFile) { - return [target.identityFile] - } - return resolved?.identityFile ?? [] -} - export function resolveAgentConfigValue( agentSocket: string, target: SshTarget, diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index a5dd743fd43..5e06a9e7386 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -313,7 +313,7 @@ describe('findDefaultKeyFile', () => { expect(result!.contents).toEqual(Buffer.from('key-contents')) }) - it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => { + it('probes regular and FIDO2 keys in stable default order', () => { const checkedPaths: string[] = [] mockExistsSync.mockImplementation((path: unknown) => { checkedPaths.push(String(path)) @@ -331,6 +331,34 @@ describe('findDefaultKeyFile', () => { ]) }) + it('keeps a regular default ahead of a malformed FIDO2 default', () => { + mockExistsSync.mockImplementation((path: unknown) => { + return ( + path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk') + ) + }) + mockReadFileSync.mockImplementation((path: unknown) => { + if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) { + throw new Error('malformed FIDO2 key') + } + return Buffer.from('rsa-key') + }) + + expect(findDefaultKeyFile()).toEqual({ + path: '~/.ssh/id_rsa', + contents: Buffer.from('rsa-key') + }) + }) + + it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => { + mockExistsSync.mockImplementation((path: unknown) => { + return path === testHomePath('.ssh', 'id_ed25519_sk') + }) + + expect(findDefaultKeyFile()).toBeUndefined() + expect(mockReadFileSync).not.toHaveBeenCalled() + }) + it('skips unreadable key files and tries next', () => { mockExistsSync.mockImplementation((path: unknown) => { return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa') diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index b73cc1e1db3..a3bcadf66dc 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -16,6 +16,7 @@ let execBehavior: 'callback' | 'pending' = 'callback' let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null let sftpBehavior: 'callback' | 'pending' = 'callback' let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null +let notifyClientCreated: (() => void) | undefined type MockSshClient = { setNoDelay: ReturnType @@ -45,6 +46,8 @@ vi.mock('ssh2', () => { lastConnectConfig?: unknown constructor() { clientInstances.push(this) + notifyClientCreated?.() + notifyClientCreated = undefined } on(event: string, handler: (...args: unknown[]) => void) { const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>() @@ -168,6 +171,10 @@ import { } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' +import { + createOpenSshPrivateKeyFixture, + createOpenSshPublicKeyFixture +} from './ssh-security-key-identity.test-fixture' function createTarget(overrides?: Partial): SshTarget { return { @@ -278,6 +285,7 @@ describe('SshConnection', () => { pendingExecCallback = null sftpBehavior = 'callback' pendingSftpCallback = null + notifyClientCreated = undefined clientInstances = [] getOrcaControlSocketPathMock.mockReset() getOrcaControlSocketPathMock.mockReturnValue(null) @@ -462,10 +470,11 @@ describe('SshConnection', () => { const callbacks = createCallbacks() const conn = new SshConnection(createTarget(), callbacks) + const clientCreated = new Promise((resolve) => { + notifyClientCreated = resolve + }) const connectResult = conn.connect().catch((error: Error) => error) - for (let i = 0; i < 5 && clientInstances.length === 0; i++) { - await Promise.resolve() - } + await clientCreated expect(clientInstances).toHaveLength(1) await conn.disconnect() @@ -485,10 +494,11 @@ describe('SshConnection', () => { const callbacks = createCallbacks() const conn = new SshConnection(createTarget(), callbacks) + const clientCreated = new Promise((resolve) => { + notifyClientCreated = resolve + }) const connectResult = conn.connect().catch((error: Error) => error) - for (let i = 0; i < 5 && clientInstances.length === 0; i++) { - await Promise.resolve() - } + await clientCreated expect(clientInstances).toHaveLength(1) await conn.disconnect() @@ -1462,6 +1472,47 @@ describe('SshConnection', () => { ) }) + it('uses system SSH before ssh2 parses a security-key private key', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-')) + const keyPath = join(directory, 'id_ed25519_sk') + writeFileSync( + keyPath, + createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true }) + ) + const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks()) + + try { + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(directory, { recursive: true }) + } + }) + + it('uses system SSH for an agent-backed security-key public identity', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-')) + const identityPath = join(directory, 'id_ed25519_sk') + writeFileSync( + `${identityPath}.pub`, + createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com') + ) + const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks()) + + try { + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(directory, { recursive: true }) + } + }) + it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => { connectBehavior = 'error' connectErrorMessage = 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)' diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 17f0a382251..37d661587ad 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -41,6 +41,10 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' +import { + requiresSystemSshForSecurityKey, + shouldUseSystemSshTransport +} from './ssh-transport-selection' import type { RemoteHostPlatform } from './ssh-remote-platform' import { resolveSftpTransferPathIfMapped, @@ -636,7 +640,14 @@ export class SshConnection { const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( () => null ) - if (shouldUseSystemSshTransport(this.target, resolved)) { + const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved) + const requiresSecurityKeyTransport = usesConfiguredSystemTransport + ? false + : await requiresSystemSshForSecurityKey(this.target, resolved) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } @@ -1386,24 +1397,4 @@ export class SshConnection { } } -export function shouldUseSystemSshTransport( - target: SshTarget, - resolved: Pick | null -): boolean { - if (isOpenSshConfigBackedTarget(target) && resolved) { - return ( - process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || - resolved.proxyUseFdpass === true || - resolved.proxyCommand != null || - resolved.proxyJump != null - ) - } - return ( - process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || - target.proxyCommand != null || - target.jumpHost != null || - resolved?.proxyUseFdpass === true || - resolved?.proxyCommand != null || - resolved?.proxyJump != null - ) -} +export { shouldUseSystemSshTransport } from './ssh-transport-selection' diff --git a/src/main/ssh/ssh-security-key-identity.test-fixture.ts b/src/main/ssh/ssh-security-key-identity.test-fixture.ts new file mode 100644 index 00000000000..0a8bcbd58f4 --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.test-fixture.ts @@ -0,0 +1,42 @@ +function encodeUint32(value: number): Buffer { + const buffer = Buffer.alloc(4) + buffer.writeUInt32BE(value) + return buffer +} + +function sshString(value: string | Buffer): Buffer { + const contents = typeof value === 'string' ? Buffer.from(value, 'ascii') : value + return Buffer.concat([encodeUint32(contents.length), contents]) +} + +export function createOpenSshPrivateKeyFixture( + keyTypes: string[], + options: { encrypted?: boolean; cipher?: string; privateBlock?: Buffer; authTag?: Buffer } = {} +): Buffer { + const cipher = options.cipher ?? (options.encrypted ? 'aes256-ctr' : 'none') + const encrypted = cipher !== 'none' + const publicKeys = keyTypes.map((keyType) => sshString(sshString(keyType))) + const decoded = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'ascii'), + sshString(cipher), + sshString(encrypted ? 'bcrypt' : 'none'), + sshString(encrypted ? Buffer.from('fixture-kdf') : Buffer.alloc(0)), + encodeUint32(publicKeys.length), + ...publicKeys, + sshString(options.privateBlock ?? Buffer.from('fixture-private-block')), + options.authTag ?? Buffer.alloc(0) + ]) + const encoded = + decoded + .toString('base64') + .match(/.{1,70}/g) + ?.join('\n') ?? '' + return Buffer.from( + `-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n` + ) +} + +export function createOpenSshPublicKeyFixture(keyType: string): Buffer { + const encoded = sshString(keyType).toString('base64') + return Buffer.from(`${keyType} ${encoded} fixture-comment\n`) +} diff --git a/src/main/ssh/ssh-security-key-identity.test.ts b/src/main/ssh/ssh-security-key-identity.test.ts new file mode 100644 index 00000000000..c424bfa6891 --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.test.ts @@ -0,0 +1,309 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { + isOpenSshSecurityKeyPrivateKey, + isOpenSshSecurityKeyPublicKey +} from './ssh-security-key-identity' +import { + createOpenSshPrivateKeyFixture, + createOpenSshPublicKeyFixture +} from './ssh-security-key-identity.test-fixture' +import { requiresSystemSshForSecurityKey } from './ssh-transport-selection' + +const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() })) + +vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock })) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal<{ homedir: () => string }>() + return { + ...actual, + homedir: () => process.env.ORCA_TEST_SSH_HOME || actual.homedir() + } +}) + +const ED25519_SECURITY_KEY = 'sk-ssh-ed25519@openssh.com' +const ECDSA_SECURITY_KEY = 'sk-ecdsa-sha2-nistp256@openssh.com' +const tempDirs: string[] = [] + +function createTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-1', + label: 'Test Server', + host: 'example.com', + port: 22, + username: 'deploy', + ...overrides + } +} + +beforeEach(() => { + findSystemSshMock.mockReset() + findSystemSshMock.mockReturnValue('/usr/bin/ssh') +}) + +async function writeKey(contents: Buffer, filename = 'security key'): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-')) + tempDirs.push(directory) + const keyPath = join(directory, filename) + await writeFile(keyPath, contents) + return keyPath +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('isOpenSshSecurityKeyPrivateKey', () => { + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'recognizes unencrypted %s keys', + (keyType) => { + expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(true) + } + ) + + it('recognizes authenticated encrypted envelopes with a trailing tag', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], { + cipher: 'aes256-gcm@openssh.com', + authTag: Buffer.alloc(16, 7) + }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'recognizes encrypted %s keys from the public section', + (keyType) => { + const key = createOpenSshPrivateKeyFixture([keyType], { encrypted: true }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true) + } + ) + + it.each(['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ssh-rsa'])( + 'leaves regular %s keys on ssh2', + (keyType) => { + expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(false) + } + ) + + it('supports CRLF armored keys', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + expect( + isOpenSshSecurityKeyPrivateKey(Buffer.from(key.toString().replaceAll('\n', '\r\n'))) + ).toBe(true) + }) + + it('recognizes OpenSSH envelopes without optional base64 padding', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], { + privateBlock: Buffer.alloc(0) + }) + const unpadded = Buffer.from(key.toString().replace(/=+(?=\n-----END)/, '')) + expect(unpadded).not.toEqual(key) + expect(isOpenSshSecurityKeyPrivateKey(unpadded)).toBe(true) + }) + + it('does not match security-key text outside a valid public-key type', () => { + const key = createOpenSshPrivateKeyFixture(['ssh-ed25519'], { + privateBlock: Buffer.from(ED25519_SECURITY_KEY) + }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(false) + expect( + isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`)) + ).toBe(false) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'validates the %s type inside an OpenSSH public key blob', + (keyType) => { + expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture(keyType))).toBe(true) + } + ) + + it('recognizes public keys without optional base64 padding', () => { + const key = createOpenSshPublicKeyFixture(ECDSA_SECURITY_KEY) + const unpadded = Buffer.from(key.toString().replace(/=+(?=\s)/, '')) + expect(unpadded).not.toEqual(key) + expect(isOpenSshSecurityKeyPublicKey(unpadded)).toBe(true) + }) + + it('rejects regular or mismatched OpenSSH public key blobs', () => { + expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false) + expect( + isOpenSshSecurityKeyPublicKey( + Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`) + ) + ).toBe(false) + }) + + it('rejects malformed and truncated OpenSSH envelopes without throwing', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + const malformedLength = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'ascii'), + Buffer.from([0xff, 0xff, 0xff, 0xff]) + ]).toString('base64') + const malformedKey = Buffer.from( + `-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n` + ) + expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false) + expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false) + expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false) + }) +}) + +describe('requiresSystemSshForSecurityKey', () => { + it('uses default FIDO2 identities only when config resolution is unavailable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) + await expect( + requiresSystemSshForSecurityKey(createTarget(), { identityFile: [] }) + ).resolves.toBe(false) + }) + + it('keeps a regular default ahead of a dormant FIDO2 identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa'])) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) + }) + + it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + findSystemSshMock.mockReturnValue(null) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) + }) + + it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519.pub'), + createOpenSshPublicKeyFixture('ssh-ed25519') + ) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) + }) + + it('detects a manual target identity path with spaces', async () => { + const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])) + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null) + ).resolves.toBe(true) + }) + + it('checks every fresh resolved identity for config-backed targets', async () => { + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular') + const securityKey = await writeKey( + createOpenSshPrivateKeyFixture([ECDSA_SECURITY_KEY], { encrypted: true }), + 'security' + ) + const target = createTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: '/stale/security-key' + }) + + await expect( + requiresSystemSshForSecurityKey(target, { identityFile: [regularKey, securityKey] }) + ).resolves.toBe(true) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'detects an agent-backed %s identity from its public sidecar', + async (keyType) => { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-agent-')) + tempDirs.push(directory) + const identityPath = join(directory, 'agent-key') + await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(keyType)) + + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null) + ).resolves.toBe(true) + } + ) + + it('ignores a stale FIDO2 sidecar beside a regular private identity', async () => { + const identityPath = await writeKey( + createOpenSshPrivateKeyFixture(['ssh-ed25519']), + 'regular-with-stale-sidecar' + ) + await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(ED25519_SECURITY_KEY)) + + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null) + ).resolves.toBe(false) + }) + + it('ignores stale imported identity paths when fresh config has regular keys', async () => { + const staleKey = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), 'stale') + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular') + const target = createTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: staleKey + }) + + await expect( + requiresSystemSshForSecurityKey(target, { identityFile: [regularKey] }) + ).resolves.toBe(false) + }) + + it('keeps a manual target identity authoritative over resolved defaults', async () => { + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'manual') + const securityKey = await writeKey( + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), + 'resolved' + ) + + await expect( + requiresSystemSshForSecurityKey( + createTarget({ source: 'manual', identityFile: regularKey }), + { identityFile: [securityKey] } + ) + ).resolves.toBe(false) + }) + + it('degrades to ssh2 when identity files are missing or malformed', async () => { + const malformedKey = await writeKey(Buffer.from('not a key'), 'malformed') + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: malformedKey }), null) + ).resolves.toBe(false) + await expect( + requiresSystemSshForSecurityKey( + createTarget({ identityFile: join(tmpdir(), 'missing-security-key') }), + null + ) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-security-key-identity.ts b/src/main/ssh/ssh-security-key-identity.ts new file mode 100644 index 00000000000..bb1af581d7d --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.ts @@ -0,0 +1,107 @@ +const OPENSSH_PRIVATE_KEY_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----' +const OPENSSH_PRIVATE_KEY_FOOTER = '-----END OPENSSH PRIVATE KEY-----' +const OPENSSH_KEY_MAGIC = Buffer.from('openssh-key-v1\0', 'ascii') +export const MAX_SSH_IDENTITY_FILE_BYTES = 1024 * 1024 +const MAX_PUBLIC_KEYS = 64 +const SECURITY_KEY_TYPES = new Set([ + 'sk-ssh-ed25519@openssh.com', + 'sk-ecdsa-sha2-nistp256@openssh.com' +]) + +type SshString = { value: Buffer; nextOffset: number } + +function decodeBase64(value: string): Buffer | null { + if (!value || value.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + return null + } + const unpadded = value.replace(/=+$/, '') + const decoded = Buffer.from(unpadded, 'base64') + return decoded.toString('base64').replace(/=+$/, '') === unpadded ? decoded : null +} + +function readSshString(buffer: Buffer, offset: number): SshString | null { + if (offset < 0 || offset + 4 > buffer.length) { + return null + } + const length = buffer.readUInt32BE(offset) + const start = offset + 4 + const end = start + length + if (end < start || end > buffer.length) { + return null + } + return { value: buffer.subarray(start, end), nextOffset: end } +} + +function decodeOpenSshPrivateKey(contents: Buffer): Buffer | null { + if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + const lines = contents.toString('ascii').trim().split(/\r?\n/) + if (lines[0] !== OPENSSH_PRIVATE_KEY_HEADER || lines.at(-1) !== OPENSSH_PRIVATE_KEY_FOOTER) { + return null + } + const encoded = lines.slice(1, -1).join('') + const decoded = decodeBase64(encoded) + if (!decoded) { + return null + } + return decoded.subarray(0, OPENSSH_KEY_MAGIC.length).equals(OPENSSH_KEY_MAGIC) ? decoded : null +} + +export function isOpenSshSecurityKeyPrivateKey(contents: Buffer): boolean { + const decoded = decodeOpenSshPrivateKey(contents) + if (!decoded) { + return false + } + + let offset = OPENSSH_KEY_MAGIC.length + for (let field = 0; field < 3; field++) { + const value = readSshString(decoded, offset) + if (!value) { + return false + } + offset = value.nextOffset + } + if (offset + 4 > decoded.length) { + return false + } + + const keyCount = decoded.readUInt32BE(offset) + offset += 4 + if (keyCount === 0 || keyCount > MAX_PUBLIC_KEYS) { + return false + } + + let hasSecurityKey = false + for (let keyIndex = 0; keyIndex < keyCount; keyIndex++) { + const publicKey = readSshString(decoded, offset) + if (!publicKey) { + return false + } + offset = publicKey.nextOffset + const keyType = readSshString(publicKey.value, 0) + if (!keyType) { + return false + } + hasSecurityKey ||= SECURITY_KEY_TYPES.has(keyType.value.toString('ascii')) + } + + const privateBlock = readSshString(decoded, offset) + return privateBlock !== null && hasSecurityKey +} + +export function isOpenSshSecurityKeyPublicKey(contents: Buffer): boolean { + if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) { + return false + } + const [declaredType, encoded] = contents.toString('ascii').trim().split(/\s+/, 3) + if (!declaredType || !encoded || !SECURITY_KEY_TYPES.has(declaredType)) { + return false + } + const decoded = decodeBase64(encoded) + if (!decoded) { + return false + } + const keyType = readSshString(decoded, 0) + return keyType?.value.toString('ascii') === declaredType +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 36312762d08..03e3e8baf53 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -24,7 +24,6 @@ vi.mock('child_process', () => ({ import { buildSshArgs, - findSystemSsh, downloadFileViaSystemSsh, spawnSystemSsh, spawnSystemSshCommand, @@ -152,22 +151,6 @@ function createMockChildProcess(): EventEmitter & { return child } -describe('findSystemSsh', () => { - beforeEach(() => { - existsSyncMock.mockReset() - }) - - it('returns the first existing ssh path', () => { - mockSystemSshExists() - expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH) - }) - - it('returns null when no ssh binary is found', () => { - existsSyncMock.mockReturnValue(false) - expect(findSystemSsh()).toBeNull() - }) -}) - describe('spawnSystemSsh', () => { let mockProc: { stdin: { @@ -827,6 +810,7 @@ describe('spawnSystemSsh', () => { it('throws when no system ssh is found', () => { existsSyncMock.mockReturnValue(false) + vi.stubEnv('PATH', '') expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') }) diff --git a/src/main/ssh/ssh-transport-selection.ts b/src/main/ssh/ssh-transport-selection.ts new file mode 100644 index 00000000000..fdbfb87075f --- /dev/null +++ b/src/main/ssh/ssh-transport-selection.ts @@ -0,0 +1,118 @@ +import { constants } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './ssh-config-parser' +import { listDefaultIdentityFilePaths, resolveIdentityFilePaths } from './ssh-auth-resolution' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { + MAX_SSH_IDENTITY_FILE_BYTES, + isOpenSshSecurityKeyPrivateKey, + isOpenSshSecurityKeyPublicKey +} from './ssh-security-key-identity' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' +import { findSystemSsh } from './system-ssh-binary' + +type TransportResolvedConfig = Pick< + SshResolvedConfig, + 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump' +> + +const READ_CHUNK_BYTES = 64 * 1024 +const READ_OPEN_FLAGS = + constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK) + +type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean } + +async function readBoundedKeyFile(path: string): Promise { + let handle: Awaited> | undefined + try { + const pathStats = await stat(path) + if (!pathStats.isFile() || pathStats.size > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + handle = await open(path, READ_OPEN_FLAGS) + const stats = await handle.stat() + if (!stats.isFile() || stats.size > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + const chunks: Buffer[] = [] + let offset = 0 + while (offset <= MAX_SSH_IDENTITY_FILE_BYTES) { + const buffer = Buffer.alloc( + Math.min(READ_CHUNK_BYTES, MAX_SSH_IDENTITY_FILE_BYTES + 1 - offset) + ) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) + if (bytesRead === 0) { + break + } + offset += bytesRead + if (offset > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + chunks.push(buffer.subarray(0, bytesRead)) + } + return Buffer.concat(chunks, offset) + } catch { + return null + } finally { + await handle?.close().catch(() => undefined) + } +} + +async function inspectIdentityPath(keyPath: string): Promise { + const resolvedPath = resolveSshConfigHomePath(keyPath) + const identity = await readBoundedKeyFile(resolvedPath) + if (identity !== null) { + return { + privateIdentityExists: true, + requiresSystemSsh: + isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity) + } + } + const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`) + return { + privateIdentityExists: false, + requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity) + } +} + +export function shouldUseSystemSshTransport( + target: SshTarget, + resolved: TransportResolvedConfig | null +): boolean { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + resolved.proxyUseFdpass === true || + resolved.proxyCommand != null || + resolved.proxyJump != null + ) + } + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + target.proxyCommand != null || + target.jumpHost != null || + resolved?.proxyUseFdpass === true || + resolved?.proxyCommand != null || + resolved?.proxyJump != null + ) +} + +export async function requiresSystemSshForSecurityKey( + target: SshTarget, + resolved: Pick | null +): Promise { + const configuredPaths = resolveIdentityFilePaths(target, resolved) + const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile + const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths + for (const keyPath of identityPaths) { + const inspection = await inspectIdentityPath(keyPath) + if (inspection.requiresSystemSsh) { + return !usesDefaultPaths || findSystemSsh() !== null + } + if (usesDefaultPaths && inspection.privateIdentityExists) { + return false + } + } + return false +} diff --git a/src/main/ssh/system-ssh-binary.test.ts b/src/main/ssh/system-ssh-binary.test.ts new file mode 100644 index 00000000000..a3be418d2fc --- /dev/null +++ b/src/main/ssh/system-ssh-binary.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { accessSyncMock, existsSyncMock, statSyncMock } = vi.hoisted(() => ({ + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + statSyncMock: vi.fn() +})) + +vi.mock('node:fs', () => ({ + accessSync: accessSyncMock, + constants: { O_RDONLY: 0, O_NONBLOCK: 4, X_OK: 1 }, + existsSync: existsSyncMock, + statSync: statSyncMock +})) + +import { findSystemSsh } from './system-ssh-binary' + +describe('findSystemSsh', () => { + beforeEach(() => { + accessSyncMock.mockReset() + existsSyncMock.mockReset() + statSyncMock.mockReset() + statSyncMock.mockImplementation(() => { + throw new Error('missing') + }) + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('returns the first existing fixed ssh path', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/ssh') + + try { + expect(findSystemSsh()).toBe('/usr/bin/ssh') + } finally { + platformSpy.mockRestore() + } + }) + + it('returns null when no ssh binary is found', () => { + existsSyncMock.mockReturnValue(false) + expect(findSystemSsh()).toBeNull() + }) + + it('finds a PATH-installed ssh.exe on Windows', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('PATH', 'C:\\Git\\usr\\bin;C:\\Tools') + existsSyncMock.mockReturnValue(false) + statSyncMock.mockImplementation((path: string) => { + if (path === 'C:\\Git\\usr\\bin\\ssh.exe') { + return { isFile: () => true } + } + throw new Error('missing') + }) + + try { + expect(findSystemSsh()).toBe('C:\\Git\\usr\\bin\\ssh.exe') + } finally { + platformSpy.mockRestore() + } + }) + + it('finds an executable PATH-installed ssh on POSIX', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + vi.stubEnv('PATH', '/nix/store/ssh/bin:/opt/tools') + existsSyncMock.mockReturnValue(false) + statSyncMock.mockImplementation((path: string) => { + if (path === '/nix/store/ssh/bin/ssh') { + return { isFile: () => true } + } + throw new Error('missing') + }) + + try { + expect(findSystemSsh()).toBe('/nix/store/ssh/bin/ssh') + expect(accessSyncMock).toHaveBeenCalledWith('/nix/store/ssh/bin/ssh', expect.any(Number)) + } finally { + platformSpy.mockRestore() + } + }) + + it('resolves Windows OpenSSH from the runtime system root', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('SystemRoot', 'D:\\Windows') + existsSyncMock.mockImplementation( + (path: string) => path === 'D:\\Windows\\System32\\OpenSSH\\ssh.exe' + ) + + try { + expect(findSystemSsh()).toBe('D:\\Windows\\System32\\OpenSSH\\ssh.exe') + } finally { + platformSpy.mockRestore() + } + }) + + it('keeps an explicit system ssh override authoritative', () => { + vi.stubEnv('ORCA_SYSTEM_SSH_PATH', 'C:\\Custom\\ssh.exe') + + expect(findSystemSsh()).toBe('C:\\Custom\\ssh.exe') + expect(existsSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/system-ssh-binary.ts b/src/main/ssh/system-ssh-binary.ts index 579b483392c..bc8ff3ea516 100644 --- a/src/main/ssh/system-ssh-binary.ts +++ b/src/main/ssh/system-ssh-binary.ts @@ -1,9 +1,41 @@ -import { existsSync } from 'node:fs' +import { accessSync, constants, existsSync, statSync } from 'node:fs' +import { posix, win32 } from 'node:path' -const SYSTEM_SSH_PATHS = - process.platform === 'win32' - ? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe'] - : ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] +function systemSshPaths(platform: NodeJS.Platform): string[] { + if (platform !== 'win32') { + return ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] + } + const systemRoot = process.env.SystemRoot || process.env.WINDIR + return systemRoot ? [win32.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe')] : [] +} + +function findSshOnPath(platform: NodeJS.Platform): string | null { + const pathValue = process.env.PATH + if (!pathValue) { + return null + } + const pathApi = platform === 'win32' ? win32 : posix + const executable = platform === 'win32' ? 'ssh.exe' : 'ssh' + for (const entry of pathValue.split(pathApi.delimiter)) { + const directory = entry.trim().replace(/^"|"$/g, '') + if (!directory) { + continue + } + const candidate = pathApi.join(directory, executable) + try { + if (!statSync(candidate).isFile()) { + continue + } + if (platform !== 'win32') { + accessSync(candidate, constants.X_OK) + } + return candidate + } catch { + continue + } + } + return null +} /** * Find the system ssh binary path. Returns null if not found. @@ -12,10 +44,10 @@ export function findSystemSsh(): string | null { if (process.env.ORCA_SYSTEM_SSH_PATH) { return process.env.ORCA_SYSTEM_SSH_PATH } - for (const candidate of SYSTEM_SSH_PATHS) { + for (const candidate of systemSshPaths(process.platform)) { if (existsSync(candidate)) { return candidate } } - return null + return findSshOnPath(process.platform) } From 16c5526dfda787ee3bac9925532abc2c39b78fc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:35:34 -0700 Subject: [PATCH 20/22] fix(daemon): cover in-flight sleep in PAM watch (#11921) * fix(daemon): rebaseline in-flight PAM suspension * test(activity): await portal readiness commits --- config/reliability-gates.jsonc | 14 ++-- src/main/daemon/login-session-watch-clock.ts | 17 ++++ src/main/daemon/login-session-watch-timing.ts | 19 +++++ .../macos-login-session-death-watch.test.ts | 66 +++++++++++++-- .../daemon/macos-login-session-death-watch.ts | 81 +++++++++---------- ...ty-portal-readiness-loop.react185.test.tsx | 13 ++- 6 files changed, 154 insertions(+), 56 deletions(-) create mode 100644 src/main/daemon/login-session-watch-clock.ts create mode 100644 src/main/daemon/login-session-watch-timing.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 01db646c217..80cb0d5865f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -26,13 +26,13 @@ "providers": ["local-daemon"], "coveredPlatforms": ["macos"], "coveredProviders": ["local-daemon"], - "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, event-trigger preemption, timer suspension, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", + "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, pending-timer and in-flight-probe suspension, event-trigger preemption, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/11749", "https://github.com/stablyai/orca/issues/7936" ], - "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized timer gap resets the rejection window, and client or PTY activity cannot shorten its scheduled backoff.", - "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour without firing the scheduled timer, require rejection evidence to restart after wake, and again recover without retirement. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", + "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized gap before or during a probe resets the rejection window to the periodic cadence, and client or PTY activity cannot shorten its scheduled backoff.", + "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour while a timer is pending and while a PAM probe promise is unresolved; require rejection evidence to restart after wake, zero probes for the next 119,999 milliseconds, and recovery without retirement in both cases. Repeatedly delay timers beyond the suspension threshold for one hour and require the periodic probe bound, one live timer, and zero resolver or retirement calls. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot" ], @@ -43,6 +43,8 @@ "assertions": [ "preserves the daemon when a short PAM rejection burst recovers after wake", "does not count a suspended timer gap as rejection evidence", + "does not count suspension during an in-flight probe as rejection evidence", + "backs off repeated timer lateness to the periodic probe cadence", "retires only after sustained conclusive rejections with a degraded resolver", "suppresses retirement while resolver health is healthy or unknown, then retires on explicit degradation", "stop prevents an in-flight resolver check from retiring the daemon" @@ -57,7 +59,7 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot", "result": "passed", "durationSeconds": 0.1, - "summary": "The focused state-machine suite passed 19 tests, including the field-shaped 20-second rejection burst, suspended-timer rebaselining, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." + "summary": "The focused state-machine suite passed 21 tests, including the field-shaped 20-second rejection burst, pending-timer and in-flight-probe suspension rebaselining, repeated-lateness backoff, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." } ], "runtimeBudget": { @@ -74,7 +76,7 @@ }, "performanceBudget": { "required": true, - "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." + "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Timer lateness and in-flight probe suspension both rebaseline evidence to the periodic cadence, preventing an App Nap retry loop, and resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." }, "promotionCriteria": [ "Collect 100 consecutive focused CI passes or 14 days of soak history.", @@ -83,7 +85,7 @@ ], "knownGaps": [ "A real dead macOS GUI login session cannot be fabricated without ending the runner's login session; sustained-death coverage uses deterministic PAM and resolver oracles.", - "The two-minute production window and one-hour timer suspension are covered by fake-clock tests rather than a real sleep/wake run.", + "The two-minute production window and one-hour pending/in-flight suspension cases are covered by fake-clock tests rather than a real sleep/wake run.", "No multi-process aggregate throttle is added; each stale daemon independently obeys the same non-preemptible observation window." ], "demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault." diff --git a/src/main/daemon/login-session-watch-clock.ts b/src/main/daemon/login-session-watch-clock.ts new file mode 100644 index 00000000000..8082545815b --- /dev/null +++ b/src/main/daemon/login-session-watch-clock.ts @@ -0,0 +1,17 @@ +export type LoginSessionWatchClock = { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number +} + +export function createLoginSessionWatchClock(): LoginSessionWatchClock { + return { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } +} diff --git a/src/main/daemon/login-session-watch-timing.ts b/src/main/daemon/login-session-watch-timing.ts new file mode 100644 index 00000000000..8bed6decba1 --- /dev/null +++ b/src/main/daemon/login-session-watch-timing.ts @@ -0,0 +1,19 @@ +export type LoginSessionWatchTiming = { + periodicProbeMs: number + rejectionRecheckMs: number + minimumRejectionSpanMs: number + ptyExitDebounceMs: number + clientActivityMinGapMs: number + minProbeGapMs: number +} + +export const DEFAULT_LOGIN_SESSION_WATCH_TIMING: LoginSessionWatchTiming = { + periodicProbeMs: 120_000, + rejectionRecheckMs: 10_000, + // Why: a full periodic window separates logout from short wake/PAM recovery bursts. + minimumRejectionSpanMs: 120_000, + ptyExitDebounceMs: 2_000, + // Why: steady reconnects must not turn client hellos into a PAM probe storm. + clientActivityMinGapMs: 30_000, + minProbeGapMs: 5_000 +} diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts index fa983508ec5..1be9aecca64 100644 --- a/src/main/daemon/macos-login-session-death-watch.test.ts +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -158,7 +158,7 @@ describe('MacosLoginSessionDeathWatch', () => { it('does not count a suspended timer gap as rejection evidence', async () => { const readResolverHealth = vi.fn(async () => 'unhealthy' as const) const { watch, clock, onRetire, probe } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED], readResolverHealth }) watch.start() @@ -167,15 +167,69 @@ describe('MacosLoginSessionDeathWatch', () => { await clock.advance(120_000) clock.suspend(60 * 60 * 1000) await clock.advance(0) - await clock.advance(10_000) - await clock.advance(10_000) - expect(probe).toHaveBeenCalledTimes(5) + expect(probe).toHaveBeenCalledTimes(3) expect(readResolverHealth).not.toHaveBeenCalled() expect(onRetire).not.toHaveBeenCalled() - await clock.advance(100_000) - expect(probe).toHaveBeenCalledTimes(6) + await clock.advance(119_999) + expect(probe).toHaveBeenCalledTimes(3) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(4) + expect(onRetire).not.toHaveBeenCalled() + }) + + it('does not count suspension during an in-flight probe as rejection evidence', async () => { + let resolveProbe!: (outcome: LoginPreflightOutcome) => void + const deferredProbe = new Promise((resolve) => { + resolveProbe = resolve + }) + const probe = vi + .fn() + .mockResolvedValueOnce(ACCEPTED) + .mockResolvedValueOnce(REJECTED) + .mockReturnValueOnce(deferredProbe) + .mockResolvedValueOnce(ACCEPTED) + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe, readResolverHealth }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + await clock.advance(10_000) + clock.suspend(60 * 60 * 1000) + resolveProbe(REJECTED) + await drainMicrotasks() + + expect(probe).toHaveBeenCalledTimes(3) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(119_999) + expect(probe).toHaveBeenCalledTimes(3) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(4) + expect(onRetire).not.toHaveBeenCalled() + }) + + it('backs off repeated timer lateness to the periodic probe cadence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, ...Array.from({ length: 30 }, () => REJECTED)], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + + for (let i = 0; i < 27; i++) { + clock.suspend(135_001) + await clock.advance(0) + } + + expect(probe).toHaveBeenCalledTimes(29) + expect(clock.pendingCount()).toBe(1) + expect(readResolverHealth).not.toHaveBeenCalled() expect(onRetire).not.toHaveBeenCalled() }) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index ce803ff409b..1f529ff3e00 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -1,24 +1,17 @@ import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' import type { DaemonFileLog } from './daemon-file-log' +import { + createLoginSessionWatchClock, + type LoginSessionWatchClock +} from './login-session-watch-clock' +import { + DEFAULT_LOGIN_SESSION_WATCH_TIMING, + type LoginSessionWatchTiming +} from './login-session-watch-timing' import type { SystemResolverHealth } from './types' // Why: retain count confirmation while the elapsed window below blocks short bursts. const REQUIRED_CONSECUTIVE_REJECTIONS = 3 -const PERIODIC_PROBE_MS = 120_000 -const REJECTION_RECHECK_MS = 10_000 -// Why: a full periodic window separates logout from short wake/PAM recovery bursts. -const MINIMUM_REJECTION_SPAN_MS = PERIODIC_PROBE_MS -const PTY_EXIT_DEBOUNCE_MS = 2_000 -// Why: a client hello right after login is the fastest death signal for a stale -// daemon, but steady reconnects must not turn hellos into a PAM probe storm. -const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000 -const MIN_PROBE_GAP_MS = 5_000 - -type WatchClock = { - setTimeout(callback: () => void, delayMs: number): unknown - clearTimeout(handle: unknown): void - now(): number -} export type MacosLoginSessionDeathWatchOptions = { /** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */ @@ -31,15 +24,8 @@ export type MacosLoginSessionDeathWatchOptions = { }) => void log: DaemonFileLog /** Direct-construction seam for deterministic tests; production uses real timers. */ - clock?: WatchClock - timing?: Partial<{ - periodicProbeMs: number - rejectionRecheckMs: number - minimumRejectionSpanMs: number - ptyExitDebounceMs: number - clientActivityMinGapMs: number - minProbeGapMs: number - }> + clock?: LoginSessionWatchClock + timing?: Partial } /** @@ -63,7 +49,7 @@ export class MacosLoginSessionDeathWatch { private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth'] private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire'] private readonly log: DaemonFileLog - private readonly clock: WatchClock + private readonly clock: LoginSessionWatchClock private readonly periodicProbeMs: number private readonly rejectionRecheckMs: number private readonly minimumRejectionSpanMs: number @@ -91,21 +77,16 @@ export class MacosLoginSessionDeathWatch { this.readResolverHealth = opts.readResolverHealth this.onRetire = opts.onRetire this.log = opts.log - this.clock = opts.clock ?? { - setTimeout: (callback, delayMs) => { - const timer = setTimeout(callback, delayMs) - timer.unref() - return timer - }, - clearTimeout: (handle) => clearTimeout(handle as ReturnType), - now: () => Date.now() - } - this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS - this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS - this.minimumRejectionSpanMs = opts.timing?.minimumRejectionSpanMs ?? MINIMUM_REJECTION_SPAN_MS - this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS - this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS - this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS + this.clock = opts.clock ?? createLoginSessionWatchClock() + const defaults = DEFAULT_LOGIN_SESSION_WATCH_TIMING + this.periodicProbeMs = opts.timing?.periodicProbeMs ?? defaults.periodicProbeMs + this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? defaults.rejectionRecheckMs + this.minimumRejectionSpanMs = + opts.timing?.minimumRejectionSpanMs ?? defaults.minimumRejectionSpanMs + this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? defaults.ptyExitDebounceMs + this.clientActivityMinGapMs = + opts.timing?.clientActivityMinGapMs ?? defaults.clientActivityMinGapMs + this.minProbeGapMs = opts.timing?.minProbeGapMs ?? defaults.minProbeGapMs } start(): void { @@ -222,10 +203,12 @@ export class MacosLoginSessionDeathWatch { } const timerGapMs = scheduledAtMs === undefined ? 0 : Math.max(0, this.clock.now() - scheduledAtMs) + let rejectionWindowRebased = false if (this.consecutiveRejections > 0 && timerGapMs > this.minProbeGapMs * 3) { // Why: sleep/App Nap pauses probes; elapsed wall time is not rejection evidence. this.consecutiveRejections = 0 this.firstRejectionAtMs = null + rejectionWindowRebased = true this.log.log('login-session-rejection-window-reset', { cause: 'timer-gap', timerGapMs @@ -242,7 +225,8 @@ export class MacosLoginSessionDeathWatch { return } this.probeInFlight = true - this.lastProbeStartedAtMs = this.clock.now() + const probeStartedAtMs = this.clock.now() + this.lastProbeStartedAtMs = probeStartedAtMs const abortController = new AbortController() this.probeAbortController = abortController try { @@ -250,6 +234,17 @@ export class MacosLoginSessionDeathWatch { if (this.stopped || this.retired) { return } + const probeGapMs = Math.max(0, this.clock.now() - probeStartedAtMs) + if (this.consecutiveRejections > 0 && probeGapMs > this.minProbeGapMs * 3) { + // Why: sleep can suspend an in-flight PAM probe after the timer-gap check. + this.consecutiveRejections = 0 + this.firstRejectionAtMs = null + rejectionWindowRebased = true + this.log.log('login-session-rejection-window-reset', { + cause: 'probe-gap', + probeGapMs + }) + } if (outcome === null) { // Why: no wrapper machinery means no PAM oracle — watching would only ever misfire. this.log.log('login-session-watch-disabled', { trigger }) @@ -284,7 +279,9 @@ export class MacosLoginSessionDeathWatch { rejections: this.consecutiveRejections }) if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) { - this.scheduleNextProbe(this.rejectionRecheckMs) + this.scheduleNextProbe( + rejectionWindowRebased ? this.periodicProbeMs : this.rejectionRecheckMs + ) return } const rejectionSpanMs = this.clock.now() - this.firstRejectionAtMs diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 14a1967a329..50190295daa 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -76,6 +76,15 @@ function installAnimationFrameController(): { } } +async function flushPortalFramesUntil( + frames: ReturnType, + settled: () => boolean +): Promise { + for (let frame = 0; frame < 4 && !settled(); frame += 1) { + await frames.flush() + } +} + // Models the tab-root DOM and sibling hiding emitted by a portaled TerminalPane. function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: string[]): void { const isolatedLeafId = leafIds[0] @@ -345,7 +354,7 @@ describe('Activity portal pane switching', () => { await act(async () => { root.render() }) - await frames.flush() + await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'unavailable') expect(statuses.at(-1)).toBe('unavailable') // Feed each observed DOM state separately so MutationObserver cannot coalesce the flips. @@ -362,7 +371,7 @@ describe('Activity portal pane switching', () => { buildRoot('ready') await Promise.resolve() }) - await frames.flush() + await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') expect(statuses.at(-1)).toBe('ready') }) }) From 5c0195af64961092150ef76666e51f47dcf13517 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:55:58 -0700 Subject: [PATCH 21/22] Bound remote watcher fan-out and defer File Explorer refreshes (#11908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * batch remote watcher events and defer File Explorer refreshes Remote filesystem watcher events now batch with the shared 150ms trailing and 500ms max-wait window, coalescing per-path like local events. File Explorer tree and directory refreshes are scheduled with debounce and transport-aware concurrency caps (16 local, 8 runtime, 4 SSH). Stale directory cache tracking prevents trusting collapsed listings skipped by full refresh; they are re-read on re-expansion. Relay implements a 15-minute idle-only grace cap for zero-PTY relays via PTY pool lifecycle tracking, independent of explicitly configured grace time. * fix(watch/relay): bound remote watcher fan-out and read the live relay grace Three P1 fixes from the SSH/remote freeze audit: - Remote watchers now debounce on the same 150/500 window as local ones (finding D), and every teardown path drops the trailing flush timer instead of letting it fire into a dead watch. The deferred send is wrapped so a frame disposed mid-window can't escape as a fatal main-process exception. - File Explorer refreshes are scheduled and concurrency-capped rather than fanned out unbounded over expanded dirs (finding C). Local transports use a zero window, since main already coalesced the burst. - relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the launch-time argv closure, so a grace raised after launch is honored. The branch selection moves to relay-grace-branch.ts because relay.ts has no exports and calls main() at import, making it untestable. Consequence: a host-sleep relay holding zero PTYs now exits after the idle cap. Pinned by test and documented in docs/reference/relay-grace-time-reconfiguration.md. Also drops the duplicated 150/500/5000 constants in the runtime-RPC batcher in favor of the shared window module. * docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit Co-authored-by: Orca * refactor(file-explorer): use useMemo for paths; remove relay reference Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle. * rm design doc * fix(remote-watcher): prevent stranded timer after close An in-flight provider receive can land after the batch is torn down. Without a guard, pushing events to a closed batch would re-arm a timer that would never be cleared, stranding the task indefinitely. Track the closed state and skip pushes after close(). Relay.ts comment clarifies why pool watches remain registered during grace-period shutdown deferral — the socket server stays listening so a reconnecting client can cancel the grace and resume. --------- Co-authored-by: Orca --- .gitignore | 1 + .../filesystem-watcher-large-batch.test.ts | 62 +++ .../filesystem-watcher-remote-batch.test.ts | 191 +++++++++ ...system-watcher-remote-cancellation.test.ts | 3 +- src/main/ipc/filesystem-watcher.test.ts | 31 +- src/main/ipc/filesystem-watcher.ts | 77 +++- .../ipc/remote-watcher-event-batch.test.ts | 270 +++++++++++++ src/main/ipc/remote-watcher-event-batch.ts | 147 +++++++ .../rpc/methods/file-watch-event-batcher.ts | 15 +- src/relay/pty-handler.test.ts | 189 +++++++++ src/relay/pty-handler.ts | 74 +++- src/relay/relay-grace-branch.test.ts | 100 +++++ src/relay/relay-grace-branch.ts | 63 +++ src/relay/relay.ts | 91 ++++- src/relay/subprocess.test.ts | 255 ++++++++++++ .../components/right-sidebar/FileExplorer.tsx | 13 +- ...le-explorer-expanded-dirs-refresh.test.ts} | 146 ++++++- .../file-explorer-expanded-dirs-refresh.ts | 137 +++++++ .../file-explorer-refresh-concurrency.test.ts | 36 ++ .../file-explorer-refresh-concurrency.ts | 31 ++ .../file-explorer-stale-dir-cache.test.ts | 53 +++ .../file-explorer-stale-dir-cache.ts | 34 ++ .../right-sidebar/file-explorer-types.ts | 9 + .../file-explorer-watch-reconcile.test.ts | 29 ++ ...e-explorer-watch-refresh-scheduler.test.ts | 382 ++++++++++++++++++ .../file-explorer-watch-refresh-scheduler.ts | 152 +++++++ .../useFileExplorerManualRefresh.ts | 2 +- .../useFileExplorerTree.stale-dirs.test.tsx | 155 +++++++ .../right-sidebar/useFileExplorerTree.ts | 174 +++----- ...orerVisibleRowProjection.debounce.test.tsx | 49 ++- .../useFileExplorerVisibleRowProjection.ts | 27 +- .../right-sidebar/useFileExplorerWatch.ts | 44 +- src/shared/filesystem-watch-batch-window.ts | 5 + 33 files changed, 2847 insertions(+), 200 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-remote-batch.test.ts create mode 100644 src/main/ipc/remote-watcher-event-batch.test.ts create mode 100644 src/main/ipc/remote-watcher-event-batch.ts create mode 100644 src/relay/relay-grace-branch.test.ts create mode 100644 src/relay/relay-grace-branch.ts rename src/renderer/src/components/right-sidebar/{useFileExplorerTree.test.ts => file-explorer-expanded-dirs-refresh.test.ts} (58%) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx create mode 100644 src/shared/filesystem-watch-batch-window.ts diff --git a/.gitignore b/.gitignore index 51306ff2066..2918ae32a69 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/relay-grace-time-reconfiguration.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 01f7ccc90cc..80d45c90e43 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -32,6 +32,11 @@ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesyste import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import type { Event as WatcherEvent } from '@parcel/watcher' +import type { FsChangedPayload } from '../../shared/types' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../shared/filesystem-watch-batch-window' type HandlerMap = Record Promise | unknown> @@ -111,4 +116,61 @@ describe('local filesystem watcher large batches', () => { await closeAllWatchers() vi.useRealTimers() }) + + it('still coalesces a local burst on the shared trailing window', async () => { + vi.useFakeTimers() + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: vi.fn() } as never + }) + const worktreePath = resolve('/tmp/repo') + const filePath = join(worktreePath, 'a.ts') + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath }) + watcherCallback?.(null, [{ type: 'update', path: filePath }]) + watcherCallback?.(null, [{ type: 'update', path: filePath }]) + + await vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS - 1) + expect(sender.send).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath, + events: [{ kind: 'update', absolutePath: filePath, isDirectory: true }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('still flushes a sustained local stream at the shared max wait', async () => { + vi.useFakeTimers() + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: vi.fn() } as never + }) + const worktreePath = resolve('/tmp/repo') + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath }) + // Step under the trailing window so only the max wait can force a flush. + const step = 100 + for (let elapsed = 0; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += step) { + watcherCallback?.(null, [{ type: 'update', path: join(worktreePath, `f-${elapsed}.ts`) }]) + if (elapsed < WATCH_BATCH_MAX_WAIT_MS) { + expect(sender.send).not.toHaveBeenCalled() + } + await vi.advanceTimersByTimeAsync(step) + } + + expect(sender.send).toHaveBeenCalledTimes(1) + expect((sender.send.mock.calls[0][1] as FsChangedPayload).events).toHaveLength(6) + await closeAllWatchers() + vi.useRealTimers() + }) }) diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts new file mode 100644 index 00000000000..6d964c2b2d4 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' + +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> +type WatchCallback = (events: FsChangeEvent[]) => void + +const WORKTREE_PATH = '/home/me/repo' +const WATCH_ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } + +describe('remote filesystem watcher batching', () => { + const handlers: HandlerMap = {} + const watchCallbacks: WatchCallback[] = [] + + function makeSender(overrides: Partial<{ isDestroyed: () => boolean }> = {}) { + return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1, ...overrides } + } + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + watchCallbacks.length = 0 + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + getSshFilesystemProviderMock.mockReturnValue({ + watch: vi.fn(async (_root: string, callback: WatchCallback) => { + watchCallbacks.push(callback) + return vi.fn() + }) + }) + }) + + it('coalesces a burst of remote events into a single fs:changed send', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + for (let i = 0; i < 50; i++) { + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + } + expect(sender.send).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + // An atomic replace (delete+create) followed by an rm inside one window must still tombstone the path. + it('sends the net delete when a remote replace is removed in the same window', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([ + { kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }, + { kind: 'create', absolutePath: `${WORKTREE_PATH}/dist/app.js` } + ]) + watchCallbacks[0]([{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }]) + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('collapses an oversized remote burst into one overflow payload', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]( + Array.from( + { length: 6_000 }, + (_unused, index): FsChangeEvent => ({ + kind: 'update', + absolutePath: `${WORKTREE_PATH}/file-${index}.ts` + }) + ) + ) + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('sends nothing when the worktree is unwatched before the flush deadline', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await handlers['fs:unwatchWorktree']({ sender }, WATCH_ARGS) + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) + + it('sends nothing when all watchers close before the flush deadline', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await closeAllWatchers() + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('skips a WebContents destroyed while the batch was buffering', async () => { + vi.useFakeTimers() + let destroyed = false + const sender = makeSender({ isDestroyed: () => destroyed }) + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + destroyed = true + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) + + it('drops a stale flush once a newer install owns the key', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + for (const listener of providerRegistrationListeners) { + listener('conn-1') + } + await vi.advanceTimersByTimeAsync(1_000) + expect(watchCallbacks).toHaveLength(2) + sender.send.mockClear() + + // The replaced transport's callback still holds its own batch; its flush must not reach the renderer. + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index f4af15588be..6a67f3a8fb7 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -113,7 +113,8 @@ describe('remote filesystem watcher cancellation', () => { await Promise.all([first, second]) expect(watchMock).toHaveBeenCalledTimes(2) secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) - expect(secondSender.send).toHaveBeenCalledTimes(1) + // Why: remote fs:changed rides the shared debounce window, so the send lands a flush later. + await vi.waitFor(() => expect(secondSender.send).toHaveBeenCalledTimes(1)) handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) expect(secondUnwatch).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index b2f83baaf22..1e9e3cf509a 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -58,9 +58,18 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { acquireWatcherRemovalGate } from './watcher-removal-gate' +import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' type HandlerMap = Record Promise | unknown> +/** Remote fs:changed rides the shared debounce window, so drain it before asserting sends. */ +const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => { + onEvents(events) + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS) + : new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 25))) +} + describe('registerFilesystemWatcherHandlers', () => { const handlers: HandlerMap = {} const originalPlatform = process.platform @@ -234,7 +243,7 @@ describe('registerFilesystemWatcherHandlers', () => { onTerminalError: expect.any(Function) }) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ path: '/home/me/repo/file.txt', type: 'update' }] @@ -329,7 +338,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(watchMock).toHaveBeenCalledTimes(2) const recoveredEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - recoveredEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(recoveredEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(senderOne.send).not.toHaveBeenCalled() expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', @@ -369,7 +378,7 @@ describe('registerFilesystemWatcherHandlers', () => { ) const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - reinstalledEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] @@ -552,7 +561,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(watchMock).toHaveBeenCalledTimes(1) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -621,7 +630,7 @@ describe('registerFilesystemWatcherHandlers', () => { events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] }) const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - replacementEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenLastCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] @@ -716,7 +725,7 @@ describe('registerFilesystemWatcherHandlers', () => { 'still watched by another client' ) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) await handlers['fs:watchWorktree']( @@ -724,7 +733,7 @@ describe('registerFilesystemWatcherHandlers', () => { { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) expect(watchMock).toHaveBeenCalledTimes(1) - onEvents([{ path: '/home/me/repo/file-2.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file-2.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(2) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -763,7 +772,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -809,7 +818,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).not.toHaveBeenCalled() @@ -855,7 +864,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(unwatchMock).toHaveBeenCalledTimes(1) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sender.send).not.toHaveBeenCalled() }) @@ -886,7 +895,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(senderOne.send).not.toHaveBeenCalled() expect(senderTwo.send).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 58401722494..571f6c46b3a 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -16,6 +16,14 @@ import { onSshFilesystemProviderRegistered } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' +import { + createRemoteWatcherEventBatch, + type RemoteWatcherEventBatch +} from './remote-watcher-event-batch' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../shared/filesystem-watch-batch-window' import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' import { isWatcherProcessFailure } from './parcel-watcher-process-failure' import { @@ -32,11 +40,6 @@ import { // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' -// ── Debounce helpers ───────────────────────────────────────────────── - -const DEBOUNCE_TRAILING_MS = 150 -const DEBOUNCE_MAX_WAIT_MS = 500 - // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -349,7 +352,7 @@ function scheduleBatchFlush(root: WatchedRoot): void { } // If we've exceeded the max wait, flush immediately - if (now - root.batch.firstEventAt >= DEBOUNCE_MAX_WAIT_MS) { + if (now - root.batch.firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) { if (root.batch.timer) { clearTimeout(root.batch.timer) } @@ -361,7 +364,7 @@ function scheduleBatchFlush(root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS) + root.batch.timer = setTimeout(() => void flushBatch(root), WATCH_BATCH_TRAILING_MS) } // ── Watcher creation ───────────────────────────────────────────────── @@ -944,6 +947,8 @@ type RemoteWatcherState = { unwatch: () => void listeners: Map installToken: RemoteWatcherInstallToken + /** Held so every teardown can drop the trailing flush timer instead of letting it fire into a dead watch. */ + batch: RemoteWatcherEventBatch } type RemoteWatcherInstallToken = { @@ -1049,6 +1054,7 @@ export async function closeRemoteWatcherForWorktreePath( await (provider?.closeWatch ? provider.closeWatch(worktreePath) : Promise.resolve(state?.unwatch())) + state?.batch.close() remoteWatchers.delete(key) loggedUnavailableRemoteWatchers.delete(key) } @@ -1161,6 +1167,7 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { return } state.unwatch() + state.batch.close() remoteWatchers.delete(key) } @@ -1287,23 +1294,47 @@ async function doInstallRemoteWatcher( cancelToken: RemoteWatcherInstallToken ): Promise { let unwatch: () => void - try { - unwatch = await provider.watch( - worktreePath, - (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return + // Why: the abandon paths below close the batch directly; once it is stored on the remoteWatchers + // entry, every later teardown closes it through that handle. deliver's state/installToken guard + // still covers a flush that races a close. + const batch = createRemoteWatcherEventBatch({ + rootPath: worktreePath, + trailingMs: WATCH_BATCH_TRAILING_MS, + maxWaitMs: WATCH_BATCH_MAX_WAIT_MS, + maxEvents: MAX_BATCHED_WATCHER_EVENTS, + deliver: (events) => { + const state = remoteWatchers.get(key) + // Why: buffering defers delivery past install, so a bare non-null check would let a stale flush + // land on a different install generation. + if (!state || state.installToken !== cancelToken) { + return + } + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue - } + try { listener.send('fs:changed', { worktreePath, events } satisfies FsChangedPayload) + } catch (err) { + // Why: batching moved this send out of the SSH mux's notification try/catch into a bare + // timer, so a frame disposed mid-window would escape as a fatal main-process exception. + console.warn( + `[filesystem-watch] failed to deliver remote fs:changed for ${worktreePath}: ${ + err instanceof Error ? err.message : String(err) + }` + ) } + } + } + }) + try { + unwatch = await provider.watch( + worktreePath, + (events) => { + batch.push(events) }, { signal: cancelToken.abortController.signal, @@ -1312,6 +1343,9 @@ async function doInstallRemoteWatcher( } ) } catch (err) { + // Why: the provider can fire its callback before rejecting (an aborted reinstall), so this + // abandon path owes the same close as the two below — nothing else holds the batch. + batch.close() if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { return 'cancelled' } @@ -1326,6 +1360,7 @@ async function doInstallRemoteWatcher( Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed()) ) if (cancelToken.cancelled || liveListeners.size === 0) { + batch.close() try { unwatch() } catch (err) { @@ -1334,9 +1369,10 @@ async function doInstallRemoteWatcher( return 'cancelled' } if (cancelToken.terminalError) { + batch.close() return 'unavailable' } - remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken }) + remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken, batch }) for (const listener of liveListeners.values()) { registerSenderCleanup(listener) } @@ -1357,6 +1393,7 @@ function handleRemoteWatcherTerminalError( return } remoteWatchers.delete(key) + state.batch.close() if (remoteWatchersClosed || suspendedRemoteWatcherListeners.has(key)) { return } @@ -1779,6 +1816,7 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void { const stale = remoteWatchers.get(key) if (stale) { remoteWatchers.delete(key) + stale.batch.close() try { stale.unwatch() } catch { @@ -1912,6 +1950,7 @@ export async function closeAllWatchers(): Promise { // Why: remote watchers are separate from local @parcel/watcher subs; unwatch here or the relay keeps polling FS after shutdown. for (const [key, state] of remoteWatchers) { + state.batch.close() try { state.unwatch() } catch (err) { diff --git a/src/main/ipc/remote-watcher-event-batch.test.ts b/src/main/ipc/remote-watcher-event-batch.test.ts new file mode 100644 index 00000000000..b659c446aed --- /dev/null +++ b/src/main/ipc/remote-watcher-event-batch.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' +import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch' + +const ROOT = '/home/u/repo' + +function makeBatch(deliver: (events: FsChangeEvent[]) => void, maxEvents = 5_000) { + return createRemoteWatcherEventBatch({ + rootPath: ROOT, + deliver, + trailingMs: 150, + maxWaitMs: 500, + maxEvents + }) +} + +describe('createRemoteWatcherEventBatch', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses many pushes inside the trailing window into one delivery', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + for (let i = 0; i < 50; i++) { + batch.push([{ kind: 'update', absolutePath: `${ROOT}/file-${i}.ts` }]) + vi.advanceTimersByTime(1) + } + expect(deliver).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toHaveLength(50) + }) + + it('flushes at the max wait when pushes never stop', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + for (let i = 0; i < 10; i++) { + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + vi.advanceTimersByTime(100) + } + + expect(deliver).toHaveBeenCalled() + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + }) + + it('emits both events for delete then create, delete first', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + }) + + it('drops both events for create then delete', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'create', absolutePath: `${ROOT}/tmp.ts` }, + { kind: 'delete', absolutePath: `${ROOT}/tmp.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver).not.toHaveBeenCalled() + }) + + it('clears a stale delete when a later update is known to be a file', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` }, + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + }) + + it('keeps the delete when a later update has unknown isDirectory', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'create', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist` } + ]) + }) + + it('emits the net delete for delete then create then delete', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }, + { kind: 'create', absolutePath: `${ROOT}/dist/app.js` }, + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` } + ]) + }) + + it('re-hoists the delete when the window ends on a recreate', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` }, + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + }) + + it('keeps only the last update for one path', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }]) + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + }) + + it('discards the buffer and delivers one overflow when an overflow event arrives', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + batch.push([{ kind: 'overflow', absolutePath: ROOT }]) + batch.push([{ kind: 'update', absolutePath: `${ROOT}/b.ts` }]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }]) + }) + + it('latches overflow once the accumulated batch exceeds maxEvents', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver, 100) + + for (let i = 0; i < 3; i++) { + batch.push( + Array.from( + { length: 50 }, + (_unused, index): FsChangeEvent => ({ + kind: 'update', + absolutePath: `${ROOT}/wave-${i}-${index}.ts` + }) + ) + ) + } + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }]) + }) + + it('never invents isDirectory for events that arrive without it', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'create', absolutePath: `${ROOT}/dir` }]) + vi.advanceTimersByTime(150) + + const [delivered] = deliver.mock.calls[0][0] as FsChangeEvent[] + expect(delivered.isDirectory).toBeUndefined() + expect('isDirectory' in delivered).toBe(false) + }) + + it('passes an unknown kind through verbatim after the coalesced set', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` }, + { kind: 'update', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts` }, + { kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` } + ]) + }) + + it('delivers nothing after close', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + batch.close() + vi.advanceTimersByTime(1_000) + + expect(deliver).not.toHaveBeenCalled() + }) + + it('ignores a push that lands after close instead of arming a timer', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.close() + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(1_000) + expect(deliver).not.toHaveBeenCalled() + }) + + // Why: the host platform is irrelevant — the coalesce key must stay shape-based, so a case-sensitive + // remote pair must survive it on any host, and neither path may pick up drive/backslash rewriting. + it('keeps case-distinct remote POSIX paths separate and byte-identical', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: '/home/u/A' }]) + batch.push([{ kind: 'update', absolutePath: '/home/u/a' }]) + vi.advanceTimersByTime(150) + + const delivered = deliver.mock.calls[0][0] as FsChangeEvent[] + expect(delivered).toEqual([ + { kind: 'update', absolutePath: '/home/u/A' }, + { kind: 'update', absolutePath: '/home/u/a' } + ]) + for (const event of delivered) { + expect(event.absolutePath).not.toContain('\\') + expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/) + } + }) +}) diff --git a/src/main/ipc/remote-watcher-event-batch.ts b/src/main/ipc/remote-watcher-event-batch.ts new file mode 100644 index 00000000000..86f10e81d96 --- /dev/null +++ b/src/main/ipc/remote-watcher-event-batch.ts @@ -0,0 +1,147 @@ +import type { FsChangeEvent } from '../../shared/types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +export type RemoteWatcherEventBatchOptions = { + rootPath: string + deliver: (events: FsChangeEvent[]) => void + trailingMs: number + maxWaitMs: number + maxEvents: number +} + +export type RemoteWatcherEventBatch = { + push: (events: FsChangeEvent[]) => void + close: () => void +} + +// Mirrors coalesceEvents in filesystem-watcher.ts, with two deliberate divergences: a hoisted delete +// survives a later event of unknown isDirectory, so delete→create→update over-emits a delete; and +// delete→create→delete emits that delete, which local drops — the path is gone, so the renderer needs it. +function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { + const lastByKey = new Map() + const deleteBeforeCreate = new Map() + const passthrough: FsChangeEvent[] = [] + + for (const event of raw) { + if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') { + // Why: 'rename' has no producer on this transport; forward it untouched rather than invent a rule. + passthrough.push(event) + continue + } + // Why: key on the comparison form, never node path.resolve — resolving a remote POSIX path on a + // Windows host mints C:\home\u\f, which the renderer's watch canonicalizer then rejects outright. + // Residual cost: its NFC fold merges canonically equivalent byte-distinct names on a POSIX host. + const key = normalizeRuntimePathForComparison(event.absolutePath) + const prev = lastByKey.get(key) + + if (prev) { + if (prev.kind === 'delete' && event.kind === 'create') { + deleteBeforeCreate.set(key, prev) + } + if (prev.kind === 'create' && event.kind === 'delete') { + // Why: cancel only a path created and removed entirely inside the window; an earlier delete means + // the file predates it, so the window nets out to a delete the renderer still has to act on. + const netNoOp = !deleteBeforeCreate.delete(key) + if (netNoOp) { + lastByKey.delete(key) + continue + } + } + } + + lastByKey.set(key, event) + + // Why: drop the hoisted delete only when the later event proves the path is a live file, or repeats the + // delete. Remote events carry no isDirectory, and discarding it there strands a dead dir's cached subtree. + if (event.kind === 'delete' || (event.kind === 'update' && event.isDirectory === false)) { + deleteBeforeCreate.delete(key) + } + } + + return [...deleteBeforeCreate.values(), ...lastByKey.values(), ...passthrough] +} + +export function createRemoteWatcherEventBatch({ + rootPath, + deliver, + trailingMs, + maxWaitMs, + maxEvents +}: RemoteWatcherEventBatchOptions): RemoteWatcherEventBatch { + let buffered: FsChangeEvent[] = [] + let overflowed = false + let firstEventAt = 0 + let timer: ReturnType | null = null + let closed = false + + function flush(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + const pending = buffered + const latched = overflowed + buffered = [] + overflowed = false + firstEventAt = 0 + + if (latched) { + deliver([{ kind: 'overflow', absolutePath: rootPath }]) + return + } + const coalesced = coalesceRemoteEvents(pending) + if (coalesced.length > 0) { + deliver(coalesced) + } + } + + function schedule(): void { + const now = Date.now() + if (firstEventAt === 0) { + firstEventAt = now + } + if (now - firstEventAt >= maxWaitMs) { + flush() + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(flush, trailingMs) + } + + return { + push(events) { + // Why: an in-flight provider receive can land after teardown; re-arming there would strand a timer. + if (closed) { + return + } + if (!overflowed) { + if ( + buffered.length + events.length > maxEvents || + events.some((event) => event.kind === 'overflow') + ) { + // Why: past the cap precision only burns memory; one overflow buys the same conservative refresh. + buffered = [] + overflowed = true + } else { + // Why: a deletion storm can exceed V8's argument limit for push(...events). + for (const event of events) { + buffered.push(event) + } + } + } + schedule() + }, + close() { + closed = true + if (timer) { + clearTimeout(timer) + timer = null + } + buffered = [] + overflowed = false + firstEventAt = 0 + } + } +} diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts index de96b7b6855..24e93e2affc 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -1,8 +1,9 @@ +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../../../shared/filesystem-watch-batch-window' import type { FsChangeEvent } from '../../../../shared/types' - -const FILE_WATCH_FLUSH_MS = 150 -const FILE_WATCH_MAX_WAIT_MS = 500 -const MAX_FILE_WATCH_BATCH_EVENTS = 5_000 +import { MAX_BATCHED_WATCHER_EVENTS } from '../../../ipc/filesystem-watcher-event-batch' export function createFileWatchEventBatcher( worktree: string, @@ -46,7 +47,7 @@ export function createFileWatchEventBatcher( if (incomingOverflow) { events = [incomingOverflow] overflowed = true - } else if (events.length + nextEvents.length > MAX_FILE_WATCH_BATCH_EVENTS) { + } else if (events.length + nextEvents.length > MAX_BATCHED_WATCHER_EVENTS) { // Why: once precision is too expensive to retain and stream, one // overflow asks clients to refresh safely without a huge payload. events = [ @@ -66,14 +67,14 @@ export function createFileWatchEventBatcher( if (firstEventAt === 0) { firstEventAt = now } - if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) { + if (now - firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) { flush() return } clearTimer() // Why: remote file-watch events cross the runtime WebSocket before the // renderer refreshes the tree. Match local watcher batching here. - timer = setTimeout(flush, FILE_WATCH_FLUSH_MS) + timer = setTimeout(flush, WATCH_BATCH_TRAILING_MS) if (typeof timer.unref === 'function') { timer.unref() } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index c81d2b5a6a1..1c1ff41328d 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -3316,6 +3316,195 @@ describe('PtyHandler', () => { ]) expect(handler.activePtyCount).toBe(0) }) + + describe('onPtyPoolEmpty', () => { + it('fires once when natural exit drains the last PTY, never while one remains', async () => { + const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCallbacks.push(cb) + }) + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + await spawnPty() + await spawnPty() + expect(onExitCallbacks).toHaveLength(2) + + onExitCallbacks[0]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(1) + expect(poolEmpty).not.toHaveBeenCalled() + + onExitCallbacks[1]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when the dead-shell reap inside attach removes the last PTY', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + await spawnPty() + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + try { + await expect(attachPty({ id: 'pty-1', suppressReplayNotification: true })).rejects.toThrow( + 'PTY "pty-1" not found' + ) + } finally { + aliveSpy.mockRestore() + } + + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when dispose-for-shutdown removes the last PTY', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + await spawnPty() + await spawnPty() + + await handler.dispose({ waitForPhysicalExit: false }) + + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when a spawn fails before the PTY ever reaches the pool', async () => { + // Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight + // would otherwise leave the relay believing it is still non-idle forever. + mockPtySpawn.mockImplementation(() => { + throw new Error('posix_spawnp failed') + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + await expect(spawnPty()).rejects.toThrow() + + expect(handler.activePtyCount).toBe(0) + expect(handler.pendingPtyCreationCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('stays silent when a failing creation settles while another is still admitted', async () => { + let spawnCall = 0 + mockPtySpawn.mockImplementation(() => { + spawnCall += 1 + if (spawnCall === 1) { + throw new Error('posix_spawnp failed') + } + return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() } + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + // Both admissions land before either creation resolves, so the failing one must not + // announce an empty pool while the surviving one still owns a shell. + const failing = spawnPty() + const succeeding = spawnPty() + expect(handler.pendingPtyCreationCount).toBe(2) + + await expect(failing).rejects.toThrow() + await succeeding + + expect(handler.activePtyCount).toBe(1) + expect(poolEmpty).not.toHaveBeenCalled() + }) + + it('stops notifying after the returned unsubscribe runs', async () => { + const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCallbacks.push(cb) + }) + }) + const poolEmpty = vi.fn() + const unsubscribe = handler.onPtyPoolEmpty(poolEmpty) + + await spawnPty() + onExitCallbacks[0]({ exitCode: 0 }) + expect(poolEmpty).toHaveBeenCalledTimes(1) + + unsubscribe() + await spawnPty() + onExitCallbacks[1]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + }) + + describe('onPtyPoolActive', () => { + it('fires at admission, while the pool is still empty', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolCounts: number[] = [] + handler.onPtyPoolActive(() => { + poolCounts.push(handler.activePtyCount) + }) + + const pending = spawnPty() + // The relay must learn it is non-idle here, not after the creation resolves. + expect(poolCounts).toEqual([0]) + + await pending + expect(poolCounts).toEqual([0, 1]) + }) + + it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => { + await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' }) + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolActive = vi.fn() + handler.onPtyPoolActive(poolActive) + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(handler.activePtyCount).toBe(1) + expect(poolActive).toHaveBeenCalled() + }) + + it('stops notifying after the returned unsubscribe runs', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolActive = vi.fn() + const unsubscribe = handler.onPtyPoolActive(poolActive) + + await spawnPty() + const callsWhileSubscribed = poolActive.mock.calls.length + expect(callsWhileSubscribed).toBeGreaterThan(0) + + unsubscribe() + await spawnPty() + expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed) + }) + }) + + it('counts a spawn admitted but not yet pooled as a pending creation', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + expect(handler.pendingPtyCreationCount).toBe(0) + + const pending = spawnPty() + // The shell is already owned even though activePtyCount still reads zero. + expect(handler.activePtyCount).toBe(0) + expect(handler.pendingPtyCreationCount).toBe(1) + + await pending + expect(handler.activePtyCount).toBe(1) + expect(handler.pendingPtyCreationCount).toBe(0) + }) }) describe('attachIdentityMismatches', () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 67bfc4c6e15..cc685389f0a 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -372,6 +372,8 @@ export class PtyHandler { private reloadPtyModuleFromDisk = false // Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup. private exitListener: PtyExitListener | null = null + private ptyPoolEmptyListener: (() => void) | null = null + private ptyPoolActiveListener: (() => void) | null = null // Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks. private envAugmenters: PtyEnvAugmenter[] = [] private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() @@ -462,6 +464,10 @@ export class PtyHandler { } } + // Why: this value never reaches the grace *timer* — startGraceTimer's only caller always passes an + // explicit timeoutMs — but relay.startGrace reads it back through configuredGraceTimeMs to pick the + // grace branch, so a host-sent change does affect shutdown behavior. Callers and the host-sleep + // consequence: docs/reference/relay-grace-time-reconfiguration.md. setGraceTimeMs(graceTimeMs: number): void { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } @@ -494,6 +500,53 @@ export class PtyHandler { this.exitListener = listener } + /** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */ + onPtyPoolEmpty(listener: () => void): () => void { + this.ptyPoolEmptyListener = listener + return () => { + if (this.ptyPoolEmptyListener === listener) { + this.ptyPoolEmptyListener = null + } + } + } + + /** + * Notified when a PTY creation is admitted or a PTY joins the pool. + * + * Why: the relay arms its idle cap on an empty pool, and a spawn/revive that lands after that + * decision must disarm it — creation is asynchronous, so the pool-empty edge alone can't see it. + */ + onPtyPoolActive(listener: () => void): () => void { + this.ptyPoolActiveListener = listener + return () => { + if (this.ptyPoolActiveListener === listener) { + this.ptyPoolActiveListener = null + } + } + } + + private notifyPoolListener(listener: (() => void) | null, label: string): void { + if (!listener) { + return + } + try { + listener() + } catch (err) { + process.stderr.write( + `[pty-handler] ${label} listener threw: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + + // Why: the sole removal path, so the three exit routes can't drift on who announces an empty pool. + private removePty(id: string): void { + this.ptys.delete(id) + if (this.ptys.size > 0) { + return + } + this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') + } + /** Register an env augmenter merged into every spawn env *after* process.env and renderer env. * Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords. See docs/design/agent-status-over-ssh.md §3. */ addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void { @@ -617,6 +670,8 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) + // Why: a second announce covers any store whose admission window has already closed. + this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq this.appendReplayBuffer(managed, emission.data) @@ -672,7 +727,7 @@ export class PtyHandler { this.publishPendingExit(managed.id) this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) - this.ptys.delete(managed.id) + this.removePty(managed.id) this.clearPtyInputState(managed.id) // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). disposeManagedPty(managed) @@ -1157,6 +1212,9 @@ export class PtyHandler { throw error } this.pendingSpawnCount++ + // Why: announce at admission, not at store — the relay must stop treating itself as idle before + // the creation parks on its first await, or an armed idle timer kills the shell it produces. + this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') let finished = false return () => { if (finished) { @@ -1171,6 +1229,11 @@ export class PtyHandler { this.pendingCreationDrainResolvers.clear() } finishPtyCreationOperations(finishRemovalOperations) + // Why: a creation that failed before wireAndStore still leaves the pool empty, and removePty + // can't announce a PTY that was never stored — without this the relay never re-arms its idle cap. + if (this.ptys.size === 0 && this.pendingSpawnCount === 0) { + this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') + } } } @@ -1535,7 +1598,7 @@ export class PtyHandler { this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) - this.ptys.delete(id) + this.removePty(id) this.clearPtyFlowState(id) throw new Error(`PTY "${id}" not found`) } @@ -2065,7 +2128,7 @@ export class PtyHandler { this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) - this.ptys.delete(managed.id) + this.removePty(managed.id) this.clearPtyFlowState(managed.id) } } @@ -2105,6 +2168,11 @@ export class PtyHandler { return this.ptys.size } + /** Spawns admitted but not yet in the pool — each already owns a shell the relay must not treat as idle. */ + get pendingPtyCreationCount(): number { + return this.pendingSpawnCount + } + get retainedStartupCommandCount(): number { let count = 0 for (const managed of this.ptys.values()) { diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts new file mode 100644 index 00000000000..1361715a4ea --- /dev/null +++ b/src/relay/relay-grace-branch.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { decideRelayGrace, type RelayGraceDecisionInput } from './relay-grace-branch' + +const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 +const IDLE_RELAY_GRACE_MS = 15 * 60_000 + +function decide(overrides: Partial = {}) { + return decideRelayGrace({ + configuredGraceMs: 0, + relayIdle: false, + detached: false, + hasAcceptedSocketClient: true, + activePtyCount: 1, + retryDeferredShutdown: false, + emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: IDLE_RELAY_GRACE_MS, + ...overrides + }) +} + +describe('decideRelayGrace', () => { + describe('the zero-only gate', () => { + it('honors a grace raised after launch instead of clamping it to the idle cap', () => { + // Why: the reported bug. startGrace used to read the launch-time argv closure, so a host that + // raised the grace to 24h via relay.configureGraceTime still got the 15-minute idle cap. + expect(decide({ configuredGraceMs: 86_400_000, relayIdle: true, activePtyCount: 0 })).toEqual( + { + branch: 'configured', + timeoutMs: 86_400_000 + } + ) + }) + + it('caps an idle relay running the unlimited default at the idle grace', () => { + // Why: prepareForHostSleep notifies graceTimeSeconds:0, which now reaches this selector. + // Deliberate behavior change: a host-sleep relay holding zero PTYs exits after the idle cap + // instead of living forever. Pinned so the revived path stays a decision, not an accident. + expect(decide({ configuredGraceMs: 0, relayIdle: true, activePtyCount: 0 })).toEqual({ + branch: 'idle-no-ptys', + timeoutMs: IDLE_RELAY_GRACE_MS + }) + }) + + it('leaves a non-idle relay on the configured branch even at the unlimited default', () => { + // Why: an admitted-but-unpooled creation keeps relayIdle false, so the cap must not apply — + // it would kill the shell that creation is about to produce (#6955). + expect(decide({ configuredGraceMs: 0, relayIdle: false })).toEqual({ + branch: 'configured', + timeoutMs: 0 + }) + }) + }) + + describe('branch precedence', () => { + it('prefers shutdown-deferred over every other branch', () => { + expect( + decide({ + retryDeferredShutdown: true, + detached: true, + hasAcceptedSocketClient: false, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + + it('prefers startup-empty-detached over the idle cap', () => { + expect( + decide({ + detached: true, + hasAcceptedSocketClient: false, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'startup-empty-detached', timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS }) + }) + + it('bounds a configured grace on the startup-empty-detached branch', () => { + expect( + decide({ + configuredGraceMs: 5_000, + detached: true, + hasAcceptedSocketClient: false, + activePtyCount: 0 + }) + ).toEqual({ branch: 'startup-empty-detached', timeoutMs: 5_000 }) + }) + + it('does not treat a detached relay that already accepted a client as startup-empty', () => { + expect( + decide({ + detached: true, + hasAcceptedSocketClient: true, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'idle-no-ptys', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + }) +}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts new file mode 100644 index 00000000000..aa8aabcdd63 --- /dev/null +++ b/src/relay/relay-grace-branch.ts @@ -0,0 +1,63 @@ +/** Which rule decided the current relay grace window. */ +export type RelayGraceBranch = + | 'shutdown-deferred' + | 'startup-empty-detached' + | 'idle-no-ptys' + | 'configured' + +export type RelayGraceDecisionInput = { + /** The live configured grace, not the launch-time argv value; 0 means unlimited. */ + configuredGraceMs: number + /** Zero pooled PTYs *and* zero admitted-but-unpooled creations. */ + relayIdle: boolean + detached: boolean + hasAcceptedSocketClient: boolean + activePtyCount: number + retryDeferredShutdown: boolean + emptyDetachedStartupGraceMs: number + idleRelayGraceMs: number +} + +export type RelayGraceDecision = { + branch: RelayGraceBranch + timeoutMs: number +} + +/** + * Picks the relay's shutdown grace window. + * + * Why a separate module: relay.ts runs `main()` on import and exports nothing, so the zero-only + * gate below — the property that keeps a configured grace from being clamped to the idle cap — + * could not otherwise be tested. + */ +export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDecision { + // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. + const startupEmptyDetached = + input.detached && !input.hasAcceptedSocketClient && input.activePtyCount === 0 + // Why: zero PTYs means nothing left to preserve, so only the unlimited default is capped — an + // explicitly configured grace is honored verbatim rather than clamped down to the idle cap. + // Why: a spawn parked mid-creation is not in the pool yet, so capping on it would kill the live + // shell it is about to produce. + const idleNoPtys = input.relayIdle && input.configuredGraceMs === 0 + + const branch: RelayGraceBranch = input.retryDeferredShutdown + ? 'shutdown-deferred' + : startupEmptyDetached + ? 'startup-empty-detached' + : idleNoPtys + ? 'idle-no-ptys' + : 'configured' + + const timeoutMs = + branch === 'startup-empty-detached' + ? input.configuredGraceMs === 0 + ? input.emptyDetachedStartupGraceMs + : Math.min(input.configuredGraceMs, input.emptyDetachedStartupGraceMs) + : // Why: a refused kill leaves the PTY pooled, so the idle branch can't be reached — this + // branch must supply its own bound or the shipped grace=0 default arms no retry at all. + branch === 'idle-no-ptys' || branch === 'shutdown-deferred' + ? input.idleRelayGraceMs + : input.configuredGraceMs + + return { branch, timeoutMs } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index f282cbb483e..f807fc6ac2f 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -51,6 +51,7 @@ import { } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' +import { decideRelayGrace, type RelayGraceBranch } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -68,6 +69,9 @@ const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', 60_000 ) +// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. +// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. +const IDLE_RELAY_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) type SocketIdentity = { dev: bigint @@ -679,8 +683,21 @@ async function main(): Promise { function configureRelayGraceTime(params: Record): { graceTimeMs: number } { const seconds = Number(params.graceTimeSeconds) if (Number.isFinite(seconds) && seconds >= 0) { + const previousGraceMs = ptyHandler.configuredGraceTimeMs // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. ptyHandler.setGraceTimeMs(Math.floor(seconds) * 1000) + // Why: startGrace samples the configured value at arm time, so a raise that lands while the idle + // timer is already running would still fire at the old deadline. Re-arm only on an actual change + // — the host re-asserts the same value on every establish, and restarting the window on those + // would keep a grace alive indefinitely. + if ( + ptyHandler.configuredGraceTimeMs !== previousGraceMs && + graceDeadlineAt !== null && + graceReason !== null && + !shutdownInFlight + ) { + startGrace('grace reconfigured') + } } return { graceTimeMs: ptyHandler.configuredGraceTimeMs } } @@ -829,6 +846,9 @@ async function main(): Promise { let hasAcceptedSocketClient = false let graceDeadlineAt: number | null = null let graceReason: string | null = null + // Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a + // PTY appears mid-window; the other branches keep their armed deadline. + let graceBranch: RelayGraceBranch | null = null dispatcher.onRequest('relay.status', async () => ({ pid: process.pid, @@ -864,6 +884,7 @@ async function main(): Promise { } graceDeadlineAt = null graceReason = null + graceBranch = null ptyHandler.cancelGraceTimer() } @@ -1116,26 +1137,45 @@ async function main(): Promise { dispatcher.invalidateClient() }) - function startGrace(reason: string): void { - const startupEmptyDetached = - detached && !hasAcceptedSocketClient && ptyHandler.activePtyCount === 0 - // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. - const timeoutMs = startupEmptyDetached - ? graceTimeMs === 0 - ? EMPTY_DETACHED_STARTUP_GRACE_MS - : Math.min(graceTimeMs, EMPTY_DETACHED_STARTUP_GRACE_MS) - : graceTimeMs + function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void { + // Why: the live configured value, not the launch-time argv closure — the host can raise the grace + // after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be + // zero-at-launch-only, i.e. correct only by coincidence. + const decision = decideRelayGrace({ + configuredGraceMs: ptyHandler.configuredGraceTimeMs, + relayIdle: isRelayIdle(), + detached, + hasAcceptedSocketClient, + activePtyCount: ptyHandler.activePtyCount, + retryDeferredShutdown: options?.retryDeferredShutdown === true, + emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: IDLE_RELAY_GRACE_MS + }) + graceBranch = decision.branch + const timeoutMs = decision.timeoutMs graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs graceReason = reason relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` + `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` ) ptyHandler.startGraceTimer(() => { + // Why: last line of defense for the idle cap — a PTY that appeared without announcing itself + // must not be killed by a timer armed while the relay was still empty (#6955). + if (graceBranch === 'idle-no-ptys' && !isRelayIdle()) { + relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) + startGrace(reason) + return + } relayLogLine(`[relay] Grace expired (${reason}); shutting down`) shutdown() }, timeoutMs) } + // Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle. + function isRelayIdle(): boolean { + return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0 + } + if (detached) { // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. startGrace('detached startup') @@ -1176,9 +1216,12 @@ async function main(): Promise { ) graceDeadlineAt = null graceReason = null + graceBranch = null void ptyHandler .dispose() .then(() => { + stopPoolWatch() + stopPoolActiveWatch() dispatcher.dispose() fsHandler.dispose() gitHandler.dispose() @@ -1192,13 +1235,41 @@ async function main(): Promise { }) .catch((error) => { // Why: keep owning a PTY whose native kill was rejected so a transient signal failure doesn't orphan a remote shell. + // Why: the pool watches stay registered — the socket server is still listening, so a client can + // reconnect and cancel this grace, and that revived relay still needs both re-evaluations. shutdownInFlight = false relayLogLine( `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` ) + // Why: shutdown() already cleared graceReason, so without re-arming a client-less relay + // whose kill was refused would stay resident forever with nothing left to retry it. + if (socketClients.size === 0) { + startGrace('shutdown deferred', { retryDeferredShutdown: true }) + } }) } + // Why: with the shipped unlimited default the grace timer is never armed, so an expiry can't + // notice the pool emptying; re-evaluate on the last exit instead. graceReason (not + // graceTimerActive) is the only signal that a grace window is open in that configuration. + // Why: a null deadline is exactly that unlimited case; re-arming a scheduled one would restart + // an explicitly configured window and double it. + const stopPoolWatch = ptyHandler.onPtyPoolEmpty(() => { + if (graceReason !== null && graceDeadlineAt === null && !shutdownInFlight) { + startGrace('last pty exited') + } + }) + + // Why: the idle cap is armed on an empty pool, but creation is asynchronous — a spawn or revive + // admitted after that decision (the client dropped mid-`pty.revive`, say) makes the relay non-idle + // again, and re-evaluating disarms the timer instead of letting it kill a live PTY (#6955). + // The grace window itself stays open, so the PTY's own exit still re-arms the cap. + const stopPoolActiveWatch = ptyHandler.onPtyPoolActive(() => { + if (graceBranch === 'idle-no-ptys' && graceReason !== null && !shutdownInFlight) { + startGrace(graceReason) + } + }) + process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) // Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect. diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index e80a3ccec86..57b3466707d 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -108,6 +108,57 @@ const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} } } }\n` +// A shell that reports its own exit after a delay, so the relay sees the pool drain on its own. +function selfExitingNodePtyModule(exitAfterMs: number): string { + return `module.exports = { spawn() { + const exitHandlers = [] + setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, ${exitAfterMs}) + return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, kill() {}, clear() {} + } +} }\n` +} + +// A shell whose first dispose is refused, so ptyHandler.dispose() rejects once before a retry can succeed. +const KILL_REJECTS_FIRST_DISPOSE_MODULE = `let killAttempts = 0 +module.exports = { spawn() { + const exitHandlers = [] + return { + pid: 2147483646, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, + kill() { + killAttempts++ + if (killAttempts <= 2) { throw new Error('kill refused') } + setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) + } + } +} }\n` + +// Why: an ESM mock with top-level await parks loadPty() in the window where a spawn is admitted but not yet pooled. +function writeSlowLoadingNodePty(root: string, loadDelayMs: number): void { + const nodePtyDir = path.join(root, 'node_modules', 'node-pty') + mkdirSync(path.join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync(path.join(nodePtyDir, 'package.json'), '{"type":"module","main":"lib/index.js"}\n') + writeFileSync( + path.join(nodePtyDir, 'lib', 'index.js'), + `await new Promise((resolve) => setTimeout(resolve, ${loadDelayMs})) +export function spawn() { + const exitHandlers = [] + return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, + // Report the exit so relay shutdown can complete instead of parking on waitForPhysicalExit. + kill() { setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) } + } +} +` + ) +} + describe('Subprocess: Relay entry point', () => { let relay: RelayProcess | null = null let tmpDir: string @@ -504,6 +555,210 @@ describe('Subprocess: Relay entry point', () => { 10_000 ) + // Why: a relay holding zero PTYs preserves nothing, so the unlimited default must still be + // bounded once a client has come and gone — but an explicitly configured grace is never shortened. + function spawnIdleGraceDaemon( + nodePtyModule: string, + graceTimeSeconds: string, + idleGraceMs: string + ): { daemon: RelayProcess; sockPath: string } { + const daemonEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, daemonEntry) + writeMockNodePty(tmpDir, nodePtyModule) + const sockPath = path.join(tmpDir, 'relay.sock') + const daemon = spawnRelayEntry( + daemonEntry, + ['--detached', '--grace-time', graceTimeSeconds, '--sock-path', sockPath], + { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: idleGraceMs } + ) + return { daemon, sockPath } + } + + async function connectAndDisconnect( + sockPath: string, + whileConnected?: (bridge: RelayProcess) => Promise + ): Promise { + const bridge = spawn(['--connect', '--sock-path', sockPath]) + try { + await bridge.sentinelReceived + await whileConnected?.(bridge) + } finally { + bridge.kill('SIGTERM') + await bridge.waitForExit().catch(() => {}) + } + } + + it.skipIf(process.platform === 'win32')( + 'shuts down an idle relay with no PTYs after the idle grace even with --grace-time 0', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-')) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawn(['--detached', '--grace-time', '0', '--sock-path', sockPath], { + ...process.env, + ORCA_RELAY_IDLE_GRACE_MS: '200' + }) + await relay.sentinelReceived + + // Accepting a client defeats the startup-empty branch, so only the idle cap can end this relay. + await connectAndDisconnect(sockPath) + + await relay.waitForExit(2000) + expect(relay.proc.exitCode).toBe(0) + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 'keeps a relay with a live PTY alive past the idle grace', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-live-pty-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(WORKING_NODE_PTY_MODULE, '0', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + await new Promise((resolve) => setTimeout(resolve, 1200)) + expect(relay.proc.exitCode).toBeNull() + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 're-arms the idle grace when the last PTY exits during grace', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-rearm-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(1500), '0', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // The live PTY must suppress the idle grace at disconnect; only its own exit re-arms it. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(relay.proc.exitCode).toBeNull() + + await relay.waitForExit(5000) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 'honors an explicitly configured grace instead of clamping it to the idle cap', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-configured-')) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawn(['--detached', '--grace-time', '3600', '--sock-path', sockPath], { + ...process.env, + ORCA_RELAY_IDLE_GRACE_MS: '200' + }) + await relay.sentinelReceived + + await connectAndDisconnect(sockPath) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(relay.proc.exitCode).toBeNull() + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 'keeps the relay alive when the client drops while a PTY creation is still in flight', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-inflight-')) + const daemonEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, daemonEntry) + writeSlowLoadingNodePty(tmpDir, 1500) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawnRelayEntry( + daemonEntry, + ['--detached', '--grace-time', '0', '--sock-path', sockPath], + { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: '200' } + ) + await relay.sentinelReceived + + const bridge = spawn(['--connect', '--sock-path', sockPath]) + await bridge.sentinelReceived + // Revive, not spawn: it has no client-disconnect abort, so it really does produce a live shell + // after the parked module load resolves. + bridge.send('pty.revive', { + state: JSON.stringify([ + { id: 'pty-restored', pid: process.pid, cols: 80, rows: 24, cwd: tmpDir } + ]) + }) + // Let the creation park on the module load: it already owns a shell but is not in the pool yet. + await new Promise((resolve) => setTimeout(resolve, 400)) + bridge.kill('SIGTERM') + await bridge.waitForExit().catch(() => {}) + + // Well past the idle cap and past the point where the parked creation lands in the pool. + await new Promise((resolve) => setTimeout(resolve, 2500)) + expect(relay.proc.exitCode).toBeNull() + + const probe = spawn(['--connect', '--sock-path', sockPath]) + try { + await probe.sentinelReceived + const status = await probe.waitForResponse(probe.send('relay.status')) + expect((status.result as { ptys: { active: number } }).ptys.active).toBe(1) + } finally { + probe.kill('SIGTERM') + await probe.waitForExit().catch(() => {}) + } + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 'does not extend an explicitly configured grace when the last PTY exits mid-window', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-configured-rearm-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(2500), '3', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // The 3s window keeps governing; re-arming at the ~2.5s PTY exit would push shutdown past 5s. + await relay.waitForExit(4200) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 're-arms grace after a shutdown deferred by a rejected kill, so the relay still exits', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-shutdown-deferred-')) + const { daemon, sockPath } = spawnIdleGraceDaemon( + KILL_REJECTS_FIRST_DISPOSE_MODULE, + '2', + '200' + ) + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // First grace expiry hits the refused kill; only the re-armed window can retry it. + await relay.waitForExit(8000) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + it('reports relay diagnostics over relay.status', async () => { relay = spawn() await relay.sentinelReceived diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 81a41e20aea..9e22f01ac5d 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -42,6 +42,7 @@ import { useFileExplorerDragDrop } from './useFileExplorerDragDrop' import { useFileExplorerImport } from './useFileExplorerImport' import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh' import { useFileExplorerTree } from './useFileExplorerTree' +import { decideExpandedDirLoad } from './file-explorer-stale-dir-cache' import { useFileExplorerWatch } from './useFileExplorerWatch' import { buildAddProjectFromFolderModalData, @@ -148,6 +149,7 @@ function FileExplorerFiles(): React.JSX.Element { markPathAsDirectory, refreshTree, refreshDir, + isDirStale, resetAndLoad } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) const hasNameFilterQuery = nameFilterQuery.trim().length > 0 @@ -347,11 +349,14 @@ function FileExplorerFiles(): React.JSX.Element { return } for (const dirPath of expanded) { - if (!dirCache[dirPath]?.children.length && !dirCache[dirPath]?.loading) { - const depth = - splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1 - void loadDir(dirPath, depth) + // Why: a full refresh (watcher overflow) re-reads only root and the dirs expanded at the time, + // so a listing cached while collapsed is unverified — re-read it here instead of trusting it. + const decision = decideExpandedDirLoad(dirCache[dirPath], isDirStale(dirPath)) + if (decision === 'skip') { + continue } + const depth = splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1 + void loadDir(dirPath, depth, decision === 'reload' ? { force: true } : undefined) } }, [expanded, visibleFilesWorktreePath]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts similarity index 58% rename from src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts rename to src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts index b98e61c825e..97e2dbd25d3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts @@ -3,7 +3,7 @@ import type { SetStateAction } from 'react' import type { DirEntry } from '../../../../shared/types' import type { DirCache } from './file-explorer-types' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' -import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree' +import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh' type CacheUpdate = SetStateAction> @@ -44,7 +44,10 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: createFileExplorerDirLoadTracker(), setDirCache, - readDirectory + readDirectory, + // A limit at or above the dir count must stay a single wave, i.e. exactly + // today's two batched writes. + maxConcurrentReads: 16 }) expect(refreshed).toBe(true) @@ -128,7 +131,8 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: tracker, setDirCache, - readDirectory + readDirectory, + maxConcurrentReads: 16 }) // Not every dir was still current, so the refresh reports partial completion. @@ -172,7 +176,8 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: tracker, setDirCache, - readDirectory + readDirectory, + maxConcurrentReads: 16 }) // Let /repo/src resolve (and pass its resolve-time token check) while @@ -207,4 +212,137 @@ describe('refreshFileExplorerExpandedDirs', () => { children: [{ name: 'guide.md' }] }) }) + + it('never exceeds maxConcurrentReads in flight and still commits every directory', async () => { + const dirs = Array.from({ length: 20 }, (_, index) => ({ + dirPath: `/repo/d${index}`, + depth: 0 + })) + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let inFlight = 0 + let peakInFlight = 0 + const readDirectory = vi.fn(async (dirPath: string) => { + inFlight++ + peakInFlight = Math.max(peakInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 0)) + inFlight-- + return { + entries: [entry(`${dirPath.slice('/repo/'.length)}.ts`)], + operationOwner: { kind: 'local' as const } + } + }) + + const refreshed = await refreshFileExplorerExpandedDirs({ + dirs, + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory, + maxConcurrentReads: 4 + }) + + expect(refreshed).toBe(true) + expect(peakInFlight).toBeLessThanOrEqual(4) + expect(readDirectory).toHaveBeenCalledTimes(20) + // One up-front loading write plus one result write per wave (five waves). + expect(setDirCache).toHaveBeenCalledTimes(6) + for (const { dirPath } of dirs) { + expect(cache[dirPath]).toMatchObject({ + loading: false, + children: [{ name: expect.any(String) }] + }) + } + }) + + it('marks queued dirs loading up front so only the first wave reads', async () => { + const dirs = Array.from({ length: 9 }, (_, index) => ({ + dirPath: `/repo/d${index}`, + depth: 0 + })) + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseFirstWave!: () => void + const firstWaveGate = new Promise((resolve) => { + releaseFirstWave = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (['/repo/d0', '/repo/d1', '/repo/d2'].includes(dirPath)) { + await firstWaveGate + } + return { entries: [], operationOwner: { kind: 'local' as const } } + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs, + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory, + maxConcurrentReads: 3 + }) + await Promise.resolve() + + expect(cache['/repo/d0']).toMatchObject({ loading: true }) + // A queued dir must already advertise loading:true, or FileExplorer's + // auto-load effect fans out an unbounded loadDir for it on the next + // `expanded` change — the reads this cap exists to bound. + expect(cache['/repo/d6']).toMatchObject({ loading: true }) + expect(readDirectory).toHaveBeenCalledTimes(3) + + releaseFirstWave() + await refreshPromise + + expect(cache['/repo/d6']).toMatchObject({ loading: false }) + }) + + it('drops a later-wave directory superseded while an earlier wave was reading', async () => { + const tracker = createFileExplorerDirLoadTracker() + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/a') { + await firstGate + } + return { entries: [entry('x.ts')], operationOwner: { kind: 'local' as const } } + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/a', depth: 0 }, + { dirPath: '/repo/b', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: tracker, + setDirCache, + readDirectory, + maxConcurrentReads: 1 + }) + await Promise.resolve() + + // A watcher-driven refreshDir supersedes the queued wave-2 dir before it + // ever starts reading; its token was already taken up front. + tracker.begin('/repo/b') + const newerBCache: DirCache = { loading: false, children: [] } + setDirCache((prev) => ({ ...prev, '/repo/b': newerBCache })) + + releaseFirst() + const refreshed = await refreshPromise + + expect(refreshed).toBe(false) + expect(cache['/repo/a']).toMatchObject({ loading: false, children: [{ name: 'x.ts' }] }) + // Wave 2 must neither read nor commit the superseded dir. + expect(readDirectory).toHaveBeenCalledTimes(1) + expect(cache['/repo/b']).toEqual(newerBCache) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts new file mode 100644 index 00000000000..280520c6425 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts @@ -0,0 +1,137 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { DirCache } from './file-explorer-types' +import type { FileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { + fileExplorerEntriesToTreeNodes, + type FileExplorerDirectoryListing +} from './file-explorer-directory-listing' + +export type RefreshFileExplorerTreeDir = { + dirPath: string + depth: number +} + +export type RefreshFileExplorerExpandedDirsParams = { + dirs: RefreshFileExplorerTreeDir[] + worktreePath: string + dirLoadTracker: FileExplorerDirLoadTracker + setDirCache: Dispatch>> + readDirectory: (dirPath: string) => Promise + maxConcurrentReads: number + /** Called per dir whose fresh listing was committed, so callers can clear a staleness mark. */ + onDirCommitted?: (dirPath: string) => void +} + +export async function refreshFileExplorerExpandedDirs({ + dirs, + worktreePath, + dirLoadTracker, + setDirCache, + readDirectory, + maxConcurrentReads, + onDirCommitted +}: RefreshFileExplorerExpandedDirsParams): Promise { + if (dirs.length === 0) { + return true + } + + const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) + // Why: begin every token before the first read so a concurrent refreshDir or + // worktree reset supersedes dirs still queued in a later wave, exactly as the + // single-batch version did. + const loadTokens = new Map( + uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) + ) + const waveSize = Math.max(1, Math.floor(maxConcurrentReads)) + let committedDirs = 0 + + // Why: mark every dir loading up front, not per wave — FileExplorer's auto-load + // effect re-runs on any `expanded` change and fans out an unbounded loadDir per + // dir that is neither cached nor loading, which would defeat the wave cap. + setDirCache((prev) => { + const next = { ...prev } + for (const { dirPath } of uniqueDirs) { + next[dirPath] = { + children: prev[dirPath]?.children ?? [], + loading: true + } + } + return next + }) + + for (let start = 0; start < uniqueDirs.length; start += waveSize) { + // Why: a dir superseded while an earlier wave was reading is left to its + // newer load — reading it here would only burn a round trip for a result + // that is dropped at commit time. + const wave = uniqueDirs + .slice(start, start + waveSize) + .filter(({ dirPath }) => dirLoadTracker.isCurrent(loadTokens.get(dirPath)!)) + if (wave.length === 0) { + continue + } + + // Why: batch each wave's results into one setDirCache write, so the whole + // refresh costs 1 + ceil(N / waveSize) cache spreads rather than one per dir. + const results = await Promise.all( + wave.map(async ({ dirPath, depth }) => { + const loadToken = loadTokens.get(dirPath)! + try { + const listing = await readDirectory(dirPath) + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { + children: fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ), + loading: false, + operationOwner: listing.operationOwner + } + } + } catch { + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { children: [], loading: false } + } + } + }) + ) + + // Why: the batch commits only after the slowest read in the wave, so a dir + // can be superseded (watcher refreshDir, worktree reset) after its own read + // resolved. Re-check tokens at commit time so the batched write never + // clobbers a newer load — preserving the old per-dir commit ordering. + const currentResults = results.filter( + (result): result is Extract => + result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!) + ) + if (currentResults.length === 0) { + continue + } + + setDirCache((prev) => { + const next = { ...prev } + for (const result of currentResults) { + next[result.dirPath] = result.cache + } + return next + }) + for (const result of currentResults) { + onDirCommitted?.(result.dirPath) + } + committedDirs += currentResults.length + } + + return committedDirs === uniqueDirs.length +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts new file mode 100644 index 00000000000..b1d319933b7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' + +describe('fileExplorerRefreshConcurrency', () => { + it('allows the widest fan-out for local disk reads', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'local' })).toBe(16) + }) + + it('caps SSH-owned worktrees at the remote tier', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'ssh', connectionId: 'target-1' })).toBe(4) + }) + + it('treats an SSH-backed runtime as remote', () => { + expect( + fileExplorerRefreshConcurrency({ + kind: 'runtime', + environmentId: 'env-1', + executionHostId: 'ssh:target-1' + }) + ).toBe(4) + }) + + it('uses the runtime tier for a non-SSH runtime host', () => { + expect( + fileExplorerRefreshConcurrency({ + kind: 'runtime', + environmentId: 'env-1', + executionHostId: 'runtime:env-1' + }) + ).toBe(8) + }) + + it('falls back to the conservative remote tier when ownership is unresolved', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'unresolved' })).toBe(4) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts new file mode 100644 index 00000000000..49c6170ad6b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts @@ -0,0 +1,31 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { FileExplorerOperationOwner } from './file-explorer-types' + +export const FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY = 16 +export const FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY = 8 +export const FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY = 4 + +/** + * In-flight directory-read cap for one File Explorer refresh, by how expensive a + * read is on the owning transport. + * + * Why: tiers come from the owner, not from `getFileExplorerOperationRoute`, + * which maps every non-SSH runtime host to `expectedExecutionHostId: 'local'` — + * including an `orca serve` runtime reached over a network socket, whose reads + * are RPC round trips rather than disk reads. + */ +export function fileExplorerRefreshConcurrency(owner: FileExplorerOperationOwner): number { + switch (owner.kind) { + case 'local': + return FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY + case 'ssh': + return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + case 'runtime': + return parseExecutionHostId(owner.executionHostId)?.kind === 'ssh' + ? FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + : FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY + case 'unresolved': + // Why: unknown ownership could be the slowest transport; stay conservative. + return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts new file mode 100644 index 00000000000..e0cf1c1eccf --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { DirCache, TreeNode } from './file-explorer-types' +import { collectStaleDirCachePaths, decideExpandedDirLoad } from './file-explorer-stale-dir-cache' + +function cache(...dirPaths: string[]): Record { + return Object.fromEntries(dirPaths.map((dirPath) => [dirPath, { children: [], loading: false }])) +} + +describe('collectStaleDirCachePaths', () => { + it('marks a cached dir that a full refresh will not re-read', () => { + expect( + collectStaleDirCachePaths(cache('/repo', '/repo/src', '/repo/docs'), '/repo', new Set()) + ).toEqual(['/repo/src', '/repo/docs']) + }) + + it('never marks the root or a currently expanded dir', () => { + expect( + collectStaleDirCachePaths( + cache('/repo', '/repo/src', '/repo/docs'), + '/repo', + new Set(['/repo/src']) + ) + ).toEqual(['/repo/docs']) + }) + + it('returns nothing when the cache holds only refreshed dirs', () => { + expect( + collectStaleDirCachePaths(cache('/repo', '/repo/src'), '/repo', new Set(['/repo/src'])) + ).toEqual([]) + }) +}) + +describe('decideExpandedDirLoad', () => { + const children = [{ name: 'gone.ts', path: '/repo/src/gone.ts' } as TreeNode] + + it('re-reads a cached dir whose listing the last full refresh skipped', () => { + expect(decideExpandedDirLoad({ children, loading: false }, true)).toBe('reload') + }) + + it('trusts a cached listing that is not stale', () => { + expect(decideExpandedDirLoad({ children, loading: false }, false)).toBe('skip') + }) + + it('reads a dir that has never been listed', () => { + expect(decideExpandedDirLoad(undefined, false)).toBe('load') + expect(decideExpandedDirLoad({ children: [], loading: false }, false)).toBe('load') + }) + + it('never stacks a read on one already in flight, stale or not', () => { + expect(decideExpandedDirLoad({ children, loading: true }, true)).toBe('skip') + expect(decideExpandedDirLoad({ children: [], loading: true }, false)).toBe('skip') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts new file mode 100644 index 00000000000..007e18960c8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts @@ -0,0 +1,34 @@ +import type { DirCache } from './file-explorer-types' + +/** + * Cached directories a full tree refresh does not re-read. + * + * Why: `refreshTree` reads the root plus the currently expanded dirs, but `toggleDir` drops a dir + * from `expandedDirs` without purging `dirCache`. After a watcher overflow discarded the precise + * events, those collapsed-but-cached listings are unverified and must be re-read on re-expansion — + * otherwise a file deleted while the dir was collapsed stays visible forever, since the expansion + * effect skips any dir that already has children. + */ +export function collectStaleDirCachePaths( + cache: Record, + worktreePath: string, + expanded: Set +): string[] { + return Object.keys(cache).filter((dirPath) => dirPath !== worktreePath && !expanded.has(dirPath)) +} + +export type ExpandedDirLoadDecision = 'skip' | 'load' | 'reload' + +/** What the expansion effect owes a newly expanded dir: nothing, a first read, or a forced re-read. */ +export function decideExpandedDirLoad( + cached: DirCache | undefined, + stale: boolean +): ExpandedDirLoadDecision { + if (cached?.loading) { + return 'skip' + } + if (!cached?.children.length) { + return 'load' + } + return stale ? 'reload' : 'skip' +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-types.ts b/src/renderer/src/components/right-sidebar/file-explorer-types.ts index 7cf4bbb96d3..8fa8a432544 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-types.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-types.ts @@ -22,3 +22,12 @@ export type DirCache = { loading: boolean operationOwner?: FileExplorerOperationOwner } + +/** + * How a full tree refresh ended. + * + * Why three states, not a boolean: a caller that dropped its own pending dir refreshes because the + * tree refresh covered them must re-issue on `superseded` (someone newer owns those reads) but NOT + * on `root-unreadable` — there the transport is down, and re-issuing buys one dead timeout per dir. + */ +export type FileExplorerTreeRefreshOutcome = 'refreshed' | 'superseded' | 'root-unreadable' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts index d62420f7915..c6d221b4fad 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts @@ -439,3 +439,32 @@ describe('processFileExplorerFsPayload update reconciliation', () => { expect(remainingExpanded).toEqual(new Set()) }) }) + +describe('processFileExplorerFsPayload overflow reconciliation', () => { + it('routes an overflow event to a single tree refresh and skips per-dir refreshes', () => { + const root = '/repo' + const refreshDir = vi.fn() + const refreshTree = vi.fn() + processFileExplorerFsPayload({ + payload: { + worktreePath: root, + events: [ + { kind: 'create', absolutePath: `${root}/a.txt` }, + { kind: 'overflow', absolutePath: root }, + { kind: 'create', absolutePath: `${root}/src/b.txt` } + ] + }, + currentWorktreePath: root, + worktreeId: 'wt-1', + cache: { [root]: cacheWithChildren([]), [`${root}/src`]: cacheWithChildren([]) }, + expanded: new Set([`${root}/src`]), + setDirCache: vi.fn(), + setSelectedPath: vi.fn(), + refreshDir, + refreshTree + }) + + expect(refreshTree).toHaveBeenCalledOnce() + expect(refreshDir).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts new file mode 100644 index 00000000000..376479eb388 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts @@ -0,0 +1,382 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler' +import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types' + +const TRAILING_MS = 150 +const MAX_WAIT_MS = 500 + +function createDeferred(): { + promise: Promise + resolve: (outcome?: FileExplorerTreeRefreshOutcome) => void +} { + let resolve!: (outcome?: FileExplorerTreeRefreshOutcome) => void + const promise = new Promise((res) => { + resolve = (outcome = 'refreshed') => res(outcome) + }) + return { promise, resolve } +} + +function setup( + overrides: Partial[0]> = {} +) { + const { refreshTree: treeImpl, refreshDir: dirImpl, ...rest } = overrides + const refreshTree = vi.fn( + treeImpl ?? (async (): Promise => 'refreshed') + ) + const refreshDir = vi.fn(dirImpl ?? (async (_dirPath: string) => {})) + const scheduler = createFileExplorerWatchRefreshScheduler({ + refreshTree, + refreshDir, + isCoveredByFullRefresh: () => false, + dirConcurrency: 4, + ...rest + }) + return { refreshTree, refreshDir, scheduler } +} + +describe('createFileExplorerWatchRefreshScheduler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces a burst of full-refresh requests into one tree refresh', async () => { + const { refreshTree, scheduler } = setup() + + for (let index = 0; index < 20; index++) { + scheduler.requestFullRefresh() + } + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('flushes at the max wait even when requests keep arriving inside the trailing window', async () => { + const { refreshTree, scheduler } = setup() + + scheduler.requestFullRefresh() + for (let index = 0; index < 4; index++) { + await vi.advanceTimersByTimeAsync(100) + scheduler.requestFullRefresh() + } + expect(refreshTree).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS - 400) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('dedupes dir refreshes that differ only in path spelling', async () => { + const { refreshDir, scheduler } = setup() + + // Spellings only the runtime-path normalizer folds: Windows separators and a + // trailing slash. Raw-string keying would issue three reads of one dir. + scheduler.requestDirRefresh('C:/repo/src/') + scheduler.requestDirRefresh('C:\\repo\\src') + scheduler.requestDirRefresh('C:/repo/src') + scheduler.requestDirRefresh('/repo/docs/') + scheduler.requestDirRefresh('/repo/docs') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(2) + expect(refreshDir.mock.calls.map(([dirPath]) => dirPath)).toEqual(['C:/repo/src', '/repo/docs']) + }) + + it('still refreshes a pending dir that a full refresh does not cover', async () => { + // Regression guard: dirCache keys are a strict superset of expanded dirs, so + // a full refresh must not subsume pending dir refreshes. + const { refreshTree, refreshDir, scheduler } = setup() + + scheduler.requestDirRefresh('/repo/collapsed') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/collapsed') + }) + + it('skips a pending dir the full refresh already re-read', async () => { + const { refreshTree, refreshDir, scheduler } = setup({ + isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src' + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('re-issues covered dir refreshes when the tree refresh was superseded', async () => { + // Regression guard: refreshTree bails without re-reading the expanded dirs when its root + // load is superseded. Nothing marks an expanded dir stale, so dropping these would leave + // them stale until a manual refresh — collapse/re-expand does not heal it. + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src' + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + gate.resolve('superseded') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/src') + }) + + it('drops pending dir refreshes when the tree refresh could not read the root', async () => { + // Regression guard: a failed root read means the transport is down, so re-issuing buys one + // dead timeout per dir and holds inFlight open, blocking every later refresh. + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: () => false + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestDirRefresh('/repo/docs') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + gate.resolve('root-unreadable') + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('refreshes a covered dir when no full refresh is pending', async () => { + const { refreshTree, refreshDir, scheduler } = setup({ + isCoveredByFullRefresh: () => true + }) + + scheduler.requestDirRefresh('/repo/src') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).not.toHaveBeenCalled() + expect(refreshDir).toHaveBeenCalledTimes(1) + }) + + it('does not start a second run while the first is still in flight', async () => { + const gate = createDeferred() + const { refreshTree, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + expect(refreshTree).toHaveBeenCalledTimes(1) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + expect(refreshTree).toHaveBeenCalledTimes(1) + + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(2) + }) + + it('flushes a dir request that arrived during an in-flight run', async () => { + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + scheduler.requestDirRefresh('/repo/late') + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + expect(refreshDir).not.toHaveBeenCalled() + + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/late') + }) + + it('never runs more dir refreshes at once than dirConcurrency', async () => { + let inFlight = 0 + let peakInFlight = 0 + const refreshDir = vi.fn(async (_dirPath: string) => { + inFlight++ + peakInFlight = Math.max(peakInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 10)) + inFlight-- + }) + const { scheduler } = setup({ refreshDir, dirConcurrency: 4 }) + + for (let index = 0; index < 20; index++) { + scheduler.requestDirRefresh(`/repo/d${index}`) + } + await vi.advanceTimersByTimeAsync(TRAILING_MS + 20 * 10) + + expect(refreshDir).toHaveBeenCalledTimes(20) + expect(peakInFlight).toBeLessThanOrEqual(4) + }) + + it('cancels a pending flush', async () => { + const { refreshTree, refreshDir, scheduler } = setup() + + scheduler.requestFullRefresh() + scheduler.requestDirRefresh('/repo/src') + scheduler.cancel() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshTree).not.toHaveBeenCalled() + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('stops refreshing queued dirs when cancelled mid-run', async () => { + // Regression guard: refreshDir is bound to a live ref that React reassigns + // before the effect cleanup runs, so a queued path surviving cancel() would + // be read against the NEXT worktree's binding. + const gate = createDeferred() + const refreshDir = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/d0') { + await gate.promise + } + }) + const { scheduler } = setup({ refreshDir, dirConcurrency: 1 }) + + for (let index = 0; index < 4; index++) { + scheduler.requestDirRefresh(`/repo/d${index}`) + } + await vi.advanceTimersByTimeAsync(TRAILING_MS) + expect(refreshDir).toHaveBeenCalledTimes(1) + + scheduler.cancel() + gate.resolve() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + }) + + it('still refreshes a dir first expanded while the tree refresh was in flight', async () => { + const gate = createDeferred() + const expanded = new Set() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: (dirPath) => expanded.has(dirPath) + }) + + scheduler.requestDirRefresh('/repo/late') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + // refreshTree already ran against the old expanded set, so this dir was + // never re-read and its pending refresh must survive. + expanded.add('/repo/late') + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/late') + }) + + it('drops a dir queued during an in-flight run when cancel lands first', async () => { + const gate = createDeferred() + const { refreshTree, refreshDir, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + scheduler.requestDirRefresh('/repo/src') + scheduler.cancel() + + gate.resolve() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('uses the injected timer functions', async () => { + const schedule = vi.fn((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) + const clear = vi.fn((timer: ReturnType) => { + clearTimeout(timer) + }) + const { refreshTree, scheduler } = setup({ schedule, clear }) + + scheduler.requestFullRefresh() + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(schedule).toHaveBeenCalledTimes(2) + expect(clear).toHaveBeenCalledTimes(1) + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('keeps a full refresh ahead of the dir refreshes it does not cover', async () => { + const order: string[] = [] + const { scheduler } = setup({ + refreshTree: async (): Promise => { + order.push('tree') + return 'refreshed' + }, + refreshDir: async (dirPath) => { + order.push(dirPath) + } + }) + + scheduler.requestDirRefresh('/repo/collapsed') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(order).toEqual(['tree', '/repo/collapsed']) + }) + + // Why: local workspaces pass a zero window because the main process already coalesced the burst + // on the same 150/500 timings. Stacking a second debounce there only doubled paint latency. + describe('zero window (local transport)', () => { + const zeroWindow = { trailingMs: 0, maxWaitMs: 0 } + + it('flushes on the next tick instead of waiting out a trailing window', async () => { + const { refreshTree, scheduler } = setup(zeroWindow) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('still coalesces one payload burst into a single refresh', async () => { + const { refreshTree, refreshDir, scheduler } = setup(zeroWindow) + + for (let index = 0; index < 20; index++) { + scheduler.requestFullRefresh() + scheduler.requestDirRefresh('/repo/src') + } + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/src') + }) + + it('does not start a second run while one is in flight', async () => { + const deferred = createDeferred() + const { refreshTree, scheduler } = setup({ + ...zeroWindow, + refreshTree: () => deferred.promise + }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + + deferred.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts new file mode 100644 index 00000000000..246ead05519 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts @@ -0,0 +1,152 @@ +import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../../../shared/filesystem-watch-batch-window' +import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' + +type SchedulerTimer = ReturnType + +export type FileExplorerWatchRefreshScheduler = { + requestFullRefresh: () => void + requestDirRefresh: (dirPath: string) => void + cancel: () => void +} + +export type CreateFileExplorerWatchRefreshSchedulerParams = { + refreshTree: () => Promise + refreshDir: (dirPath: string) => Promise + /** Dirs a full tree refresh already re-reads; the scheduler has no view of `expanded`. */ + isCoveredByFullRefresh: (dirPath: string) => boolean + dirConcurrency: number + trailingMs?: number + maxWaitMs?: number + schedule?: (callback: () => void, delayMs: number) => SchedulerTimer + clear?: (timer: SchedulerTimer) => void +} + +/** + * Coalesces watcher-driven File Explorer refreshes into one debounced run. + * + * Why: an overflow storm otherwise issues K × (1 + N_expanded) readDirectory + * round trips on the same SSH mux that carries PTY output. Transport-agnostic on + * purpose — it sits above both the Electron fs:changed bus and the runtime-RPC + * file-change subscription. + */ +export function createFileExplorerWatchRefreshScheduler({ + refreshTree, + refreshDir, + isCoveredByFullRefresh, + dirConcurrency, + trailingMs = WATCH_BATCH_TRAILING_MS, + maxWaitMs = WATCH_BATCH_MAX_WAIT_MS, + schedule = setTimeout, + clear = clearTimeout +}: CreateFileExplorerWatchRefreshSchedulerParams): FileExplorerWatchRefreshScheduler { + let pendingFull = false + const pendingDirs = new Map() + let firstRequestAt: number | null = null + let timer: SchedulerTimer | null = null + let inFlight: Promise | null = null + let disposed = false + + function clearTimer(): void { + if (timer !== null) { + clear(timer) + timer = null + } + } + + function arm(delayMs: number): void { + clearTimer() + timer = schedule(() => { + timer = null + // Why: a run started here would race the in-flight one; the settle + // handler re-arms instead, so nothing pending is dropped. + if (inFlight === null) { + run() + } + }, delayMs) + } + + function armForRequest(): void { + firstRequestAt ??= Date.now() + arm(Math.max(0, Math.min(trailingMs, firstRequestAt + maxWaitMs - Date.now()))) + } + + function run(): void { + const full = pendingFull + const dirs = Array.from(pendingDirs.values()) + pendingFull = false + pendingDirs.clear() + firstRequestAt = null + + const started = (async () => { + // Why: isCoveredByFullRefresh reads a live `expanded` ref, so resolve it + // against the set refreshTree is about to read — a dir expanded during the + // tree refresh was never re-read and must keep its pending refresh. + const covered = full ? new Set(dirs.filter(isCoveredByFullRefresh)) : null + const outcome = full && !disposed ? await refreshTree() : null + // Why: refreshTree bails without touching the expanded dirs when its root load is + // superseded, so trusting `covered` there would drop those refreshes for good — nothing + // marks an expanded dir stale, so a later re-expansion won't re-read it either. + // Why: 'root-unreadable' is the opposite case. The transport just failed a read, so + // re-issuing buys one dead timeout per dir and holds `inFlight` open the whole time, + // blocking every later refresh. Drop them; a recovered watcher resyncs with an overflow. + const outstanding = + outcome === 'root-unreadable' + ? [] + : covered && outcome === 'refreshed' + ? dirs.filter((dirPath) => !covered.has(dirPath)) + : dirs + // Why: forEachWithConcurrency has no cancel hook, and the bound refreshDir + // is a live ref — a queued path would otherwise be read against the next + // worktree's binding after a switch. + await forEachWithConcurrency(outstanding, dirConcurrency, (dirPath) => + disposed ? Promise.resolve() : refreshDir(dirPath) + ) + })() + inFlight = started + void started + .catch(() => { + // Refresh failures are transient; the next watcher event retries. + }) + .finally(() => { + if (inFlight === started) { + inFlight = null + } + if (!disposed && (pendingFull || pendingDirs.size > 0)) { + arm(trailingMs) + } + }) + } + + return { + requestFullRefresh: () => { + if (disposed) { + return + } + // Why: pendingDirs is deliberately NOT cleared. toggleDir drops a dir from + // expandedDirs without purging dirCache, so cache keys are a strict + // superset of what refreshTree re-reads; subsuming them would strand a + // stale listing for a collapsed-but-cached dir. + pendingFull = true + armForRequest() + }, + requestDirRefresh: (dirPath: string) => { + if (disposed) { + return + } + pendingDirs.set(normalizeRuntimePathForComparison(dirPath), dirPath) + armForRequest() + }, + cancel: () => { + disposed = true + clearTimer() + pendingFull = false + pendingDirs.clear() + firstRequestAt = null + } + } +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts b/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts index 36fc1d24caf..b5c48de77da 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' const REFRESH_SPINNER_DELAY_MS = 200 -export function useFileExplorerManualRefresh(refreshTree: () => Promise): { +export function useFileExplorerManualRefresh(refreshTree: () => Promise): { isRefreshing: boolean showRefreshSpinner: boolean handleRefresh: () => void diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx new file mode 100644 index 00000000000..9c665f4cce6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DirEntry } from '../../../../shared/types' +import { useFileExplorerTree } from './useFileExplorerTree' + +const readDirectoryMock = vi.hoisted(() => vi.fn()) +vi.mock('./file-explorer-directory-listing', async (importOriginal) => ({ + ...(await importOriginal>()), + readFileExplorerDirectory: readDirectoryMock +})) +vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({ + ...(await importOriginal>()), + getFileExplorerOperationOwner: () => ({ kind: 'local' as const }) +})) + +function entry(name: string, isDirectory = false): DirEntry { + return { name, isDirectory } as DirEntry +} + +function listing(...entries: DirEntry[]) { + return { entries, operationOwner: { kind: 'local' as const } } +} + +describe('useFileExplorerTree stale collapsed dirs', () => { + beforeEach(() => { + readDirectoryMock.mockReset() + readDirectoryMock.mockResolvedValue(listing()) + }) + + afterEach(() => { + cleanup() + }) + + it('marks a collapsed cached dir the full refresh skipped, and clears it on the next read', async () => { + const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1')) + + readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts'))) + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + expect(result.current.dirCache['/repo/src'].children).toHaveLength(1) + expect(result.current.isDirStale('/repo/src')).toBe(false) + + // The overflow refresh reads root and the (empty) expanded set — not the collapsed /repo/src. + readDirectoryMock.mockClear() + await act(async () => { + await result.current.refreshTree() + }) + expect(readDirectoryMock).not.toHaveBeenCalledWith('wt-1', '/repo', '/repo/src') + expect(result.current.isDirStale('/repo/src')).toBe(true) + // Its cached children stay put until something re-reads them. + expect(result.current.dirCache['/repo/src'].children).toHaveLength(1) + + await act(async () => { + await result.current.loadDir('/repo/src', 0, { force: true }) + }) + expect(result.current.isDirStale('/repo/src')).toBe(false) + expect(result.current.dirCache['/repo/src'].children).toHaveLength(0) + }) + + it('does not mark the root or a dir the full refresh re-read', async () => { + const expanded = new Set(['/repo/src']) + const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + + expect(result.current.isDirStale('/repo')).toBe(false) + expect(result.current.isDirStale('/repo/src')).toBe(false) + }) + + it('keeps a mark a superseded refresh never verified, even once the dir is expanded', async () => { + // Regression guard: refreshTree used to REPLACE the mark set. A dir marked while collapsed and + // then expanded was dropped by the replacement (expanded dirs are never marked) — and when that + // refresh bailed on a superseded root load, nothing re-read it and nothing still called it + // unverified, so re-expanding served the pre-overflow listing forever. + const expanded = new Set() + const { result, rerender } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts'))) + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + expect(result.current.isDirStale('/repo/src')).toBe(true) + + expanded.add('/repo/src') + rerender() + + let releaseRoot!: () => void + const rootGate = new Promise((resolve) => { + releaseRoot = resolve + }) + readDirectoryMock.mockImplementationOnce(async () => { + await rootGate + return listing() + }) + + let refresh!: Promise + act(() => { + refresh = result.current.refreshTree() + }) + await act(async () => { + // A concurrent root read supersedes this refresh's token, so it bails before the expanded set. + void result.current.refreshDir('/repo') + releaseRoot() + expect(await refresh).toBe('superseded') + }) + + expect(result.current.isDirStale('/repo/src')).toBe(true) + }) + + it('does not read the expanded dirs when the root read failed', async () => { + // Regression guard: loadDir swallows read errors unless failOnError is set, so a dead + // transport used to report a completed root read and fan out one doomed wave per 4 dirs. + const expanded = new Set(['/repo/a', '/repo/b']) + const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + readDirectoryMock.mockReset() + readDirectoryMock.mockRejectedValue(new Error('ETIMEDOUT')) + + await act(async () => { + expect(await result.current.refreshTree()).toBe('root-unreadable') + }) + + // Only the root read was attempted; the expanded dirs were not. + expect(readDirectoryMock).toHaveBeenCalledTimes(1) + expect(readDirectoryMock).toHaveBeenCalledWith('wt-1', '/repo', '/repo') + }) + + it('drops stale marks when the tree is reset for a new worktree', async () => { + const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1')) + + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + expect(result.current.isDirStale('/repo/src')).toBe(true) + + await act(async () => { + result.current.resetAndLoad() + }) + expect(result.current.isDirStale('/repo/src')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 591d9fd4d9f..d6f695c2ac3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,12 +1,9 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' -import type { DirCache } from './file-explorer-types' +import type { DirCache, FileExplorerTreeRefreshOutcome } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { statRuntimePath } from '@/runtime/runtime-file-client' -import { - createFileExplorerDirLoadTracker, - type FileExplorerDirLoadTracker -} from './file-explorer-dir-load-tracker' +import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' import { getFileExplorerOperationOwner, getFileExplorerOwnerUnresolvedMessage, @@ -14,9 +11,11 @@ import { } from './file-explorer-operation-owner' import { fileExplorerEntriesToTreeNodes, - readFileExplorerDirectory, - type FileExplorerDirectoryListing + readFileExplorerDirectory } from './file-explorer-directory-listing' +import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh' +import { collectStaleDirCachePaths } from './file-explorer-stale-dir-cache' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' type UseFileExplorerTreeResult = { dirCache: Record @@ -30,113 +29,13 @@ type UseFileExplorerTreeResult = { ) => Promise statPath: (path: string) => Promise<{ isDirectory: boolean }> markPathAsDirectory: (path: string) => void - refreshTree: () => Promise + refreshTree: () => Promise refreshDir: (dirPath: string) => Promise + /** True while a dir's cached listing predates the last full refresh that skipped it. */ + isDirStale: (dirPath: string) => boolean resetAndLoad: () => void } -type RefreshFileExplorerTreeDir = { - dirPath: string - depth: number -} - -type RefreshFileExplorerExpandedDirsParams = { - dirs: RefreshFileExplorerTreeDir[] - worktreePath: string - dirLoadTracker: FileExplorerDirLoadTracker - setDirCache: Dispatch>> - readDirectory: (dirPath: string) => Promise -} - -export async function refreshFileExplorerExpandedDirs({ - dirs, - worktreePath, - dirLoadTracker, - setDirCache, - readDirectory -}: RefreshFileExplorerExpandedDirsParams): Promise { - if (dirs.length === 0) { - return true - } - - const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) - const loadTokens = new Map( - uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) - ) - - // Why: expanded refresh can touch many directories; commit the loading and - // result states in two batched setDirCache writes (rather than per-directory) - // so refreshing large worktrees stays O(N) instead of O(N²) cache spreads. - setDirCache((prev) => { - const next = { ...prev } - for (const { dirPath } of uniqueDirs) { - next[dirPath] = { - children: prev[dirPath]?.children ?? [], - loading: true - } - } - return next - }) - - const results = await Promise.all( - uniqueDirs.map(async ({ dirPath, depth }) => { - const loadToken = loadTokens.get(dirPath)! - try { - const listing = await readDirectory(dirPath) - if (!dirLoadTracker.isCurrent(loadToken)) { - return { current: false as const } - } - return { - current: true as const, - dirPath, - cache: { - children: fileExplorerEntriesToTreeNodes( - listing.entries, - dirPath, - depth, - worktreePath, - listing.operationOwner - ), - loading: false, - operationOwner: listing.operationOwner - } - } - } catch { - if (!dirLoadTracker.isCurrent(loadToken)) { - return { current: false as const } - } - return { - current: true as const, - dirPath, - cache: { children: [], loading: false } - } - } - }) - ) - - // Why: the batch commits only after the slowest read, so a dir can be - // superseded (watcher refreshDir, worktree reset) after its own read - // resolved. Re-check tokens at commit time so the batched write never - // clobbers a newer load — preserving the old per-dir commit ordering. - const currentResults = results.filter( - (result): result is Extract => - result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!) - ) - if (currentResults.length === 0) { - return false - } - - setDirCache((prev) => { - const next = { ...prev } - for (const result of currentResults) { - next[result.dirPath] = result.cache - } - return next - }) - - return currentResults.length === uniqueDirs.length -} - export function useFileExplorerTree( worktreePath: string | null, expanded: Set, @@ -147,6 +46,11 @@ export function useFileExplorerTree( const dirCacheRef = useRef(dirCache) dirCacheRef.current = dirCache const dirLoadTrackerRef = useRef(createFileExplorerDirLoadTracker()) + // Why: a ref, not state — the expansion effect must read the mark set by a refresh that landed + // after the effect's render, and a state write would only be visible one render too late. + const staleDirsRef = useRef(new Set()) + // Why: separates a failed root read from a superseded one — loadDir returns false for both. + const rootReadFailedRef = useRef(false) const loadDir = useCallback( async ( @@ -159,6 +63,8 @@ export function useFileExplorerTree( return true } const loadToken = dirLoadTrackerRef.current.begin(dirPath) + // Why: this read starts after the refresh that marked the dir, so its result is current. + staleDirsRef.current.delete(dirPath) // Why: when force-reloading a directory (e.g. after a file is created, // duplicated, or deleted), keep the previous children visible while the // fresh listing loads. Clearing to [] would momentarily shrink the @@ -200,6 +106,7 @@ export function useFileExplorerTree( // empty worktree. Preserve the message so the UI can distinguish // "no files" from "could not read this worktree". setRootError(error instanceof Error ? error.message : String(error)) + rootReadFailedRef.current = true } setDirCache((prev) => ({ ...prev, [dirPath]: { children: [], loading: false } })) return !options?.failOnError @@ -248,17 +155,42 @@ export function useFileExplorerTree( [activeWorktreeId, worktreePath] ) - const refreshTree = useCallback(async () => { + const refreshTree = useCallback(async (): Promise => { if (!worktreePath) { - return + // Why: not 'root-unreadable' — no read was attempted, and that outcome tells callers to DROP + // their pending refreshes. Report the refresh as not-done so they keep them instead. + return 'superseded' } // Why: clearing the entire dirCache here would momentarily empty the // visible projection and jump the virtualizer to the top. Instead we rely // on force-reload keeping existing children visible until fresh data lands. + // Why: mark before the first read, against the live expanded set — a dir expanded after this + // point loads through the expansion effect, which would otherwise trust a listing this refresh + // never re-read. + // Why: union, not replace. This refresh can bail below without re-reading a single expanded + // dir, so replacing would erase an earlier mark for a dir nothing has verified since. Marks are + // cleared only where a read actually lands: loadDir, and onDirCommitted per committed wave. + // Why: prune first — a mark only means anything while the dir still has a cached listing to + // distrust, and unioning without this would accumulate every path visited in the session. + for (const dirPath of staleDirsRef.current) { + if (dirCacheRef.current[dirPath] === undefined) { + staleDirsRef.current.delete(dirPath) + } + } + // Why: callers use the latest refreshTree identity, so this closure has the live expanded set. + for (const dirPath of collectStaleDirCachePaths(dirCacheRef.current, worktreePath, expanded)) { + staleDirsRef.current.add(dirPath) + } const refreshSession = dirLoadTrackerRef.current.getSession() - const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true }) + rootReadFailedRef.current = false + // Why: failOnError, else a dead transport reports a completed root read and we fan out one + // doomed wave per 4 expanded dirs — 200 dirs is ~50 sequential 15s timeouts, and the watch + // scheduler cannot start another refresh for that entire window. + const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true, failOnError: true }) if (!rootLoadCompleted || !dirLoadTrackerRef.current.isSessionCurrent(refreshSession)) { - return + // Why: the expanded dirs below were never re-read either way, but the two reasons want + // opposite handling from callers — see FileExplorerTreeRefreshOutcome. + return rootReadFailedRef.current ? 'root-unreadable' : 'superseded' } // Why: root (worktreePath) was just force-loaded above; exclude it here so // refreshFileExplorerExpandedDirs doesn't queue a duplicate read of root. @@ -268,13 +200,19 @@ export function useFileExplorerTree( dirPath, depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 })) - await refreshFileExplorerExpandedDirs({ + const allDirsCommitted = await refreshFileExplorerExpandedDirs({ dirs: expandedDirs, worktreePath, dirLoadTracker: dirLoadTrackerRef.current, setDirCache, - readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) + readDirectory: (dirPath) => + readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath), + maxConcurrentReads: fileExplorerRefreshConcurrency( + getFileExplorerOperationOwner(activeWorktreeId) + ), + onDirCommitted: (dirPath) => staleDirsRef.current.delete(dirPath) }) + return allDirsCommitted ? 'refreshed' : 'superseded' }, [activeWorktreeId, expanded, loadDir, worktreePath]) const refreshDir = useCallback( @@ -291,12 +229,15 @@ export function useFileExplorerTree( [worktreePath, loadDir] ) + const isDirStale = useCallback((dirPath: string) => staleDirsRef.current.has(dirPath), []) + const rootCache = worktreePath ? dirCache[worktreePath] : undefined const resetAndLoad = useCallback(() => { // Why: stale readDir responses from the previous worktree/reset session // must not repopulate the explorer after the tree has been cleared. dirLoadTrackerRef.current.reset() + staleDirsRef.current.clear() setDirCache({}) setRootError(null) if (worktreePath) { @@ -314,6 +255,7 @@ export function useFileExplorerTree( markPathAsDirectory, refreshTree, refreshDir, + isDirStale, resetAndLoad } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx index b6dd71c8a05..00ffeee274d 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx @@ -22,24 +22,28 @@ function useProjection(query: string) { }) } -function useTreeProjection() { +function treeDirCache(loading: boolean) { + return { + '/repo': { + children: [ + { + name: 'src', + path: '/repo/src', + relativePath: 'src', + isDirectory: true, + depth: 0 + } + ], + loading + } + } +} + +function useTreeProjection(dirCache = treeDirCache(false)) { return useFileExplorerVisibleRowProjection( 'worktree-1', '/repo', - { - '/repo': { - children: [ - { - name: 'src', - path: '/repo/src', - relativePath: 'src', - isDirectory: true, - depth: 0 - } - ], - loading: false - } - }, + dirCache, new Set(), true, true, @@ -95,4 +99,19 @@ describe('file explorer ignored-path query debounce', () => { expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src']) }) + + it('does not re-issue the ignored check for a dirCache commit that changes no path', () => { + // A wave-batched tree refresh commits a fresh dirCache object per wave. The + // ignored query is an uncancellable remote git check-ignore over the whole + // visible tree, so identical contents must not re-issue it. + const hook = renderHook(({ dirCache }) => useTreeProjection(dirCache), { + initialProps: { dirCache: treeDirCache(false) } + }) + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + + hook.rerender({ dirCache: treeDirCache(true) }) + hook.rerender({ dirCache: treeDirCache(false) }) + + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index 813555af1c5..06a5e110ad4 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -109,6 +109,28 @@ export function createVisibleFileExplorerRowProjection( return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) } +/** + * Holds the array identity while its contents are unchanged. + * + * Why: a tree refresh commits dirCache once per read wave, and every commit + * rebuilds this list. Each new identity would re-issue the uncancellable git + * check-ignore over the whole visible tree — the remote round trips the wave cap + * exists to bound. + */ +function useContentStableRelativePaths(relativePaths: string[], enabled: boolean): string[] { + // Why: filters need fresh identities per keystroke and must not evict the tree signature. + // Why: NUL cannot occur in paths, so the signature can reconstruct the list losslessly. + const signature = useMemo( + () => (enabled ? relativePaths.join('\u0000') : null), + [enabled, relativePaths] + ) + const stableTreePaths = useMemo( + () => (signature ? signature.split('\u0000') : EMPTY_RELATIVE_PATHS), + [signature] + ) + return enabled ? stableTreePaths : relativePaths +} + export function useFileExplorerVisibleRowProjection( activeWorktreeId: string | null, worktreePath: string | null, @@ -128,7 +150,7 @@ export function useFileExplorerVisibleRowProjection( const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true - const relativePaths = useMemo( + const rebuiltRelativePaths = useMemo( () => activeRepoSupportsGit ? nameFilter @@ -140,6 +162,9 @@ export function useFileExplorerVisibleRowProjection( : EMPTY_RELATIVE_PATHS, [activeRepoSupportsGit, dirCache, expanded, nameFilter, showDotfiles, worktreePath] ) + // Why: the name-filter list is debounced per keystroke, so it must keep a fresh + // identity; only the dirCache-derived list needs stability across wave commits. + const relativePaths = useContentStableRelativePaths(rebuiltRelativePaths, !nameFilter) const canLoadIgnoredPaths = activeRepoSupportsGit && Boolean(activeWorktreeId) && diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index be089be1666..c75dfd4fac8 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -1,13 +1,21 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { FsChangedPayload } from '../../../../shared/types' -import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types' +import type { + DirCache, + FileExplorerOperationOwner, + FileExplorerTreeRefreshOutcome +} from './file-explorer-types' import type { InlineInput } from './FileExplorerRow' import { useAppStore } from '@/store' import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { + getFileExplorerOperationOwner, getFileExplorerOperationOwnerFromState, type FileExplorerOwnerState } from './file-explorer-operation-owner' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' +import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler' import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile' export { @@ -30,7 +38,7 @@ type UseFileExplorerWatchParams = { expanded: Set setSelectedPath: Dispatch> refreshDir: (dirPath: string) => Promise - refreshTree: () => Promise + refreshTree: () => Promise inlineInput: InlineInput | null dragSourcePath: string | null isNativeDragOver: boolean @@ -127,6 +135,29 @@ export function useFileExplorerWatch({ const currentWorktreePath = worktreePath + // Why: one scheduler per subscription covers BOTH remote transports (the + // Electron fs:changed bus and the runtime-RPC subscription below), and its + // lifetime is tied to the watched worktree so a switch can't refresh the + // previous root. + const owner = getFileExplorerOperationOwner(worktreeIdRef.current) + const scheduler = createFileExplorerWatchRefreshScheduler({ + refreshTree: () => refreshTreeRef.current(), + refreshDir: (dirPath) => refreshDirRef.current(dirPath), + // Why exact-match on `expanded`, not a normalized compare: dirPath already arrives as a + // resolved dirCache key (resolveCachedDirPath), and refreshTree re-reads dirCache under the + // verbatim expanded strings — a differently-spelled key it never wrote would be reported + // covered and left stale. Only the root is normalized: it is keyed by worktreePath. + isCoveredByFullRefresh: (dirPath) => + normalizeRuntimePathForComparison(dirPath) === + normalizeRuntimePathForComparison(currentWorktreePath) || + expandedRef.current.has(dirPath), + dirConcurrency: fileExplorerRefreshConcurrency(owner), + // Why: main already coalesced the burst on this same 150/500 window before it reached the + // fs:changed bus, so a second debounce here only doubles paint latency. Zero still arms a 0ms + // timer, keeping intra-payload dedup and the concurrency cap. + ...(owner.kind === 'local' ? { trailingMs: 0, maxWaitMs: 0 } : {}) + }) + function processPayload(payload: FsChangedPayload): void { const wtId = worktreeIdRef.current if (!wtId) { @@ -140,12 +171,8 @@ export function useFileExplorerWatch({ expanded: expandedRef.current, setDirCache, setSelectedPath, - refreshDir: (dirPath) => { - void refreshDirRef.current(dirPath) - }, - refreshTree: () => { - void refreshTreeRef.current() - } + refreshDir: scheduler.requestDirRefresh, + refreshTree: scheduler.requestFullRefresh }) } @@ -207,6 +234,7 @@ export function useFileExplorerWatch({ return () => { disposed = true unsubscribeListener?.() + scheduler.cancel() deferredRef.current = [] processPayloadRef.current = null } diff --git a/src/shared/filesystem-watch-batch-window.ts b/src/shared/filesystem-watch-batch-window.ts new file mode 100644 index 00000000000..fbbae133b78 --- /dev/null +++ b/src/shared/filesystem-watch-batch-window.ts @@ -0,0 +1,5 @@ +// Why: the main-process watcher batcher and the renderer's Explorer refresh +// scheduler share one flush window so local and remote latency can't drift. + +export const WATCH_BATCH_TRAILING_MS = 150 +export const WATCH_BATCH_MAX_WAIT_MS = 500 From b04c695750e9859e0d8bf79f503279bc4d656925 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:57:24 -0700 Subject: [PATCH 22/22] fix(runtime): drop stale local agent rows from worktree.ps after tab close (#11464) * fix(runtime): drop stale local agent rows from worktree.ps after tab close attachAgentRowsToSummaries attached every hydrated hook row by worktreeId with no check that the pane/tab still exists, so agents from closed tabs (last-status.json hydrates for days) kept showing on mobile as current activity. Local rows now require the tab in a session/runtime graph or a connected PTY; remote rows are exempt since their tabs may only exist on the remote host. Fixes #6072 * fix(runtime): resolve legacy numeric pane keys through the stale-row filter Non-UUID leaves produce tabId:paneRuntimeId keys with no tabId field; without parsing them the stale filter was bypassed entirely for such rows. * fix(runtime): filter stale WSL agent rows * fix(runtime): ignore persisted tabs for agent liveness * fix(runtime): restore session-tab liveness and thread OSC transport through the stale-row filter Review loop pass 1 (3 independent same-model reviewers, findings converged): - Revert a829e8f9cf's `!this.tabs.has(tabId)` to `mirroredWorktreeId === undefined`. The renderer graph is structurally empty under headless serve (index.ts publishes {tabs: [], leaves: []}), is cleared by markGraphUnavailable, and omits unvisited/cold-parked workspaces, so graph-only existence dropped live agent rows in all those states and broke worktree.ps/session.tabs.list parity. Every close path prunes the persisted tab, so session tabs remain valid liveness evidence; the stale-persisted-tab premise did not survive tracing. - Restore the rename and legacy-pane-key tests to their session-only fixtures (the graph syncs added with the flipped predicate masked the contract change) and pin the restored contract in a named test. - Thread the pane's connectionId through RuntimeAgentRowSnapshot so OSC-retained rows keep the SSH exemption; previously a fresher OSC ping hardcoded null and stripped it. - Pin each rescue conjunct individually (paneKey-only, tabId-only, ptyId after binding clear), the WSL keep direction, the unresolvable-paneKey guard, and row presence in the freshness cases. * fix(runtime): carry the OSC-observed ptyId when a hook row wins the freshness race Hook payloads have no ptyId field, so overwriting the rowSources entry discarded the OSC-observed one and the connected-PTY ptyId rescue went dead for hook-fresh panes during a binding-clear window (pass-2 review P3). Also corrects the incarnation-change comment on the OSC rescue test. --- src/main/runtime/orca-runtime.test.ts | 492 +++++++++++++++++++++++++- src/main/runtime/orca-runtime.ts | 82 ++++- 2 files changed, 564 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 13534a7d560..e89bf7fb214 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -32411,6 +32411,8 @@ describe('OrcaRuntimeService', () => { ] }) + // No renderer graph on purpose: headless rename attribution must work from + // the persisted session alone. const { worktrees } = await runtime.getWorktreePs() const oldSummary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) const renamedSummary = worktrees.find((worktree) => worktree.worktreeId === renamedWorktreeId) @@ -32502,16 +32504,498 @@ describe('OrcaRuntimeService', () => { } ] }) + // Why: local rows only project while their tab exists (#6072); freshness + // is what varies here, so keep the tab present in the runtime graph. + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: '33333333-3333-4333-8333-333333333333', + layout: null + } + ], + leaves: [] + }) const { worktrees } = await runtime.getWorktreePs() - expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ - hasHostSidebarActivity, - status - }) + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + expect(summary).toMatchObject({ hasHostSidebarActivity, status }) + // Why: inactive must mean "projected but not fresh", never "row dropped". + expect(summary?.agents).toHaveLength(1) } ) + it('drops a hydrated done hook row after its local tab is closed', async () => { + // Why (#6072): last-status.json hydrates hook rows for days; a closed tab's + // agent must not resurface on mobile as current worktree activity. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-tab:66666666-6666-4666-8666-666666666666', + worktreeId: TEST_WORKTREE_ID, + tabId: 'closed-tab', + state: 'done', + prompt: 'refactor the parser', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ + liveTerminalCount: 0, + hasHostSidebarActivity: false, + status: 'inactive', + agents: [] + }) + }) + + it('keeps a hydrated row while its persisted session tab exists and no renderer graph is attached', async () => { + // Why: headless serve has no renderer graph; session.tabs.list serves this + // tab to mobile as current, so its agent row must stay (desktop parity). + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'headless-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'headless-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + worktreeId: TEST_WORKTREE_ID, + tabId: 'headless-tab', + state: 'done', + prompt: 'finished while headless', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ state: 'done', prompt: 'finished while headless' }) + ]) + }) + + it('resolves legacy numeric pane keys through the stale filter too', async () => { + // Why: non-UUID leaves produce `tabId:paneRuntimeId` keys with no tabId + // field; they still name a real tab and must not bypass the filter. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'open-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-tab:7', + worktreeId: TEST_WORKTREE_ID, + state: 'done', + prompt: 'stale legacy pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + }, + { + paneKey: 'open-tab:9', + worktreeId: TEST_WORKTREE_ID, + state: 'working', + prompt: 'live legacy pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey: 'open-tab:9', prompt: 'live legacy pane' }) + ]) + }) + + it('keeps a local hook row while a connected PTY still backs its pane', async () => { + // Why: daemon-held terminals stay live across renderer graph gaps even when + // no session tab records them; a connected PTY is proof the pane exists. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const paneKey = 'daemon-tab:77777777-7777-4777-8777-777777777777' + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'daemon-tab', + state: 'working', + prompt: 'long-running daemon agent', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + // paneKey-only record: the tabId rescue must not be what keeps this row. + runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, { + connected: true, + paneKey + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey, state: 'working', prompt: 'long-running daemon agent' }) + ]) + expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) + }) + + it('keeps a local hook row when a connected PTY matches only its tab id', async () => { + // Split-pane sibling: the PTY's paneKey names another leaf of the same tab, + // so only the tabId conjunct can rescue this row. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'daemon-tab:88888888-8888-4888-8888-888888888887', + worktreeId: TEST_WORKTREE_ID, + tabId: 'daemon-tab', + state: 'working', + prompt: 'sibling pane agent', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime['recordPtyWorktree']('daemon-pty-2', TEST_WORKTREE_ID, { + connected: true, + tabId: 'daemon-tab', + paneKey: 'daemon-tab:99999999-9999-4999-8999-999999999998' + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ prompt: 'sibling pane agent', state: 'working' }) + ]) + }) + + it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => { + // A controller incarnation change nulls pty.tabId/paneKey while the PTY + // stays connected (adoptControllerTerminalHandle); the ptyId conjunct is + // then the only rescue for the retained OSC row. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { + connected: true, + tabId: 'osc-tab', + paneKey: 'osc-tab:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + }) + runtime.onPtyData( + 'osc-pty', + '\x1b]9999;{"state":"working","prompt":"osc reporter","agentType":"codex"}\x07', + 1 + ) + const pty = runtime['ptysById'].get('osc-pty')! + pty.tabId = null + pty.paneKey = null + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'osc reporter' })]) + }) + + it('keeps the connected-PTY rescue when a hook row outraces the OSC row for the same pane', async () => { + // Hook payloads carry no ptyId; the OSC-observed one must survive the + // hook row winning the freshness race or the ptyId rescue goes dead. + const paneKey = 'race-tab:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'race-tab', + state: 'working', + prompt: 'hook-fresh agent', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now() + 60_000, + stateStartedAt: Date.now() - 100 + } + ] + }) + runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { + connected: true, + tabId: 'race-tab', + paneKey + }) + runtime.onPtyData( + 'race-pty', + '\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07', + 1 + ) + const pty = runtime['ptysById'].get('race-pty')! + pty.tabId = null + pty.paneKey = null + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'hook-fresh agent' })]) + }) + + it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => { + // Why: OSC snapshots must carry the pane transport; hardcoding local would + // strip the SSH exemption off rows whose freshest update arrived via OSC. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { + connected: true, + connectionId: 'ssh-osc-1', + tabId: 'ssh-tab', + paneKey: 'ssh-tab:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + }) + runtime.onPtyData( + 'ssh-osc-pty', + '\x1b]9999;{"state":"working","prompt":"remote osc agent","agentType":"codex"}\x07', + 1 + ) + runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: false }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'remote osc agent' })]) + }) + + it('keeps a hook row with an unresolvable pane key', async () => { + // Why: no tabId means staleness is unprovable; the filter must pass it. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'opaque-key-without-structure', + worktreeId: TEST_WORKTREE_ID, + state: 'working', + prompt: 'unattributable pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'unattributable pane' })]) + }) + + it('keeps a WSL hook row while its tab is still in the session', async () => { + // Guards the WSL clause against over-filtering: local-tab evidence must + // rescue WSL rows exactly like null-connection rows. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'open-wsl-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'open-wsl-tab:99999999-9999-4999-8999-999999999997', + worktreeId: TEST_WORKTREE_ID, + tabId: 'open-wsl-tab', + state: 'working', + prompt: 'live in WSL', + agentType: 'codex', + connectionId: 'wsl:Ubuntu', + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'live in WSL' })]) + }) + + it('drops a hydrated WSL hook row after its local tab is closed', async () => { + // Why: WSL relay ids are transport provenance; the pane remains local. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-wsl-tab:99999999-9999-4999-8999-999999999999', + worktreeId: TEST_WORKTREE_ID, + tabId: 'closed-wsl-tab', + state: 'done', + prompt: 'finished in WSL', + agentType: 'codex', + connectionId: 'wsl:Ubuntu', + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([]) + }) + + it('keeps remote hook rows whose tabs are only tracked on the remote host', async () => { + // Why: the local session partition for an SSH host can be empty while the + // remote host owns the terminals; absence there is not proof of a close. + const remoteRepo = { + id: 'repo-ssh-6072', + path: '/home/me/project', + displayName: 'remote-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-6072' + } + const remoteWorktree = { + path: '/home/me/project/.worktrees/feature-agents', + head: 'def', + branch: 'refs/heads/feature/agents', + isBare: false, + isMainWorktree: false + } + const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}` + const metaById: Record = { + [remoteWorktreeId]: makeWorktreeMeta({ displayName: 'Remote agents' }) + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => getDefaultWorkspaceSession() + } + registerSshGitProvider('ssh-6072', { + listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'remote-tab:88888888-8888-4888-8888-888888888888', + worktreeId: remoteWorktreeId, + tabId: 'remote-tab', + state: 'working', + prompt: 'remote agent without local tab records', + agentType: 'codex', + connectionId: 'ssh-6072', + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === remoteWorktreeId) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ prompt: 'remote agent without local tab records' }) + ]) + }) + it('marks the desktop-active worktree as isActive', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 701bfb496e2..963c91811b9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -368,9 +368,15 @@ import { } from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { applyTerminalQuickCommandMutation, MAX_QUICK_COMMANDS, @@ -1449,6 +1455,9 @@ type RuntimeAgentRowSnapshot = { ptyId: string worktreeId?: string tabId?: string + // Transport of the pane's PTY at retain time; null for local. Without it, + // OSC rows for SSH panes would lose the remote exemption in worktree.ps. + connectionId: string | null payload: ParsedAgentStatusPayload // When the current payload.state was first observed for this pane (ms). stateStartedAt: number @@ -10056,6 +10065,7 @@ export class OrcaRuntimeService { target.paneKey, target.worktreeId, target.tabId, + target.connectionId ?? null, payload ) || retainedChanged if (!this.onTerminalAgentStatus) { @@ -10086,6 +10096,7 @@ export class OrcaRuntimeService { paneKey: string, worktreeId: string | undefined, tabId: string | undefined, + connectionId: string | null, payload: ParsedAgentStatusPayload ): boolean { const now = Date.now() @@ -10100,6 +10111,7 @@ export class OrcaRuntimeService { ptyId, worktreeId, tabId, + connectionId, payload, stateStartedAt, updatedAt: now @@ -16962,11 +16974,34 @@ export class OrcaRuntimeService { } } + // Why: a connected PTY proves a pane is still live even when its tab has + // already left every session record (daemon-held terminals, graph gaps). + // Deliberately trusts the optimistic connected flag (no freshPtyLiveness + // gate, unlike the count loops above): evidence only KEEPS rows. + const connectedPtyEvidence = { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } + for (const pty of this.ptysById.values()) { + if (!pty.connected) { + continue + } + connectedPtyEvidence.ptyIds.add(pty.ptyId) + if (pty.tabId) { + connectedPtyEvidence.tabIds.add(pty.tabId) + } + if (pty.paneKey) { + connectedPtyEvidence.paneKeys.add(pty.paneKey) + } + } + this.attachAgentRowsToSummaries( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, - mirroredWorktreeIdByTabId + mirroredWorktreeIdByTabId, + connectedPtyEvidence ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -16985,7 +17020,12 @@ export class OrcaRuntimeService { summaries: Map, runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds: Set, - mirroredWorktreeIdByTabId: ReadonlyMap + mirroredWorktreeIdByTabId: ReadonlyMap, + connectedPtyEvidence: { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet + } ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. @@ -16994,8 +17034,10 @@ export class OrcaRuntimeService { string, { paneKey: string + ptyId?: string tabId?: string worktreeId?: string + connectionId: string | null state: ParsedAgentStatusPayload['state'] agentType: string | null prompt: string @@ -17011,8 +17053,10 @@ export class OrcaRuntimeService { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, + connectionId: snapshot.connectionId, state: payload.state, agentType: payload.agentType ?? null, prompt: payload.prompt, @@ -17033,8 +17077,12 @@ export class OrcaRuntimeService { } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, + // Hook payloads carry no ptyId; keep the OSC-observed one so the + // connected-PTY rescue survives a hook row winning the freshness race. + ptyId: existing?.ptyId, tabId: entry.tabId, worktreeId: entry.worktreeId, + connectionId: entry.connectionId, state: entry.state, agentType: entry.agentType ?? null, prompt: entry.prompt, @@ -17055,9 +17103,31 @@ export class OrcaRuntimeService { for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. - const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId - const worktreeId = - (tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId + // Legacy numeric pane keys (non-UUID leaves) still name a real tab, so + // parse them too — otherwise their rows would bypass the stale filter. + const tabId = + src.tabId ?? + parsePaneKey(src.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(src.paneKey)?.tabId + const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined + if ( + tabId !== undefined && + mirroredWorktreeId === undefined && + (src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) && + !connectedPtyEvidence.tabIds.has(tabId) && + !connectedPtyEvidence.paneKeys.has(src.paneKey) && + (src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId)) + ) { + // Why: hook snapshots hydrate from last-status.json for days, so a row + // from a local or WSL-relayed pane whose tab left every session and the + // live graph, with no connected PTY, is retained history — surfacing it + // resurrects closed agents on mobile (#6072). SSH rows are exempt (their + // tabs may exist only remotely), as are rows with no resolvable tabId + // (staleness unprovable). Session tabs count as existence: headless + // serve has no renderer graph, and session.tabs.list serves them. + continue + } + const worktreeId = mirroredWorktreeId ?? src.worktreeId if (!worktreeId) { continue }