From 786d7048a1adc36e631fced91ec105979916d752 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:38:45 -0700 Subject: [PATCH 01/64] fix(win32): suppress Command Prompt window on IDE launches (#11907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(win32): suppress Command Prompt window on IDE launches - Prefer JetBrains GUI executables (`*64.exe`) over `.cmd` shims to avoid console allocation (STA-3040). - Use `start "" /B` when launching GUI apps via batch scripts; shims chain through console helpers that allocate a visible prompt even with `windowsHide`. `start /B` returns immediately, preventing the lingering window. * fix(win32): suppress Command Prompt window on IDE launches Prevent lingering Command Prompt windows when launching JetBrains IDEs on Windows. Use `start "" /B cmd /d /c` so the nested shell exits with the batch script, but only for JetBrains shims—VS Code and Cursor keep the waiting form because `start` re-parses arguments and breaks remote paths with spaces. Prefer colocated `*64.exe` executables beside the resolved `.cmd` shim over PATH lookups to avoid stale installations. * fix(win32): extend IDE launcher console suppression to direct paths Support IDE paths stored directly in settings (e.g., idea.exe, webstorm.cmd). Detect console idea.exe stubs alongside batch shims for upgrade to GUI *64.exe. Fix start command title escaping: use empty string instead of '""' to prevent libuv re-quoting. --- src/main/editor-launcher-name.ts | 44 +++++ src/main/external-editor-launch.test.ts | 177 ++++++++++++++++- src/main/external-editor-launch.ts | 203 ++++++++++++++------ src/main/ipc/shell.test.ts | 162 +++++++++++----- src/main/ipc/shell.ts | 49 +---- src/main/jetbrains-windows-gui-launchers.ts | 91 +++++++++ src/main/win32-utils.test.ts | 49 +++++ src/main/win32-utils.ts | 3 +- src/shared/windows-batch-spawn.ts | 34 +++- 9 files changed, 653 insertions(+), 159 deletions(-) create mode 100644 src/main/editor-launcher-name.ts create mode 100644 src/main/jetbrains-windows-gui-launchers.ts diff --git a/src/main/editor-launcher-name.ts b/src/main/editor-launcher-name.ts new file mode 100644 index 00000000000..b88511dfea3 --- /dev/null +++ b/src/main/editor-launcher-name.ts @@ -0,0 +1,44 @@ +import { basename, win32 } from 'node:path' + +export function stripMatchingQuotes(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { + return trimmed.slice(1, -1) + } + return trimmed +} + +export function hasMatchingOuterQuotes(value: string): boolean { + const trimmed = value.trim() + const quote = trimmed[0] + return (quote === '"' || quote === "'") && trimmed.endsWith(quote) +} + +function getLeadingShellCommandToken(command: string): string { + const trimmed = command.trim() + const quote = trimmed[0] + if (quote === '"' || quote === "'") { + const closingIndex = trimmed.indexOf(quote, 1) + if (closingIndex > 0) { + return trimmed.slice(1, closingIndex) + } + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +/** Lowercased launcher name without directory or Windows executable extension. */ +export function getLauncherBaseName( + command: string, + options: { shellCommand?: boolean } = {} +): string { + const normalized = options.shellCommand + ? getLeadingShellCommandToken(command) + : stripMatchingQuotes(command) + const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized) + return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase() +} + +export function isWindowsBatchLauncher(command: string): boolean { + return /\.(?:cmd|bat)$/i.test(stripMatchingQuotes(command)) +} diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 5422267f6e1..638e1ff02f7 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -32,6 +32,168 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => { + const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'idea' ? `${installBin}\\idea.cmd` : command + ) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\idea64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\idea64.exe`, + spawnArgs: ['C:\\workspaces\\orca'] + }) + // Why: a bare PATH `idea64` may belong to a different, stale install. + expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' }) + expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything()) + }) + + it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => { + const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'goland64' ? `${installBin}\\goland64.cmd` : command + ) + + expect( + resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\goland64.exe` + }).spawnCmd + ).toBe(`${installBin}\\goland64.exe`) + }) + + it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => { + const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'idea' ? toolboxShim : command + ) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + detachedGui: true, + spawnCmd: toolboxShim, + spawnArgs: ['C:\\workspaces\\orca'] + }) + }) + + it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => { + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + detachedGui: true, + spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat', + spawnArgs: ['C:\\ws'] + }) + }) + + it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\webstorm64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\idea64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\idea64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => { + const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe' + expect( + resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: guiExe, + spawnArgs: ['C:\\ws'] + }) + }) + + it('does not rewrite non-JetBrains .exe launchers via colocation', () => { + const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe' + expect( + resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }).spawnCmd + ).toBe(codeExe) + }) + + it('keeps idea.exe when no colocated idea64.exe exists', () => { + const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe' + expect( + resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: consoleExe, + spawnArgs: ['C:\\ws'] + }) + }) + + // Why: `start` re-parses argv, so only JetBrains shims may take that path. + it.each([ + ['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']], + [ + 'cursor', + 'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd', + ['--new-window', 'C:\\workspaces\\orca'] + ] + ])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => { + resolveCliCommandMock.mockImplementation(() => resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs + }) + }) + it('appends escaped paths to compound macOS open commands', () => { expect( resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", { @@ -85,6 +247,19 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => { + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', { + platform: 'win32' + }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', 'code --reuse-window C:\\workspaces\\orca'] + }) + }) + it('quotes Windows paths with spaces in compound commands', () => { expect( resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' }) @@ -245,7 +420,7 @@ describe('resolveExternalEditorLaunchSpec', () => { ).toEqual([pathValue]) }) - it('does not rewrite compound VS Code commands', () => { + it('does not rewrite compound VS Code commands into WSL remote args', () => { const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' expect( diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index a0283b18f46..6fa0e867508 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,26 +1,46 @@ +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { basename, posix, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import { parseWslUncPath } from '../shared/wsl-paths' import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher' import { resolveCliCommand } from './codex-cli/command' -import { getCmdExePath } from './win32-utils' +import { + getLauncherBaseName, + hasMatchingOuterQuotes, + stripMatchingQuotes +} from './editor-launcher-name' +import { + isJetBrainsConsoleShim, + resolveColocatedJetBrainsGuiExecutable +} from './jetbrains-windows-gui-launchers' +import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +export type ExternalEditorExecutableLaunchSpec = { + kind: 'executable' + hideWindowsConsole: boolean + /** + * Set only for Windows batch shims that would otherwise leave a Command + * Prompt behind; routes the spawn through `start` with an empty title and + * `/B`. Left unset elsewhere because `start` re-parses quoted argv. + */ + detachedGui?: boolean + spawnCmd: string + spawnArgs: string[] +} + +export type ExternalEditorShellLaunchSpec = { + kind: 'shell' + hideWindowsConsole: boolean + spawnCmd: string + spawnArgs: string[] +} + export type ExternalEditorLaunchSpec = - | { - kind: 'executable' - hideWindowsConsole: boolean - spawnCmd: string - spawnArgs: string[] - } - | { - kind: 'shell' - hideWindowsConsole: boolean - spawnCmd: string - spawnArgs: string[] - } + | ExternalEditorExecutableLaunchSpec + | ExternalEditorShellLaunchSpec function escapePosixPathForShell(pathValue: string): string { if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) { @@ -39,41 +59,6 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin : escapePosixPathForShell(pathValue) } -function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string { - const normalized = options.shellCommand - ? getLeadingShellCommandToken(command) - : stripMatchingQuotes(command) - const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized) - return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase() -} - -function getLeadingShellCommandToken(command: string): string { - const trimmed = command.trim() - const quote = trimmed[0] - if (quote === '"' || quote === "'") { - const closingIndex = trimmed.indexOf(quote, 1) - if (closingIndex > 0) { - return trimmed.slice(1, closingIndex) - } - } - return trimmed.split(/\s+/, 1)[0] ?? '' -} - -function stripMatchingQuotes(value: string): string { - const trimmed = value.trim() - const quote = trimmed[0] - if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { - return trimmed.slice(1, -1) - } - return trimmed -} - -function hasMatchingOuterQuotes(value: string): boolean { - const trimmed = value.trim() - const quote = trimmed[0] - return (quote === '"' || quote === "'") && trimmed.endsWith(quote) -} - function isWindowsExecutablePath(command: string): boolean { return win32.isAbsolute(command) && /\.(?:cmd|exe|bat|com)$/i.test(command) } @@ -133,6 +118,46 @@ function isCompoundShellCommand(command: string): boolean { return /\s/.test(command) } +function preferJetBrainsGuiExecutable( + editorCommand: string, + platform: NodeJS.Platform, + fileExists: (path: string) => boolean +): string { + if (platform !== 'win32') { + return editorCommand + } + return resolveColocatedJetBrainsGuiExecutable(editorCommand, fileExists) ?? editorCommand +} + +function resolveSimpleEditorCommand( + command: string, + platform: NodeJS.Platform, + fileExists: (path: string) => boolean +): string { + return preferJetBrainsGuiExecutable( + resolveCliCommand(command, { platform }), + platform, + fileExists + ) +} + +function buildExecutableLaunchSpec( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): ExternalEditorExecutableLaunchSpec { + const spec: ExternalEditorExecutableLaunchSpec = { + kind: 'executable', + hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), + spawnCmd: editorCommand, + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) + } + if (spec.hideWindowsConsole && isJetBrainsConsoleShim(editorCommand, platform)) { + spec.detachedGui = true + } + return spec +} + function buildShellLaunchSpec( command: string, pathValue: string, @@ -140,6 +165,11 @@ function buildShellLaunchSpec( ): ExternalEditorLaunchSpec { const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}` if (platform === 'win32') { + // Why: no `start` wrap here. `start` re-parses the line — it swallows the + // first quoted operand as a window title and mangles remote paths with + // spaces — and on a batch target it leaves a resident nested `cmd /K`. + // User-authored compound commands run verbatim; users who want a detached + // launch already write `start` themselves. return { kind: 'shell', hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }), @@ -165,26 +195,24 @@ export function resolveExternalEditorLaunchSpec( const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND if (isDirectExecutablePath(trimmed, platform, fileExists)) { - const editorCommand = stripMatchingQuotes(trimmed) - return { - kind: 'executable', - hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), - spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) - } + // Why: settings often store a full path to idea.cmd / idea.exe; upgrade + // those the same way as PATH-resolved names when *64.exe is colocated. + return buildExecutableLaunchSpec( + preferJetBrainsGuiExecutable(stripMatchingQuotes(trimmed), platform, fileExists), + pathValue, + platform + ) } if (isCompoundShellCommand(trimmed)) { return buildShellLaunchSpec(trimmed, pathValue, platform) } - const editorCommand = resolveCliCommand(trimmed, { platform }) - return { - kind: 'executable', - hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), - spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) - } + return buildExecutableLaunchSpec( + resolveSimpleEditorCommand(trimmed, platform, fileExists), + pathValue, + platform + ) } export function resolveVsCodeRemoteSshLaunchSpec( @@ -217,3 +245,52 @@ export function resolveVsCodeRemoteSshLaunchSpec( spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue] } } + +function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): { + spawnCmd: string + spawnArgs: string[] + windowsHide: boolean +} { + // Why: only shims flagged during resolution (JetBrains) take the start /B + // detach; every other launcher keeps the waiting form so its argv survives. + if (launchSpec.kind === 'executable') { + const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, { + detachedGui: launchSpec.detachedGui === true + }) + return { ...spawned, windowsHide: launchSpec.hideWindowsConsole } + } + return { + spawnCmd: launchSpec.spawnCmd, + spawnArgs: launchSpec.spawnArgs, + windowsHide: launchSpec.hideWindowsConsole + } +} + +export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { + const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec) + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', windowsHide }) + let settled = false + function cleanup(): void { + child.off('error', onError) + child.off('spawn', onSpawn) + } + function settle(callback: () => void): void { + if (settled) { + return + } + settled = true + cleanup() + callback() + } + function onError(error: Error): void { + settle(() => rejectPromise(error)) + } + function onSpawn(): void { + child.unref() + settle(resolvePromise) + } + child.once('error', onError) + child.once('spawn', onSpawn) + }) +} diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 7193da4beaa..9671eb7a09c 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -297,9 +297,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -320,9 +324,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -334,16 +342,25 @@ describe('registerShellHandlers', () => { }) it('uses a provided launcher command', async () => { + resolveCliCommandMock.mockReturnValueOnce('custom-editor') const workspacePath = resolve('workspace') const handler = getHandler('shell:openInExternalEditor') - await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ - ok: true + await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual( + { + ok: true + } + ) + expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', { + platform: process.platform }) - expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'custom-editor', + [normalize(workspacePath)], + { + detachedGui: false + } + ) }) it.runIf(process.platform === 'win32')( @@ -357,11 +374,13 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ - '--remote', - 'wsl+Ubuntu Preview', - '/home/Ada Lovelace/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + codeShim, + ['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'], + { + detachedGui: false + } + ) } ) @@ -377,9 +396,13 @@ describe('registerShellHandlers', () => { ok: true }) expect(resolveCliCommandMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + nvimPath, + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -392,6 +415,37 @@ describe('registerShellHandlers', () => { } }) + it('detaches JetBrains batch shims on Windows but leaves other launchers waiting', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const workspacePath = normalize(resolve('workspace')) + const ideaShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + const codeShim = 'C:\\Tools\\code.cmd' + const handler = getHandler('shell:openInExternalEditor') + + try { + resolveCliCommandMock.mockReturnValueOnce(ideaShim) + await expect(handler({}, { path: workspacePath, command: 'idea' })).resolves.toEqual({ + ok: true + }) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(ideaShim, [workspacePath], { + detachedGui: true + }) + + resolveCliCommandMock.mockReturnValueOnce(codeShim) + await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({ + ok: true + }) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(codeShim, [workspacePath], { + detachedGui: false + }) + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + } + }) + it('forces Cursor launcher folders into a new window', async () => { resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor') const workspacePath = resolve('workspace') @@ -400,18 +454,24 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/cursor', + ['--new-window', normalize(workspacePath)], + { + detachedGui: false + } + ) resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd') await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith( + 'C:\\Cursor\\cursor.cmd', + ['--new-window', normalize(workspacePath)], + { + detachedGui: false + } + ) }) it('falls back to VS Code when command is blank', async () => { @@ -438,9 +498,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], { detached: true, stdio: 'ignore', @@ -510,11 +574,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder', remotePath], + { + detachedGui: false + } + ) }) it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => { @@ -535,11 +601,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [ - '--remote', - 'ssh-remote+Ada@win-builder.example.com', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'C:\\Tools\\code.cmd', + ['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath], + { + detachedGui: false + } + ) }) it('opens a manual port-22 target with a host-only authority when username is blank', async () => { @@ -558,11 +626,13 @@ describe('registerShellHandlers', () => { await expect( handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder.example.com', - '/srv/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder.example.com', '/srv/project'], + { + detachedGui: false + } + ) }) it('rejects relative SSH paths before resolving or spawning a launcher', async () => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 760b32eed2a..80f02552f18 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,5 +1,4 @@ import { ipcMain, shell, dialog } from 'electron' -import { spawn } from 'node:child_process' import { constants, copyFile, readFile, stat } from 'node:fs/promises' import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,12 +9,11 @@ import type { } from '../../shared/shell-open-types' import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' import type { Store } from '../persistence' -import { getSpawnArgsForWindows } from '../win32-utils' import { EXTERNAL_EDITOR_CLI_COMMAND, + launchExternalEditor, resolveExternalEditorLaunchSpec, - resolveVsCodeRemoteSshLaunchSpec, - type ExternalEditorLaunchSpec + resolveVsCodeRemoteSshLaunchSpec } from '../external-editor-launch' import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority' @@ -72,49 +70,6 @@ async function openInFileManager( } } -async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { - const { spawnCmd, spawnArgs } = - launchSpec.kind === 'executable' - ? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs) - : { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs } - - await new Promise((resolvePromise, rejectPromise) => { - const child = spawn(spawnCmd, spawnArgs, { - detached: true, - stdio: 'ignore', - // Why: terminal editors such as nvim need a visible console on Windows; - // GUI editor launches stay hidden to avoid command-shim flashes. - windowsHide: launchSpec.hideWindowsConsole - }) - let settled = false - - function cleanup(): void { - child.off('error', onError) - child.off('spawn', onSpawn) - } - - function settle(callback: () => void): void { - if (settled) { - return - } - settled = true - cleanup() - callback() - } - - function onError(error: Error): void { - settle(() => rejectPromise(error)) - } - - function onSpawn(): void { - child.unref() - settle(resolvePromise) - } - child.once('error', onError) - child.once('spawn', onSpawn) - }) -} - async function openInExternalEditor( store: Store, request: ShellOpenExternalEditorRequest diff --git a/src/main/jetbrains-windows-gui-launchers.ts b/src/main/jetbrains-windows-gui-launchers.ts new file mode 100644 index 00000000000..900b1339745 --- /dev/null +++ b/src/main/jetbrains-windows-gui-launchers.ts @@ -0,0 +1,91 @@ +import { win32 } from 'node:path' +import { + getLauncherBaseName, + isWindowsBatchLauncher, + stripMatchingQuotes +} from './editor-launcher-name' + +// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The +// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe` +// stubs) chain through helpers that allocate a visible Command Prompt even +// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop. +// Maps each short console launcher name to its GUI sibling. +const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly> = { + idea: 'idea64', + webstorm: 'webstorm64', + pycharm: 'pycharm64', + phpstorm: 'phpstorm64', + goland: 'goland64', + rider: 'rider64', + clion: 'clion64', + rubymine: 'rubymine64', + datagrip: 'datagrip64', + rustrover: 'rustrover64', + studio: 'studio64' +} + +const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI)) + +/** GUI binary basename for a console or GUI launcher name, or null if unknown. */ +function getGuiExecutableName(launcherBaseName: string): string | null { + if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) { + return launcherBaseName + } + return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null +} + +/** + * The GUI `*64.exe` beside an already-resolved console entry point, or null. + * + * Why sibling-only: a bare PATH lookup for `idea64` can land in a different, + * stale install, and it costs a second full PATH walk on Electron's main + * thread. Toolbox script directories ship no exe, so those keep the shim and + * rely on the detached-GUI spawn instead. + * + * Upgrades: + * - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present + * - short-name console stubs `idea.exe` → colocated `idea64.exe` when present + * - leaves `idea64.exe` and non-JetBrains launchers unchanged + */ +export function resolveColocatedJetBrainsGuiExecutable( + resolvedCommand: string, + fileExists: (path: string) => boolean +): string | null { + const unquoted = stripMatchingQuotes(resolvedCommand) + const baseName = getLauncherBaseName(unquoted) + const isBatch = /\.(?:cmd|bat)$/i.test(unquoted) + const isExe = /\.exe$/i.test(unquoted) + if (!isBatch && !isExe) { + return null + } + + // .exe: only short console names (idea.exe), never the GUI binary itself. + if (isExe) { + const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName] + if (!guiName) { + return null + } + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) + return fileExists(candidate) ? candidate : null + } + + // .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated. + const guiName = getGuiExecutableName(baseName) + if (!guiName) { + return null + } + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) + return fileExists(candidate) ? candidate : null +} + +// Why: only JetBrains batch shims chain through console helpers that outlive +// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit +// on their own, and running them under `start` would re-parse their quoted +// WSL/SSH remote argv. detachedGui only rewrites batch targets. +export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean { + return ( + platform === 'win32' && + isWindowsBatchLauncher(command) && + getGuiExecutableName(getLauncherBaseName(command)) !== null + ) +} diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 4159d7d89b4..381fef6643e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -82,6 +82,55 @@ describe('getSpawnArgsForWindows', () => { } }) + it('routes GUI Open In .cmd launches through start /B with an inner cmd /c', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( + 'C:\\Tools\\idea.cmd', + ['C:\\workspaces\\orca'], + { detachedGui: true } + ) + expect(spawnCmd).toBe(getCmdExePath()) + // Why: `start` runs a batch target under a nested `cmd /K` that never + // exits; the inner `cmd /d /c` is what keeps the hidden shell from leaking. + // Title is empty string so libuv emits `""` — not the two-char `'""'`. + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '', + '/B', + getCmdExePath(), + '/d', + '/c', + 'C:\\Tools\\idea.cmd', + 'C:\\workspaces\\orca' + ]) + expect(spawnArgs[3]).toBe('') + expect(spawnArgs).not.toContain('/K') + expect(spawnArgs).not.toContain('""') + expect(spawnArgs[spawnArgs.indexOf('/B') + 1]).not.toMatch(/\.(?:cmd|bat)$/i) + }) + }) + + it('keeps the waiting form for batch launches without detachedGui', () => { + withPlatform('win32', () => { + const { spawnArgs } = getSpawnArgsForWindows('C:\\Tools\\idea.cmd', ['C:\\workspaces\\orca']) + expect(spawnArgs).toEqual(['/d', '/c', 'C:\\Tools\\idea.cmd', 'C:\\workspaces\\orca']) + }) + }) + + it('leaves .exe GUI launches alone even when detachedGui is requested', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( + 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe', + ['C:\\workspaces\\orca'], + { detachedGui: true } + ) + expect(spawnCmd).toBe('C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe') + expect(spawnArgs).toEqual(['C:\\workspaces\\orca']) + }) + }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index c1031c7c55e..151dc9306f0 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -8,7 +8,8 @@ export { isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, - UnsafeWindowsBatchArgumentsError + UnsafeWindowsBatchArgumentsError, + type GetSpawnArgsForWindowsOptions } from '../shared/windows-batch-spawn' function execFileWithoutBlocking( diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts index 4afddccae73..d8312d2ec27 100644 --- a/src/shared/windows-batch-spawn.ts +++ b/src/shared/windows-batch-spawn.ts @@ -39,9 +39,23 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean { return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value) } +export type GetSpawnArgsForWindowsOptions = { + /** + * GUI launchers (Open In apps) should not leave a lingering Command Prompt. + * `start "" /B` returns immediately and keeps console-subsystem children of + * `.cmd`/`.bat` shims from allocating a fresh visible prompt window. + * + * Opt-in only: `start` re-parses the command line, so callers whose argv can + * carry quoted operands (VS Code `--remote` authorities and remote paths with + * spaces) must leave this off. + */ + detachedGui?: boolean +} + export function getSpawnArgsForWindows( command: string, - args: string[] + args: string[], + options: GetSpawnArgsForWindowsOptions = {} ): { spawnCmd: string; spawnArgs: string[] } { if (isWindowsBatchScript(command)) { for (const value of [command, ...args]) { @@ -51,6 +65,24 @@ export function getSpawnArgsForWindows( } // Why: separate argv entries let Node quote spaces without breaking cmd. + if (options.detachedGui) { + // Why: `start` launches a batch target through a nested `cmd /K`, which + // stays resident after the script ends — `/B` only suppresses a *new* + // console, so the shim leaks a hidden cmd.exe. Handing `start` an inner + // `cmd /d /c` makes that interpreter exit with the script. + // + // Window title must be an *empty argv entry* (`''`). libuv's Windows + // quoter turns empty into `""` on the CreateProcess command line — the + // empty title `start` requires so a later quoted path is not eaten as + // the title. The two-character string `'""'` is wrong: libuv re-escapes + // it to `"\"\""`. (Default ComSpec has no spaces, so the bad form often + // still "works"; quoted Program Files paths are where it breaks.) + const cmdExePath = getCmdExePath() + return { + spawnCmd: cmdExePath, + spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args] + } + } return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } } return { spawnCmd: command, spawnArgs: args } From 036b1e78ba63b3e558064b7958025c942dc5974c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:45:49 -0700 Subject: [PATCH 02/64] fix(terminal): add replacement policies for repeated same-handle stream (#12003) Recovery logic strengthened with replacement-policy tiers (reuse/prefer-replacement/require-replacement) and bounded same-handle end cycles. Prevents infinite flapping by capping reuse attempts and inventory wait windows. Tracks ready evidence to reattach from prior snapshots when inventory becomes unavailable. --- .../remote-runtime-pty-transport.test.ts | 802 ++++++++++++++++++ .../remote-runtime-pty-transport.ts | 265 +++++- 2 files changed, 1021 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 9384890b4ea..e83a565bd95 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -63,6 +63,44 @@ describe('createRemoteRuntimePtyTransport', () => { return payload } + function subscribedTerminalHandles(): string[] { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + } + + function readyHostSessionInventoryResponse(terminal: string, hostTabId = 'host-tab-1'): unknown { + return { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-ready', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: `${hostTabId}::pane:1`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${hostTabId}::pane:1`, + parentTabId: hostTabId, + leafId: 'pane:1', + title: 'Terminal', + isActive: true, + status: 'ready', + terminal + } + ] + } + } + } + function emitOutput(streamId: number, data: string): void { subscriptionCallbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -1954,6 +1992,770 @@ describe('createRemoteRuntimePtyTransport', () => { }) }) + it('retries inventory and reattaches the same HUB handle after a stream ends', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const onPtyRebind = vi.fn() + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyExit, + onPtyRebind + }) + + resolvedPaneHandle = 'terminal-stable' + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-stable', + cols: 100, + rows: 30, + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'before stream end') + expect(transport.isConnected()).toBe(true) + + let inventoryAvailable = false + let hostListCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + if (!inventoryAvailable) { + throw new Error('runtime reconnect in progress') + } + return readyHostSessionInventoryResponse('terminal-stable') + }) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + + const subscribeCount = (): number => + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe).length + expect(subscribeCount()).toBe(1) + expect(hostListCalls).toBeGreaterThan(1) + expect(hostListCalls).toBeLessThan(25) + inventoryAvailable = true + await vi.advanceTimersByTimeAsync(16_000) + + expect(hostListCalls).toBeLessThan(40) + expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable']) + expect(onPtyRebind).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBe('remote:hub-env@@terminal-stable') + expect(transport.isConnected()).toBe(false) + + emitSnapshot(latestSubscribePayload().streamId, 'same handle reattached') + expect(transport.isConnected()).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('caps unavailable host inventory at two recovery windows', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-stable' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-stable', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'before inventory outage') + + let hostListCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'session.tabs.list') { + hostListCalls += 1 + throw new Error('runtime reconnect in progress') + } + return { ok: true, result: {} } + }) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(32_000) + + const callsAfterTwoWindows = hostListCalls + expect(callsAfterTwoWindows).toBeGreaterThan(25) + expect(callsAfterTwoWindows).toBeLessThan(40) + await vi.advanceTimersByTimeAsync(20_000) + expect(hostListCalls).toBe(callsAfterTwoWindows) + expect(transport.getRecoveryState?.().phase).toBe('recovering') + + await vi.advanceTimersByTimeAsync(9_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(subscribedTerminalHandles()).toEqual(['terminal-stable']) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches from prior ready evidence when the trailing inventory poll fails', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-stable' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-stable', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'before trailing inventory failure') + + let hostListCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + if (hostListCalls === 1) { + return readyHostSessionInventoryResponse('terminal-stable') + } + throw new Error('final inventory poll failed') + }) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + + expect(hostListCalls).toBeGreaterThan(1) + expect(hostListCalls).toBeLessThan(25) + expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable']) + expect(onPtyExit).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + emitSnapshot(latestSubscribePayload().streamId, 'reattached from ready evidence') + expect(transport.isConnected()).toBe(true) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('strengthens repeated same-handle end recovery until it disconnects', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-flapping' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-flapping', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'session.tabs.list' + ? readyHostSessionInventoryResponse('terminal-flapping') + : { ok: true, result: {} } + ) + emitSnapshot(latestSubscribePayload().streamId, 'initial stream') + + for (let cycle = 0; cycle < 2; cycle += 1) { + const endingStreamId = latestSubscribePayload().streamId + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: endingStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + expect(subscribedTerminalHandles()).toHaveLength(cycle + 2) + emitSnapshot(latestSubscribePayload().streamId, `same handle cycle ${cycle}`) + expect(transport.isConnected()).toBe(true) + } + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: latestSubscribePayload().streamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + + expect(subscribedTerminalHandles()).toEqual([ + 'terminal-flapping', + 'terminal-flapping', + 'terminal-flapping' + ]) + expect(transport.isConnected()).toBe(false) + await vi.advanceTimersByTimeAsync(45_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores a delayed stale send after the pane has rebound to a healthy handle', async () => { + resolvedPaneHandle = 'terminal-old' + let resolveOldSend: (response: unknown) => void = () => {} + const oldSendResponse = new Promise((resolve) => { + resolveOldSend = resolve + }) + let hostListCalls = 0 + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-old', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'old handle') + runtimeCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.send') { + return oldSendResponse + } + if (args.method === 'session.tabs.list') { + hostListCalls += 1 + return Promise.resolve(readyHostSessionInventoryResponse('terminal-new')) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const sendInputAccepted = transport.sendInputAccepted + if (!sendInputAccepted) { + throw new Error('Expected acknowledged remote terminal input') + } + const pendingSend = sendInputAccepted('sent-before-rebind') + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) + ) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'error', streamId: oldStreamId, message: 'terminal_handle_stale' } + }) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-new' }) + ) + emitSnapshot(latestSubscribePayload().streamId, 'new handle') + expect(transport.isConnected()).toBe(true) + + resolveOldSend({ + ok: false, + error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' } + }) + await expect(pendingSend).resolves.toBe(false) + + expect(subscribedTerminalHandles()).toEqual(['terminal-old', 'terminal-new']) + expect(hostListCalls).toBe(1) + expect(onPtyExit).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-new') + expect(transport.isConnected()).toBe(true) + }) + + it('honors a sticky replacement requirement during non-web pane resolution', async () => { + resolvedPaneHandle = 'terminal-old' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-old', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'old handle') + + let resolveOldSend: (response: unknown) => void = () => {} + const oldSendResponse = new Promise((resolve) => { + resolveOldSend = resolve + }) + let resolvePane: (response: unknown) => void = () => {} + const paneResponse = new Promise((resolve) => { + resolvePane = resolve + }) + runtimeCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.send') { + return oldSendResponse + } + if (args.method === 'terminal.resolvePane') { + return paneResponse + } + return Promise.resolve({ ok: true, result: {} }) + }) + const sendInputAccepted = transport.sendInputAccepted + if (!sendInputAccepted) { + throw new Error('Expected acknowledged remote terminal input') + } + const pendingSend = sendInputAccepted('sent-before-close') + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) + ) + + subscriptionCallbacks?.onClose?.() + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + ) + resolveOldSend({ + ok: false, + error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' } + }) + await expect(pendingSend).resolves.toBe(false) + resolvePane({ + ok: true, + result: { + terminal: { + handle: 'terminal-old', + tabId: 'tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + } + } + }) + + await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledOnce()) + expect(subscribedTerminalHandles()).toEqual(['terminal-old']) + expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-old') + expect(transport.getPtyId()).toBeNull() + expect(transport.isConnected()).toBe(false) + }) + + it('does not subscribe a same handle condemned after its inventory wait returns', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-old' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyRebind = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyRebind + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-old', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'old handle') + + let resolveOldSend: (response: unknown) => void = () => {} + const oldSendResponse = new Promise((resolve) => { + resolveOldSend = resolve + }) + let hostListCalls = 0 + runtimeCall.mockImplementation( + (args: { method: string; timeoutMs?: number }): Promise => { + if (args.method === 'terminal.send') { + return oldSendResponse + } + if (args.method !== 'session.tabs.list') { + return Promise.resolve({ ok: true, result: {} }) + } + hostListCalls += 1 + const response = readyHostSessionInventoryResponse('terminal-old') + if ((args.timeoutMs ?? 15_000) > 1_000) { + return Promise.resolve(response) + } + return new Promise((resolve) => { + setTimeout(() => { + // Why: settle the stale send between the inner wait and its caller's continuation. + queueMicrotask(() => + queueMicrotask(() => + resolveOldSend({ + ok: false, + error: { + code: 'terminal_handle_stale', + message: 'terminal_handle_stale' + } + }) + ) + ) + resolve(response) + }, args.timeoutMs) + }) + } + ) + const sendInputAccepted = transport.sendInputAccepted + if (!sendInputAccepted) { + throw new Error('Expected acknowledged remote terminal input') + } + const pendingSend = sendInputAccepted('sent-before-stream-end') + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.send' }) + ) + ) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + await expect(pendingSend).resolves.toBe(false) + + expect(hostListCalls).toBeGreaterThan(1) + expect(subscribedTerminalHandles()).toEqual(['terminal-old']) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old') + expect(transport.isConnected()).toBe(false) + + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + { + worktree: 'wt-1', + publicationEpoch: 'epoch-replacement', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: 'host-tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'host-tab-1::pane:1', + parentTabId: 'host-tab-1', + leafId: 'pane:1', + title: 'Terminal', + isActive: true, + status: 'ready', + terminal: 'terminal-new' + } + ] + }, + 'env-1' + ) + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-new' }) + ) + expect(onPtyRebind).toHaveBeenCalledWith( + 'remote:env-1@@terminal-new', + 'remote:env-1@@terminal-old' + ) + } finally { + vi.useRealTimers() + } + }) + + it('uses a strengthened sticky policy when an inventory retry starts', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-old' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-old', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'old handle') + + let resolveOldSend: (response: unknown) => void = () => {} + const oldSendResponse = new Promise((resolve) => { + resolveOldSend = resolve + }) + let hostListCalls = 0 + runtimeCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.send') { + return oldSendResponse + } + if (args.method === 'session.tabs.list') { + hostListCalls += 1 + return Promise.reject(new Error('runtime reconnect in progress')) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const sendInputAccepted = transport.sendInputAccepted + if (!sendInputAccepted) { + throw new Error('Expected acknowledged remote terminal input') + } + const pendingSend = sendInputAccepted('sent-before-stream-end') + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.send' }) + ) + ) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(15_000) + expect(transport.getRecoveryState?.().phase).toBe('backoff') + + resolveOldSend({ + ok: false, + error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' } + }) + await expect(pendingSend).resolves.toBe(false) + const callsBeforeRetry = hostListCalls + await vi.advanceTimersByTimeAsync(1_000) + + expect(hostListCalls).toBe(callsBeforeRetry) + expect(subscribedTerminalHandles()).toEqual(['terminal-old']) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old') + expect(transport.isConnected()).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('requires replacement after repeated same-handle stream-end flaps', async () => { + vi.useFakeTimers() + try { + resolvedPaneHandle = 'terminal-flapping' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-flapping', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'initial handle') + runtimeCall.mockImplementation((args: { method: string }) => + args.method === 'session.tabs.list' + ? Promise.resolve(readyHostSessionInventoryResponse('terminal-flapping')) + : Promise.resolve({ ok: true, result: {} }) + ) + + for (let cycle = 0; cycle < 2; cycle += 1) { + const endedStreamId = latestSubscribePayload().streamId + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: endedStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + expect(subscribedTerminalHandles()).toHaveLength(cycle + 2) + expect(latestSubscribePayload().terminal).toBe('terminal-flapping') + emitSnapshot(latestSubscribePayload().streamId, `same handle ${cycle + 1}`) + expect(transport.isConnected()).toBe(true) + } + + const condemnedStreamId = latestSubscribePayload().streamId + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: condemnedStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + + expect(subscribedTerminalHandles()).toEqual([ + 'terminal-flapping', + 'terminal-flapping', + 'terminal-flapping' + ]) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-flapping') + expect(transport.isConnected()).toBe(false) + expect(onPtyExit).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(44_001) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(subscribedTerminalHandles()).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('reuses prior ready evidence when the trailing inventory poll fails', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + resolvedPaneHandle = 'terminal-stable' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stable', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const oldStreamId = latestSubscribePayload().streamId + emitSnapshot(oldStreamId, 'before trailing failure') + let hostListCalls = 0 + runtimeCall.mockImplementation( + async (args: { method: string; timeoutMs?: number }): Promise => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + if ((args.timeoutMs ?? 15_000) <= 1_000) { + throw new Error('final inventory poll failed') + } + return readyHostSessionInventoryResponse('terminal-stable') + } + ) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId: oldStreamId, code: 0 } + }) + await vi.advanceTimersByTimeAsync(16_000) + + expect(hostListCalls).toBeGreaterThan(1) + expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable']) + expect(warn).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + emitSnapshot(latestSubscribePayload().streamId, 'ready evidence reused') + expect(transport.isConnected()).toBe(true) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + + it('does not carry a stale-handle requirement onto the replacement stream', async () => { + vi.useFakeTimers() + try { + let rejectedReplacement = false + subscriptionSendBinary.mockImplementation((bytes: Uint8Array) => { + const frame = decodeTerminalStreamFrame(bytes) + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + if (payload?.terminal === 'terminal-replacement' && !rejectedReplacement) { + rejectedReplacement = true + throw new Error('Remote runtime connection closed.') + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyRebind = vi.fn() + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1', + onPtyRebind + }) + + resolvedPaneHandle = 'terminal-stale' + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-stale', + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const staleStreamId = latestSubscribePayload().streamId + emitSnapshot(staleStreamId, 'before stale handle') + + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + return { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-replacement', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: 'host-tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'host-tab-1::pane:1', + parentTabId: 'host-tab-1', + leafId: 'pane:1', + title: 'Terminal', + isActive: true, + status: 'ready', + terminal: 'terminal-replacement' + } + ] + } + } + }) + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: staleStreamId, + message: 'terminal_handle_stale' + } + }) + await vi.waitFor(() => expect(rejectedReplacement).toBe(true)) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(runtimeSubscribe.mock.calls.length).toBeGreaterThanOrEqual(2)) + + const subscribedTerminals = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + expect(subscribedTerminals).toEqual([ + 'terminal-stale', + 'terminal-replacement', + 'terminal-replacement' + ]) + expect(onPtyRebind).toHaveBeenCalledOnce() + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-replacement' }) + ) + emitSnapshot(latestSubscribePayload().streamId, 'replacement reattached') + expect(transport.isConnected()).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it('coalesces concurrent stale errors for the handle that was replaced', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onPtyExit = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 615761567ef..1f9aef116a2 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -86,8 +86,29 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 const HOST_SESSION_ATTACH_POLL_MS = 150 const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000 const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 +const HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY = 2 +const HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT = 2 const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const +type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement' + +type HostSessionHandleWaitResult = { + handle: string | null | undefined + inventoryFailed: boolean +} + +function stricterReplacementPolicy( + left: HostHandleReplacementPolicy, + right: HostHandleReplacementPolicy +): HostHandleReplacementPolicy { + const rank: Record = { + reuse: 0, + 'prefer-replacement': 1, + 'require-replacement': 2 + } + return rank[left] >= rank[right] ? left : right +} + type RemoteAgentSessionLaunchResult = | RuntimeEnsureAgentSessionResult | RuntimeCreateAgentSessionResult @@ -162,10 +183,16 @@ export function createRemoteRuntimePtyTransport( let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribeEpoch: number | null = null let resubscribeRequestedHandle: string | null = null - let resubscribeRequestedRequiresReplacement = false - let recoveryRequiresReplacement = false + let resubscribeRequestedReplacementPolicy: HostHandleReplacementPolicy = 'reuse' + let recoveryReplacementPolicy: HostHandleReplacementPolicy = 'reuse' + let recoveryReplacementPolicyHandle: string | null = null let stopWaitingForPublishedHandle: (() => void) | null = null let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null + let resubscribeInventoryEpoch: number | null = null + let resubscribeInventoryWindows = 0 + let sameHandleEndReuseHandle: string | null = null + let sameHandleEndReuseCount = 0 + let sameHandleEndReuseAttachedAt: number | null = null let attachGeneration = 0 let subscriptionGeneration = 0 @@ -240,6 +267,62 @@ export function createRemoteRuntimePtyTransport( let lastAttachOptions: Parameters[0] | null = null let resolvePaneUnavailable = false let recoveringPaneHandle: string | null = null + const getRecoveryReplacementPolicy = (targetHandle: string): HostHandleReplacementPolicy => + recoveryReplacementPolicyHandle === targetHandle ? recoveryReplacementPolicy : 'reuse' + const resetRecoveryReplacementPolicy = (): void => { + recoveryReplacementPolicy = 'reuse' + recoveryReplacementPolicyHandle = null + } + const strengthenRecoveryReplacementPolicy = ( + targetHandle: string, + replacementPolicy: HostHandleReplacementPolicy + ): void => { + if (recoveryReplacementPolicyHandle !== targetHandle) { + recoveryReplacementPolicyHandle = targetHandle + recoveryReplacementPolicy = replacementPolicy + return + } + recoveryReplacementPolicy = stricterReplacementPolicy( + recoveryReplacementPolicy, + replacementPolicy + ) + } + const beginResubscribeInventoryWindow = (recoveryEpoch: number): number => { + if (resubscribeInventoryEpoch !== recoveryEpoch) { + resubscribeInventoryEpoch = recoveryEpoch + resubscribeInventoryWindows = 0 + } + resubscribeInventoryWindows += 1 + return resubscribeInventoryWindows + } + const resetSameHandleEndReuse = (): void => { + sameHandleEndReuseHandle = null + sameHandleEndReuseCount = 0 + sameHandleEndReuseAttachedAt = null + } + const recordSameHandleEndReuse = (targetHandle: string): void => { + if (sameHandleEndReuseHandle !== targetHandle) { + sameHandleEndReuseHandle = targetHandle + sameHandleEndReuseCount = 0 + } + sameHandleEndReuseCount += 1 + sameHandleEndReuseAttachedAt = Date.now() + } + const replacementPolicyAfterWebStreamEnd = ( + targetHandle: string + ): HostHandleReplacementPolicy => { + if ( + sameHandleEndReuseHandle !== targetHandle || + sameHandleEndReuseAttachedAt === null || + Date.now() - sameHandleEndReuseAttachedAt >= REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + ) { + resetSameHandleEndReuse() + return 'prefer-replacement' + } + return sameHandleEndReuseCount >= HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT + ? 'require-replacement' + : 'prefer-replacement' + } const adoptExecutionMetadata = (terminal: { executionHostId?: ExecutionHostId hostPlatform?: NodeJS.Platform @@ -528,29 +611,46 @@ export function createRemoteRuntimePtyTransport( async function waitForResubscribeHostSessionHandle( hostTabId: string, previousHandle: string, - requireReplacement: boolean - ): Promise { + replacementPolicy: HostHandleReplacementPolicy, + recoveryEpoch: number + ): Promise { if (!worktreeId) { - return null + return { handle: null, inventoryFailed: false } } const worktree = toRuntimeWorktreeSelector(worktreeId) const startedAt = Date.now() let pollMs = HOST_SESSION_ATTACH_POLL_MS let lastListError: unknown = null - const finishWithUnknownLiveness = (): undefined => { + let lastReadyHandle: string | null = null + let sawSuccessfulInventory = false + const finishBoundedWait = (): HostSessionHandleWaitResult => { + const effectivePolicy = stricterReplacementPolicy( + replacementPolicy, + getRecoveryReplacementPolicy(previousHandle) + ) + if (effectivePolicy === 'prefer-replacement' && lastReadyHandle) { + return { handle: lastReadyHandle, inventoryFailed: false } + } if (lastListError) { console.warn( - '[remote-runtime-pty] host session inventory unavailable during reconnect:', + sawSuccessfulInventory + ? '[remote-runtime-pty] final host session inventory poll failed during reconnect:' + : '[remote-runtime-pty] host session inventory unavailable during reconnect:', runtimeTerminalErrorMessage(lastListError) ) } // Why: a bounded wait without removal evidence is unknown liveness; keep the pane for a later snapshot to reattach. - return undefined + return { handle: undefined, inventoryFailed: lastListError !== null } } - while (!destroyed && connected && handle === previousHandle) { + while ( + !destroyed && + connected && + handle === previousHandle && + recovery.isCurrent(recoveryEpoch) + ) { const requestRemainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (requestRemainingMs <= 0) { - return finishWithUnknownLiveness() + return finishBoundedWait() } try { const listed = await listRemoteRuntimeSessionTabsDeduped({ @@ -566,12 +666,20 @@ export function createRemoteRuntimePtyTransport( ) }) lastListError = null + sawSuccessfulInventory = true const nextHandle = findReadyHostSessionHandle(listed, hostTabId) - if (nextHandle && (!requireReplacement || nextHandle !== previousHandle)) { - return nextHandle + if (nextHandle) { + lastReadyHandle = nextHandle + } + const effectivePolicy = stricterReplacementPolicy( + replacementPolicy, + getRecoveryReplacementPolicy(previousHandle) + ) + if (nextHandle && (effectivePolicy === 'reuse' || nextHandle !== previousHandle)) { + return { handle: nextHandle, inventoryFailed: false } } if (!hasHostSessionTerminalSurface(listed, hostTabId)) { - return null + return { handle: null, inventoryFailed: false } } } catch (error) { // Why: the inventory can race the reconnect that invalidated the handle; unknown liveness must not retire the pane. @@ -579,13 +687,13 @@ export function createRemoteRuntimePtyTransport( } const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { - return finishWithUnknownLiveness() + return finishBoundedWait() } // Why: a stale response can precede its replacement; bounded backoff avoids retrying the stale handle in a hot loop. await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remainingMs))) pollMs = Math.min(pollMs * 2, HOST_SESSION_REPLACEMENT_POLL_MAX_MS) } - return undefined + return { handle: undefined, inventoryFailed: false } } async function attachHostSessionMirror( @@ -1068,7 +1176,9 @@ export function createRemoteRuntimePtyTransport( return true } catch (error) { // Why: stale-handle errors must retire the mirror (recoverable via next snapshot), not dead-end in a red xterm banner (#7718). - handleRemoteTerminalError(error) + if (handle === targetHandle) { + handleRemoteTerminalError(error) + } return false } } @@ -1093,7 +1203,9 @@ export function createRemoteRuntimePtyTransport( client: { id: clientId, type: 'desktop' }, ...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {}) }).catch((error) => { - handleRemoteTerminalError(error) + if (handle === targetHandle) { + handleRemoteTerminalError(error) + } }) }) @@ -1159,7 +1271,8 @@ export function createRemoteRuntimePtyTransport( function retireRemoteTerminalId(): void { recovery.cancel() - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() connected = false connecting = false terminalEnded = true @@ -1183,6 +1296,8 @@ export function createRemoteRuntimePtyTransport( unregisterShutdownHandlers(replacedPtyId) handle = nextHandle remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId) + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() registerShutdownHandlers(remotePtyId) setAttachmentReady(false) // Why: host handle rotation preserves the pane generation; only the store identity changes, not spawn/exit semantics. @@ -1239,7 +1354,7 @@ export function createRemoteRuntimePtyTransport( if (tabId && leafId && worktreeId) { // Why: reconnect can re-mint a pane handle while its host coordinates live; keep xterm state mounted while re-resolving. closeMultiplexedStream() - scheduleResubscribeAfterTransportClose(true) + scheduleResubscribeAfterTransportClose('require-replacement') } else { retireRemoteTerminalId() } @@ -1290,16 +1405,19 @@ export function createRemoteRuntimePtyTransport( // Why: after a transport drop the host may have re-minted this handle; re-derive from the snapshot so we don't mirror/type into whatever PTY now sits behind the stale one (#7718). async function resubscribeAfterTransportClose( previousHandle: string, - requireReplacement: boolean, + replacementPolicy: HostHandleReplacementPolicy, recoveryEpoch: number ): Promise { if (tabId && isWebTerminalSurfaceTabId(tabId)) { const hostTabId = toHostSessionTabId(tabId) - const nextHandle = await waitForResubscribeHostSessionHandle( + const inventoryWindow = beginResubscribeInventoryWindow(recoveryEpoch) + const waitResult = await waitForResubscribeHostSessionHandle( hostTabId, previousHandle, - requireReplacement + replacementPolicy, + recoveryEpoch ) + const nextHandle = waitResult.handle if ( destroyed || !connected || @@ -1309,6 +1427,19 @@ export function createRemoteRuntimePtyTransport( return } if (nextHandle === undefined) { + const effectivePolicy = stricterReplacementPolicy( + replacementPolicy, + getRecoveryReplacementPolicy(previousHandle) + ) + if ( + effectivePolicy !== 'require-replacement' && + waitResult.inventoryFailed && + inventoryWindow < HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY + ) { + throw Object.assign(new Error('Remote runtime session inventory polling failed.'), { + code: 'remote_runtime_unavailable' + }) + } return } if (!nextHandle) { @@ -1316,15 +1447,36 @@ export function createRemoteRuntimePtyTransport( retireRemoteTerminalId() return } + const effectivePolicy = stricterReplacementPolicy( + replacementPolicy, + getRecoveryReplacementPolicy(previousHandle) + ) + // Why: a stale error can strengthen policy after inventory returns but before this continuation runs. + if (effectivePolicy === 'require-replacement' && nextHandle === previousHandle) { + return + } if (nextHandle !== previousHandle) { rebindRemoteTerminalHandle(nextHandle) } + clearPublishedHandleWait() + await subscribeToHandle( + recoveryEpoch, + nextHandle === previousHandle && effectivePolicy === 'prefer-replacement' + ) + return } else if (tabId && leafId && worktreeId) { const resolved = await resolvePersistedHostPane() if (destroyed || !connected || handle !== previousHandle) { return } - if (!resolved || (requireReplacement && resolved.handle === previousHandle)) { + const effectivePolicy = stricterReplacementPolicy( + replacementPolicy, + getRecoveryReplacementPolicy(previousHandle) + ) + if ( + !resolved || + (effectivePolicy === 'require-replacement' && resolved.handle === previousHandle) + ) { retireRemoteTerminalId() return } @@ -1337,7 +1489,7 @@ export function createRemoteRuntimePtyTransport( } function scheduleResubscribeAfterTransportClose( - requireReplacement = false, + replacementPolicy: HostHandleReplacementPolicy = 'reuse', requestedRecoveryEpoch?: number ): void { if (destroyed || !connected || !handle) { @@ -1354,8 +1506,8 @@ export function createRemoteRuntimePtyTransport( viewportBatcher.clear() clearPendingViewportClaim() } - recoveryRequiresReplacement ||= requireReplacement - if (requireReplacement && stopWaitingForPublishedHandle) { + strengthenRecoveryReplacementPolicy(handle, replacementPolicy) + if (replacementPolicy === 'require-replacement' && stopWaitingForPublishedHandle) { // Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs. return } @@ -1363,9 +1515,12 @@ export function createRemoteRuntimePtyTransport( // Why: concurrent stale errors belong to their own handle; don't carry an old handle's replacement requirement onto its successor. if (resubscribeRequestedHandle !== handle) { resubscribeRequestedHandle = handle - resubscribeRequestedRequiresReplacement = requireReplacement + resubscribeRequestedReplacementPolicy = replacementPolicy } else { - resubscribeRequestedRequiresReplacement ||= requireReplacement + resubscribeRequestedReplacementPolicy = stricterReplacementPolicy( + resubscribeRequestedReplacementPolicy, + replacementPolicy + ) } return } @@ -1377,16 +1532,19 @@ export function createRemoteRuntimePtyTransport( } resubscribeEpoch = recoveryEpoch resubscribeRequestedHandle = null - resubscribeRequestedRequiresReplacement = false + resubscribeRequestedReplacementPolicy = 'reuse' let retryScheduled = false - void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement, recoveryEpoch) + void resubscribeAfterTransportClose(resubscribeHandle, replacementPolicy, recoveryEpoch) .catch((error) => { if (!destroyed && connected && handle && recovery.isCurrent(recoveryEpoch)) { clearPendingViewportClaim() const clientError = toRemoteRuntimeClientErrorLike(error) if (isRecoverableRemoteRuntimeConnectionError(clientError)) { retryScheduled = recovery.schedule(recoveryEpoch, (nextEpoch) => { - scheduleResubscribeAfterTransportClose(requireReplacement, nextEpoch) + const currentReplacementPolicy = handle + ? getRecoveryReplacementPolicy(handle) + : 'reuse' + scheduleResubscribeAfterTransportClose(currentReplacementPolicy, nextEpoch) }) } else { recovery.cancel() @@ -1400,9 +1558,9 @@ export function createRemoteRuntimePtyTransport( } resubscribeEpoch = null const pendingHandle = resubscribeRequestedHandle - const pendingRequiresReplacement = resubscribeRequestedRequiresReplacement + const pendingReplacementPolicy = resubscribeRequestedReplacementPolicy resubscribeRequestedHandle = null - resubscribeRequestedRequiresReplacement = false + resubscribeRequestedReplacementPolicy = 'reuse' if ( !retryScheduled && recovery.isCurrent(recoveryEpoch) && @@ -1411,7 +1569,7 @@ export function createRemoteRuntimePtyTransport( pendingHandle === handle && !getCurrentMultiplexedStream(pendingHandle) ) { - scheduleResubscribeAfterTransportClose(pendingRequiresReplacement) + scheduleResubscribeAfterTransportClose(pendingReplacementPolicy) } }) } @@ -1428,11 +1586,14 @@ export function createRemoteRuntimePtyTransport( clearPendingViewportClaim() } recovery.schedule(recoveryEpoch, (nextEpoch) => { - scheduleResubscribeAfterTransportClose(false, nextEpoch) + scheduleResubscribeAfterTransportClose('reuse', nextEpoch) }) } - async function subscribeToHandle(expectedRecoveryEpoch?: number): Promise { + async function subscribeToHandle( + expectedRecoveryEpoch?: number, + sameHandleEndRecovery = false + ): Promise { if (!handle) { return } @@ -1495,10 +1656,13 @@ export function createRemoteRuntimePtyTransport( desiredOutputPaused, nextStream.setOutputPaused(desiredOutputPaused) ) + if (!subscriptionAttached && sameHandleEndRecovery) { + recordSameHandleEndReuse(subscribedHandle) + } subscriptionAttached = true setAttachmentReady(true) connecting = false - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() recovery.markHealthy() emitRecoveryState() storedCallbacks.onConnect?.() @@ -1514,8 +1678,10 @@ export function createRemoteRuntimePtyTransport( multiplexedStream = null multiplexedStreamHandle = null clearPendingViewportClaim() - // Why: a HUB restart ends the old handle's stream before its replacement snapshot arrives; the HUB snapshot, not the paired viewer, decides whether the pane exited. - scheduleResubscribeAfterTransportClose(true) + // Why: repeated same-handle end/reuse cycles must eventually stop on a replacement boundary. + scheduleResubscribeAfterTransportClose( + replacementPolicyAfterWebStreamEnd(subscribedHandle) + ) return } unregisterShutdownHandlers(subscribedPtyId) @@ -1564,6 +1730,7 @@ export function createRemoteRuntimePtyTransport( multiplexedStream = null multiplexedStreamHandle = null setAttachmentReady(false) + resetSameHandleEndReuse() if (recoverable) { if (retryWithBackoff) { scheduleCapacityPressureRetry() @@ -1596,7 +1763,7 @@ export function createRemoteRuntimePtyTransport( multiplexedStreamHandle = subscribedHandle setAttachmentReady(subscriptionAttached) if (subscriptionAttached) { - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() recovery.markHealthy() } // Why: a viewport change during the subscribe round-trip hit the no-op one-shot fallback; replay the latest viewport so the PTY isn't stuck at subscribe-time size. @@ -1629,7 +1796,8 @@ export function createRemoteRuntimePtyTransport( lastConnectOptions = options lastAttachOptions = null storedCallbacks = options.callbacks - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() terminalEnded = false connecting = true emitRecoveryState(true) @@ -1846,7 +2014,8 @@ export function createRemoteRuntimePtyTransport( const generation = ++attachGeneration cancelTerminalCreateRetryWait() recovery.cancel() - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() clearPublishedHandleWait() lastAttachOptions = options storedCallbacks = options.callbacks @@ -1944,7 +2113,8 @@ export function createRemoteRuntimePtyTransport( attachGeneration += 1 cancelTerminalCreateRetryWait() recovery.cancel() - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() clearPublishedHandleWait() inputBatcher.flush() inputBatcher.clear() @@ -1975,7 +2145,8 @@ export function createRemoteRuntimePtyTransport( attachGeneration += 1 cancelTerminalCreateRetryWait() recovery.cancel() - recoveryRequiresReplacement = false + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() clearPublishedHandleWait() inputBatcher.flush() inputBatcher.clear() @@ -2033,7 +2204,9 @@ export function createRemoteRuntimePtyTransport( client: { id: clientId, type: 'desktop' }, ...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {}) }).catch((error) => { - handleRemoteTerminalError(error) + if (handle === targetHandle) { + handleRemoteTerminalError(error) + } }) return true }, @@ -2134,7 +2307,7 @@ export function createRemoteRuntimePtyTransport( return false } const recoveryEpoch = recovery.begin() - scheduleResubscribeAfterTransportClose(recoveryRequiresReplacement, recoveryEpoch) + scheduleResubscribeAfterTransportClose(getRecoveryReplacementPolicy(handle), recoveryEpoch) return true }, From 36cc8495efecb7cb599a541d69bed6abe1b184b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:53:14 -0700 Subject: [PATCH 03/64] fix(terminal): stop the active-terminal repair loop from tripping React #185 (#11950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminals): make redundant tab activation idempotent (React #185) setActiveTab always reallocated activeTabIdByWorktree, even when the tab was already active for that worktree. Terminal's active-terminal repair effect depends on that map, so when the repair cannot converge activeTabId -- which happens when an earlier-scanned worktree reuses the tab id -- the effect re-triggers itself every commit until React throws #185. Crash cluster A: 12 reports, boundary terminal.workbench, 1.4.162/1.4.163. Co-authored-by: Orca * fix(terminals): converge activeTabId when a tab id is owned by two worktrees Prefer the active worktree when resolving a terminal tab's owner. First-match ownership left activeTabId permanently unconvergeable under a duplicated tab id, so the active-terminal repair effect re-triggered itself into React #185. Breadcrumb the duplicate-ownership state (once per tab id) so a crash bundle can prove or kill the production origin of the precondition. Co-authored-by: Orca * fix(crash-reporting): coalesce the duplicate-tab-owner breadcrumb Its renderer guard is once-per-tab-id, so the stale worktree map it exists to diagnose duplicates every tab id at once and could evict the whole 30-entry ring. Also drops two keyed re-reads of tabsByWorktree that would throw for a prototype-named worktree id, and pins the activeTabIdByWorktree guard with a test that fails without it. Co-authored-by: Orca * fix(crash-reporting): key the duplicate-tab-owner crumb on its convergence flag Name-only coalescing keeps only the newest payload, so a resolvedToActiveWorktree false sample — the one value saying the activation still could not converge — was erased by any later benign true in the same 30s window. Keys on the flag instead, mirroring the WebGL name:kind branch; two keys still bound the burst. Also: the previous coalescing commit had no test at all (removing the name from both sets broke zero of 2701 tests), the resolver's activeWorktreeId truthiness check was a hole rather than a guard for a '' active id, and the resolver test file failed oxfmt --check. Co-authored-by: Orca * fix(crash-reporting): keep the non-converging duplicate-tab verdict The two earlier commits contradicted each other. Splitting the coalesce key existed so a `false` verdict could not be erased by a later benign `true` — but the renderer guard was keyed on the tab id alone, so for any one id only the first verdict was ever emitted. A duplicated id that first resolves benignly, then stops converging when the user switches worktrees, dropped the `false` sample at the source. That sample is the whole reason the breadcrumb exists: it is the only value saying the activation could not converge activeTabId. Key the guard on id plus verdict. At most two crumbs per tab id, and the main process still folds each verdict into its own ring entry, so the flood bound is unchanged. * fix(crash): correct the duplicate-tab verdict rationale, pin and cap the guard Three comments said `false` is the verdict that matters because it is the only one showing the activation could not converge. That is backwards. The repair effect activates a tab drawn from tabsByWorktree[active], so the React #185 path can only ever emit `true`; `false` is what a deliberate jump-to-agent into a background worktree emits from a fully converged state. A reader of the next bundle would have discarded the exact sample the breadcrumb exists to capture. The mechanism was right, only its stated reason was wrong: the real justification for keying on the verdict is symmetric, since coalescing keeps only the newest payload and either verdict would erase the other. The suite also did not pin the "at most 2 per tab id" bound - a guard keyed on `${tabId}:${activeWorktreeId}` passed all 11 tests while emitting once per worktree, the storm the guard exists to prevent. Adds a count-pinning test that kills it. Caps the never-pruned guard set at 256 distinct verdict keys (~85KB), mirroring MAX_COALESCE_KEYS. Measured 330 B/entry; a realistic thousand duplicated tab ids is ~0.6MB, negligible but unbounded in principle. * perf(crash): scan worktree tabs by key, and soften the verdict rationale Round 6 corrected my own round-5 comment. I had written that `true` is the repair-loop signature and `false` covers a deliberate background activation. The repair effect can emit `false` too: its closure holds the worktree from its render while the guard runs against live state, so a worktree switch landing in between reattributes the tab. The verdict hints at the caller; it does not prove it, and neither value should be discarded. Comment-only. Also take the free scan win the perf review measured: Object.entries allocates a pair array per worktree on a path that runs per tab activation. Own keys are safe to index by, so Object.keys plus an indexed read is behaviour-identical (16.1us -> 5.0us at 170 worktrees x 10 tabs). * fix(terminal): keep a duplicated tab id from re-sorting the active worktree setActiveTab now prefers the active worktree when a tab id is held by more than one, but terminals.ts has a second, older owner resolver: getTerminalTabOwnerWorktreeId, a memoized map built last-writer-wins. Two of its callers — setRuntimePaneTitle and clearRuntimePaneTitle — use the result for the same "is this pane in the active worktree" gate, so under a duplicate the two resolvers disagree: the cache names whichever worktree it saw last, which can be a background one for a pane the user is looking at. The gate then fails open and every classified OSC title frame bumps sortEpoch, reinstating the click-driven sidebar re-sort #209 removed — 20 title frames measured 20 bumps, each one a store write that re-renders every sortEpoch subscriber. isTabInActiveWorktree answers from the active worktree's own tab list instead of a tie-break. It stays behind the cheap id equality so the common non-duplicated path is unchanged, and it is a hasOwn lookup plus one scan of that worktree's tabs rather than resolveActiveTabOwnerWorktreeId, whose full scan would run per title frame and whose breadcrumb would fold a second caller into one verdict. Leaves updateTabTitle and clearTabLaunchAgent on the cache: they pick which copy of a duplicated tab to mutate, where no answer is defensible until the duplication itself is fixed. * test(terminal): pin the SSH-hydration origin of the duplicate tab id Drives the duplicate from real hydration rather than constructing it: a direct-SSH snapshot is keyed by worktree path, so renaming the worktree on the host (or re-adding the repo, which mints a fresh id) re-resolves it to a new worktree id while replaceHydratedRecordKeys retains the old key verbatim. Nothing de-dupes across keys. Fails on unfixed origin/main with converged=false after 200 passes; the two precondition assertions pass on both sides, so the red is the non-convergence itself and not a setup divergence. The reconnectPersistedTerminals stub is load-bearing and marked as such: with no registered PTY the orphan sweep cleans the duplicate up before the repair effect sees it. * docs(test): record the end-to-end #185 reproduction method on the regression test Co-authored-by: Orca * test(terminal): pin the null-active-worktree guard in isTabInActiveWorktree Dropping the `activeWorktreeId === null` early return was killed by nothing: `Object.hasOwn(map, null)` coerces to the string key 'null', so a worktree literally named 'null' would answer for "no active worktree". An untested guard reads as dead code and gets deleted. * rm triage context * rm triage context * rm context files * refactor(terminal): extract repair logic into reusable hook and guard ag Extract the active terminal repair effect from Terminal.tsx into `useActiveTerminalRepair` hook to enable reuse in tests and clarify responsibilities. Replace falsy coercion guards (`obj[id] ?? []`) with explicit `Object.hasOwn()` checks to handle edge cases: empty-string worktree ids (valid but falsy), prototype-named ids like 'toString', and duplicated tab ids across worktrees. Remove the now-unused `isTabInActiveWorktree` helper. Simplify the isActive logic in terminals.ts to rely solely on owner-equality since the repair now uses proper membership checks. --------- Co-authored-by: Orca Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- ...ash-reporting-renderer-breadcrumbs.test.ts | 43 +++ src/main/ipc/crash-reporting.ts | 7 + src/renderer/src/components/Terminal.tsx | 30 +- ...ive-terminal-repair-loop.react185.test.tsx | 327 ++++++++++++++++++ .../terminal/use-active-terminal-repair.ts | 61 ++++ ...pace-snapshot-duplicate-tab-repair.test.ts | 224 ++++++++++++ .../slices/active-tab-owner-worktree.test.ts | 125 +++++++ .../store/slices/active-tab-owner-worktree.ts | 82 +++++ src/renderer/src/store/slices/tabs.ts | 14 +- src/renderer/src/store/slices/terminals.ts | 51 ++- 10 files changed, 924 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx create mode 100644 src/renderer/src/components/terminal/use-active-terminal-repair.ts create mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts create mode 100644 src/renderer/src/store/slices/active-tab-owner-worktree.test.ts create mode 100644 src/renderer/src/store/slices/active-tab-owner-worktree.ts diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 83aab9d2fab..1fb1bb12d7b 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -298,6 +298,49 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) + // Why: the renderer guard is once per tab-id/verdict, so one stale worktree + // map can emit enough crumbs to evict the pre-crash trail. + it('coalesces duplicate-tab-owner notices across tabs', () => { + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: true } + }) + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 3, resolvedToActiveWorktree: true } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) { + expect(call[0]).toMatchObject({ + coalesceKey: 'terminal_tab_id_owned_by_multiple_worktrees:true' + }) + } + }) + + // Why flag-scoped: coalescing keeps only the newest payload, and the verdict + // flips under a persisting duplicate, so one would erase the other. + it('keeps a non-converging duplicate-tab-owner notice out of the converging one', () => { + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: false } + }) + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: true } + }) + + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).toEqual([ + 'terminal_tab_id_owned_by_multiple_worktrees:false', + 'terminal_tab_id_owned_by_multiple_worktrees:true' + ]) + }) + it('records non-error renderer breadcrumbs without coalescing', () => { emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 4cdd4bdbbb4..f028c7bc3b3 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -326,11 +326,13 @@ function buildUncapturedCrashReportText( // storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds, // erasing the pre-crash trail. Coalesce repeats into one entry that carries a // suppressed count instead. +const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', 'terminal_park_verdict_churn', 'terminal_safe_fit_retry_exhausted', + DUPLICATE_TAB_OWNER_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -362,6 +364,11 @@ function rendererBreadcrumbCoalesceKey( if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { return `${name}:${String(data?.kind ?? '')}` } + // Why: a stale map can emit once per tab-id/verdict; key by verdict so + // last-write coalescing cannot erase the other signal while remaining bounded. + if (name === DUPLICATE_TAB_OWNER_BREADCRUMB) { + return `${name}:${String(data?.resolvedToActiveWorktree ?? '')}` + } const primaryMessage = name === 'renderer_error' ? data?.message : data?.reasonMessage const fallbackMessage = name === 'renderer_error' ? data?.errorMessage : undefined const message = diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 561dd4e8c40..4189b0e44a1 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -64,7 +64,7 @@ import { import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' -import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair' +import { useActiveTerminalRepair } from './terminal/use-active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' import { applyBackgroundMountTabRestriction, @@ -390,7 +390,10 @@ function Terminal(): React.JSX.Element | null { }, [foregroundTerminalTabIds]) const tabs = useMemo( - () => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []), + () => + renderedActiveWorktreeId !== null && Object.hasOwn(tabsByWorktree, renderedActiveWorktreeId) + ? tabsByWorktree[renderedActiveWorktreeId] + : [], [renderedActiveWorktreeId, tabsByWorktree] ) useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded) @@ -749,32 +752,15 @@ function Terminal(): React.JSX.Element | null { ) }, [queueEditorCloseRequests]) - useEffect(() => { - const rememberedTabId = renderedActiveWorktreeId - ? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null) - : null - // Why: prefer the remembered active tab so a repair on a transient switch render doesn't reset selection to Terminal 1. - const repairedTabId = resolveRepairedActiveTerminalTabId({ - activeTabType, - activeTabId, - rememberedTabId, - tabs - }) - if (!repairedTabId) { - return - } - // Why: run in an effect (Zustand mutation during render trips React's cross-component update warning); keep terminal-only so inactive CLI-created tabs can't steal editor/browser focus. - setActiveTab(repairedTabId) - // Why: `tabs` is the dependency so the repair reacts to tab-order/content changes, not just scalar IDs. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ + // Why: repair after render so Zustand mutation cannot trip React's cross-component update warning. + useActiveTerminalRepair({ activeTabId, activeTabType, setActiveTab, tabs, activeTabIdByWorktree, renderedActiveWorktreeId - ]) + }) // Why: only mount TerminalPanes for visited worktrees, else restoring many saved tabs mass-spawns PTYs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) diff --git a/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx b/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx new file mode 100644 index 00000000000..ab3ff3ac45e --- /dev/null +++ b/src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx @@ -0,0 +1,327 @@ +/** @vitest-environment happy-dom */ +import { act, useMemo } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' +import { useActiveTerminalRepair } from './use-active-terminal-repair' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Why: React throws #185 at 51 nested commits; 400 proves divergence, not slowness. +const MAX_PASSES = 400 + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { id, worktreeId, title: id, createdAt: 0, sortOrder: 0 } as unknown as TerminalTab +} + +function unifiedTerminalTab( + id: string, + entityId: string, + worktreeId: string, + groupId: string +): Tab { + return { + id, + entityId, + worktreeId, + groupId, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function tabGroup( + id: string, + worktreeId: string, + activeTabId: string, + tabOrder: string[] +): TabGroup { + return { id, worktreeId, activeTabId, tabOrder, recentTabIds: [activeTabId] } +} + +function RepairEffectHarness(): null { + const activeTabId = useAppStore((s) => s.activeTabId) + const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) + const activeTabType = useAppStore((s) => s.activeTabType) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const renderedActiveWorktreeId = useAppStore((s) => s.activeWorktreeId) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const tabs = useMemo( + () => + renderedActiveWorktreeId !== null && Object.hasOwn(tabsByWorktree, renderedActiveWorktreeId) + ? tabsByWorktree[renderedActiveWorktreeId] + : [], + [renderedActiveWorktreeId, tabsByWorktree] + ) + + useActiveTerminalRepair({ + activeTabId, + activeTabType, + setActiveTab, + tabs, + activeTabIdByWorktree, + renderedActiveWorktreeId + }) + return null +} + +let cleanup: (() => void) | null = null + +afterEach(() => { + cleanup?.() + cleanup = null +}) + +function measureRepairPasses(): number { + let passes = 0 + const setActiveTab = useAppStore.getState().setActiveTab + useAppStore.setState({ + setActiveTab: (tabId) => { + passes += 1 + if (passes <= MAX_PASSES) { + setActiveTab(tabId) + } + } + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + cleanup = () => { + act(() => root.unmount()) + useAppStore.setState({ setActiveTab }) + container.remove() + } + act(() => { + root.render() + }) + return passes +} + +describe('active-terminal repair effect cannot drive a React #185 update loop', () => { + it('settles when the repaired tab is owned by the active worktree', () => { + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabType: 'terminal', + activeTabId: 'stale-tab', + activeTabIdByWorktree: {}, + tabsByWorktree: { 'wt-active': [terminalTab('t1', 'wt-active')] }, + unifiedTabsByWorktree: {} + }) + expect(measureRepairPasses()).toBeLessThan(10) + expect(useAppStore.getState().activeTabId).toBe('t1') + }) + + it('settles when another worktree reuses the tab id and is scanned first', () => { + // Why regression: first-match ownership skipped activeTabId while reallocating + // activeTabIdByWorktree, retriggering the repair effect indefinitely. + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabType: 'terminal', + activeTabId: 'stale-tab', + activeTabIdByWorktree: {}, + tabsByWorktree: { + 'wt-other': [terminalTab('t1', 'wt-other')], + 'wt-active': [terminalTab('t1', 'wt-active')] + }, + unifiedTabsByWorktree: {} + }) + expect(measureRepairPasses()).toBeLessThan(10) + // Why: settling by refusing to write would leave the repair permanently + // unsatisfied — quiet, but with activeTabId stuck on a tab that is gone. + expect(useAppStore.getState().activeTabId).toBe('t1') + expect(useAppStore.getState().activeTabIdByWorktree['wt-active']).toBe('t1') + }) + + it('activates the active worktree unified tab when another worktree reuses the entity id', () => { + const otherTab = unifiedTerminalTab('t1', 't1', 'wt-other', 'g-other') + const otherPreviousTab = unifiedTerminalTab( + 'other-previous', + 'other-previous', + 'wt-other', + 'g-other' + ) + const activeTab = unifiedTerminalTab('t1', 't1', 'wt-active', 'g-active') + const previousActiveTab = unifiedTerminalTab('u-previous', 't2', 'wt-active', 'g-active') + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabId: 't2', + activeTabIdByWorktree: { 'wt-active': 't2' }, + tabsByWorktree: { + 'wt-other': [terminalTab('t1', 'wt-other')], + 'wt-active': [terminalTab('t1', 'wt-active'), terminalTab('t2', 'wt-active')] + }, + unifiedTabsByWorktree: { + 'wt-other': [otherTab, otherPreviousTab], + 'wt-active': [activeTab, previousActiveTab] + }, + groupsByWorktree: { + 'wt-other': [ + tabGroup('g-other', 'wt-other', otherPreviousTab.id, [otherTab.id, otherPreviousTab.id]) + ], + 'wt-active': [ + tabGroup('g-active', 'wt-active', previousActiveTab.id, [ + activeTab.id, + previousActiveTab.id + ]) + ] + }, + activeGroupIdByWorktree: { 'wt-other': 'g-other', 'wt-active': 'g-active' } + }) + + act(() => { + useAppStore.getState().setActiveTab('t1') + }) + + expect(useAppStore.getState().groupsByWorktree['wt-active'][0].activeTabId).toBe(activeTab.id) + expect(useAppStore.getState().groupsByWorktree['wt-other'][0].activeTabId).toBe( + otherPreviousTab.id + ) + }) + + it('keeps unified-only terminal activation as a fallback', () => { + const targetTab = unifiedTerminalTab('u-target', 't1', 'wt-active', 'g-active') + const previousTab = unifiedTerminalTab('u-previous', 't2', 'wt-active', 'g-active') + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabId: null, + activeTabIdByWorktree: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-active': [targetTab, previousTab] }, + groupsByWorktree: { + 'wt-active': [ + tabGroup('g-active', 'wt-active', previousTab.id, [targetTab.id, previousTab.id]) + ] + }, + activeGroupIdByWorktree: { 'wt-active': 'g-active' } + }) + + act(() => { + useAppStore.getState().setActiveTab('t1') + }) + + expect(useAppStore.getState().groupsByWorktree['wt-active'][0].activeTabId).toBe(targetTab.id) + expect(useAppStore.getState().activeTabId).toBeNull() + }) + + it('does not reallocate activeTabIdByWorktree when the tab is already active', () => { + // Why: that map is a dependency of both the repair effect and the parked + // watcher sync, so a redundant activation must not re-run either. + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabType: 'terminal', + activeTabId: 't1', + activeTabIdByWorktree: {}, + tabsByWorktree: { 'wt-active': [terminalTab('t1', 'wt-active')] }, + unifiedTabsByWorktree: {} + }) + act(() => { + useAppStore.getState().setActiveTab('t1') + }) + const settled = useAppStore.getState().activeTabIdByWorktree + act(() => { + useAppStore.getState().setActiveTab('t1') + }) + expect(useAppStore.getState().activeTabIdByWorktree).toBe(settled) + }) + + it('keeps bell attribution off a background worktree tab', () => { + useAppStore.setState({ + activeWorktreeId: 'wt-active', + activeTabType: 'terminal', + activeTabId: 'visible-tab', + activeTabIdByWorktree: {}, + tabsByWorktree: { + 'wt-active': [terminalTab('visible-tab', 'wt-active')], + 'wt-background': [terminalTab('bg-tab', 'wt-background')] + }, + unifiedTabsByWorktree: {} + }) + act(() => { + useAppStore.getState().setActiveTab('bg-tab') + }) + expect(useAppStore.getState().activeTabId).toBe('visible-tab') + expect(useAppStore.getState().activeTabIdByWorktree['wt-background']).toBe('bg-tab') + }) + + it('records activation for a falsy-but-valid worktree id', () => { + useAppStore.setState({ + activeWorktreeId: '', + activeTabId: null, + activeTabIdByWorktree: {}, + tabsByWorktree: { '': [terminalTab('t1', '')] }, + unifiedTabsByWorktree: {} + }) + act(() => { + useAppStore.getState().setActiveTab('t1') + }) + expect(useAppStore.getState().activeTabId).toBe('t1') + expect(useAppStore.getState().activeTabIdByWorktree['']).toBe('t1') + }) + + it('repairs a falsy-but-valid active worktree id through the production hook', () => { + useAppStore.setState({ + activeWorktreeId: '', + activeTabType: 'terminal', + activeTabId: 'stale-tab', + activeTabIdByWorktree: { '': 't1' }, + tabsByWorktree: { '': [terminalTab('t1', '')] }, + unifiedTabsByWorktree: {} + }) + expect(measureRepairPasses()).toBeLessThan(10) + expect(useAppStore.getState().activeTabId).toBe('t1') + }) + + it('does not read inherited unified tabs for a prototype-named owner', () => { + useAppStore.setState({ + activeWorktreeId: 'toString', + activeTabId: null, + activeTabIdByWorktree: {}, + tabsByWorktree: { toString: [terminalTab('t1', 'toString')] }, + unifiedTabsByWorktree: {} + }) + expect(() => useAppStore.getState().setActiveTab('t1')).not.toThrow() + expect(useAppStore.getState().activeTabId).toBe('t1') + }) + + it('activates own unified tabs for a prototype-named owner', () => { + const target = unifiedTerminalTab('t1', 't1', 'toString', 'g-target') + const previous = unifiedTerminalTab('t2', 't2', 'toString', 'g-target') + useAppStore.setState({ + activeWorktreeId: 'toString', + activeTabId: 't2', + activeTabIdByWorktree: { toString: 't2' }, + tabsByWorktree: { + toString: [terminalTab('t1', 'toString'), terminalTab('t2', 'toString')] + }, + unifiedTabsByWorktree: { toString: [target, previous] }, + groupsByWorktree: { + toString: [tabGroup('g-target', 'toString', previous.id, [target.id, previous.id])] + }, + activeGroupIdByWorktree: { toString: 'g-target' } + }) + act(() => useAppStore.getState().setActiveTab('t1')) + expect(useAppStore.getState().groupsByWorktree.toString[0].activeTabId).toBe(target.id) + }) + + it('does not activate a tab with no owner when no worktree is active', () => { + useAppStore.setState({ + activeWorktreeId: null, + activeTabId: null, + activeTabIdByWorktree: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: { 'missing-tab': true } + }) + act(() => { + useAppStore.getState().setActiveTab('missing-tab') + }) + expect(useAppStore.getState().activeTabId).toBeNull() + expect(useAppStore.getState().activeTabIdByWorktree).toEqual({}) + expect(useAppStore.getState().unreadTerminalTabs['missing-tab']).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal/use-active-terminal-repair.ts b/src/renderer/src/components/terminal/use-active-terminal-repair.ts new file mode 100644 index 00000000000..d495c80c330 --- /dev/null +++ b/src/renderer/src/components/terminal/use-active-terminal-repair.ts @@ -0,0 +1,61 @@ +import { useEffect } from 'react' +import type { TerminalTab, WorkspaceVisibleTabType } from '../../../../shared/types' +import { resolveRepairedActiveTerminalTabId } from './active-terminal-repair' + +type ActiveTerminalRepairInput = { + activeTabType: WorkspaceVisibleTabType + activeTabId: string | null + activeTabIdByWorktree: Record + renderedActiveWorktreeId: string | null + setActiveTab: (tabId: string) => void + tabs: TerminalTab[] +} + +export function repairActiveTerminalTab({ + activeTabType, + activeTabId, + activeTabIdByWorktree, + renderedActiveWorktreeId, + setActiveTab, + tabs +}: ActiveTerminalRepairInput): boolean { + const rememberedTabId = + renderedActiveWorktreeId !== null && + Object.hasOwn(activeTabIdByWorktree, renderedActiveWorktreeId) + ? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null) + : null + const repairedTabId = resolveRepairedActiveTerminalTabId({ + activeTabType, + activeTabId, + rememberedTabId, + tabs + }) + if (!repairedTabId) { + return false + } + setActiveTab(repairedTabId) + return true +} + +export function useActiveTerminalRepair(input: ActiveTerminalRepairInput): void { + const { + activeTabId, + activeTabIdByWorktree, + activeTabType, + renderedActiveWorktreeId, + setActiveTab, + tabs + } = input + useEffect(() => { + repairActiveTerminalTab(input) + // Why: `tabs` is the dependency so repair reacts to order/content changes, not just scalar ids. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeTabId, + activeTabType, + setActiveTab, + tabs, + activeTabIdByWorktree, + renderedActiveWorktreeId + ]) +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts new file mode 100644 index 00000000000..2664475db9e --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts @@ -0,0 +1,224 @@ +/** + * A direct-SSH snapshot can retain the same tab under old and new worktree IDs + * after a path or repo-ID change. This exercises that hydration path and proves + * active-tab repair converges; it deliberately does not remove the duplicate or + * reproduce React's scheduler-level #185 throw. See PR #11950 for that evidence. + */ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' +import { repairActiveTerminalTab } from '../components/terminal/use-active-terminal-repair' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const TARGET_ID = 'ssh-target-1' +const OLD_PATH = '/srv/proj/wt' +const NEW_PATH = '/srv/proj/wt-renamed' +const OLD_ID = `repoA::${OLD_PATH}` +const NEW_ID = `repoA::${NEW_PATH}` +// Why a cap and not a while(true): on unfixed code this cycle never terminates. +const MAX_REPAIR_PASSES = 200 + +const authority: DirectSshAuthority = { + targetId: TARGET_ID, + providerEpoch: 'provider-epoch-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number): DirectSshSnapshotApplyToken { + return { + authority, + catalogRevision: 0, + repoFingerprint: 'fp', + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot( + revision: number, + worktreePath: string, + tabIds: readonly string[], + activeTabId: string | null +): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + session: { + activeWorktreePath: worktreePath, + activeTabId, + tabsByWorktreePath: { + [worktreePath]: tabIds.map((tabId, index) => ({ + id: tabId, + worktreePath, + ptyId: `pty-${tabId}`, + title: `Terminal ${index + 1}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: {}, + activeWorktreePathsOnShutdown: [], + activeTabIdByWorktreePath: { [worktreePath]: activeTabId }, + remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), + lastVisitedAtByWorktreePath: { [worktreePath]: revision }, + defaultTerminalTabsAppliedByWorktreePath: { [worktreePath]: true } + } + } satisfies RemoteWorkspaceSnapshot +} + +type TestStore = ReturnType + +async function applySnapshot(store: TestStore, snap: RemoteWorkspaceSnapshot): Promise { + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snap, + token: token(snap.revision), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0 + }) +} + +function worktreeIdsOwningTab(store: TestStore, tabId: string): string[] { + return Object.entries(store.getState().tabsByWorktree) + .filter(([, tabs]) => tabs.some((tab) => tab.id === tabId)) + .map(([worktreeId]) => worktreeId) +} + +function seedCatalog(store: TestStore, worktreePath: string): void { + store.setState({ + worktreesByRepo: { + repoA: [ + makeWorktree({ + id: `repoA::${worktreePath}`, + repoId: 'repoA', + path: worktreePath, + hostId: `ssh:${TARGET_ID}` + } as never) + ] + } + }) +} + +/** + * One turn of the loop in Terminal.tsx's active-terminal repair effect: + * recompute the repaired id from live state, then activate it. Reports how many + * turns it took to stop and how often `activeTabIdByWorktree` — a declared dep + * of that effect, so a fresh identity re-runs it — was reallocated. + */ +function runRepairCycle(store: TestStore): { + converged: boolean + passes: number + depIdentityChurn: number +} { + let passes = 0 + let depIdentityChurn = 0 + for (; passes < MAX_REPAIR_PASSES; passes += 1) { + const live = store.getState() + const depsBefore = live.activeTabIdByWorktree + const repaired = repairActiveTerminalTab({ + activeTabType: 'terminal', + activeTabId: live.activeTabId, + activeTabIdByWorktree: live.activeTabIdByWorktree, + renderedActiveWorktreeId: live.activeWorktreeId, + setActiveTab: live.setActiveTab, + tabs: live.activeWorktreeId ? (live.tabsByWorktree[live.activeWorktreeId] ?? []) : [] + }) + if (!repaired) { + return { converged: true, passes, depIdentityChurn } + } + if (store.getState().activeTabIdByWorktree !== depsBefore) { + depIdentityChurn += 1 + } + } + return { converged: false, passes, depIdentityChurn } +} + +describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => { + it('converges the active-terminal repair instead of re-running it forever', async () => { + const store = createTestStore() + + store.setState({ + repos: [ + { + id: 'repoA', + path: '/srv/proj', + displayName: 'Proj', + badgeColor: '#000', + addedAt: 0, + connectionId: TARGET_ID + } as never + ], + // Load-bearing, do not drop: the IPC attach is the only thing stubbed, and + // it leaves behind exactly what a real reconnect leaves behind — one + // registered live PTY per tab. Without that the orphan sweep on the next + // worktree visit treats the duplicated tab as dead, cleans it up, and the + // bug evaporates before the repair effect ever sees it. + reconnectPersistedTerminals: (async () => { + const live = store.getState() + const registered: Record = { ...live.ptyIdsByTabId } + for (const tabs of Object.values(live.tabsByWorktree)) { + for (const tab of tabs) { + registered[tab.id] = [`pty-${tab.id}`] + } + } + store.setState({ ptyIdsByTabId: registered }) + }) as never, + markRemoteWorkspaceHydrated: (() => {}) as never, + setRemoteWorkspaceSyncStatus: (() => {}) as never + }) + + seedCatalog(store, OLD_PATH) + await applySnapshot(store, snapshot(1, OLD_PATH, ['tab-1', 'tab-2'], 'tab-1')) + store.getState().setActiveWorktree(OLD_ID) + + // The worktree is renamed on the host; the catalog re-detects it at the new + // path, so the worktree id changes while the tab ids do not. + seedCatalog(store, NEW_PATH) + await applySnapshot(store, snapshot(2, NEW_PATH, ['tab-1', 'tab-2'], 'tab-1')) + store.getState().setActiveWorktree(NEW_ID) + + // The remote deselects; importRemoteWorkspaceSession nulls an activeTabId it + // cannot find among the imported tabs, which is what arms the repair effect. + await applySnapshot(store, snapshot(3, NEW_PATH, ['tab-1', 'tab-2'], null)) + + // Why assert the precondition and not its removal: the fix stops the owner + // resolver being fooled by the duplicate, it does not remove the duplicate. + // Pinned so the test cannot pass vacuously if hydration stops producing one. + expect(worktreeIdsOwningTab(store, 'tab-1')).toEqual([OLD_ID, NEW_ID]) + expect(store.getState().activeTabId).toBeNull() + + const repair = runRepairCycle(store) + + expect(repair.converged).toBe(true) + expect(repair.passes).toBeLessThanOrEqual(store.getState().tabsByWorktree[NEW_ID].length) + expect(store.getState().activeTabId).toBe('tab-1') + const activeGroupId = store.getState().activeGroupIdByWorktree[NEW_ID] + expect( + store.getState().groupsByWorktree[NEW_ID].find((group) => group.id === activeGroupId) + ?.activeTabId + ).toBe('tab-1') + + // The dep identity settles: re-running the effect body after convergence + // reallocates nothing, so the effect does not schedule itself again. + const settled = runRepairCycle(store) + expect(settled.depIdentityChurn).toBe(0) + expect(settled.passes).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/active-tab-owner-worktree.test.ts b/src/renderer/src/store/slices/active-tab-owner-worktree.test.ts new file mode 100644 index 00000000000..7e8c092a6c7 --- /dev/null +++ b/src/renderer/src/store/slices/active-tab-owner-worktree.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' + +const recordRendererCrashBreadcrumb = vi.fn() +vi.mock('../../lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (...args: unknown[]) => recordRendererCrashBreadcrumb(...args) +})) + +const { resolveActiveTabOwnerWorktreeId, _resetDuplicateTabOwnerBreadcrumbsForTests } = + await import('./active-tab-owner-worktree') + +function tab(id: string, worktreeId: string): TerminalTab { + return { id, worktreeId, title: id, createdAt: 0, sortOrder: 0 } as unknown as TerminalTab +} + +beforeEach(() => { + recordRendererCrashBreadcrumb.mockClear() + _resetDuplicateTabOwnerBreadcrumbsForTests() +}) + +describe('resolveActiveTabOwnerWorktreeId', () => { + it('returns the sole owner and stays quiet', () => { + const owner = resolveActiveTabOwnerWorktreeId( + { 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t2', 'wt-b')] }, + 'wt-a', + 't1' + ) + expect(owner).toBe('wt-a') + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('returns null when no worktree owns the tab', () => { + expect(resolveActiveTabOwnerWorktreeId({ 'wt-a': [tab('t1', 'wt-a')] }, 'wt-a', 'gone')).toBe( + null + ) + }) + + it('prefers the active worktree over an earlier-scanned duplicate', () => { + const owner = resolveActiveTabOwnerWorktreeId( + { 'wt-other': [tab('t1', 'wt-other')], 'wt-active': [tab('t1', 'wt-active')] }, + 'wt-active', + 't1' + ) + expect(owner).toBe('wt-active') + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_tab_id_owned_by_multiple_worktrees', + { ownerCount: 2, resolvedToActiveWorktree: true } + ) + }) + + it('falls back to first match when the active worktree is not an owner', () => { + const owner = resolveActiveTabOwnerWorktreeId( + { 'wt-x': [tab('t1', 'wt-x')], 'wt-y': [tab('t1', 'wt-y')] }, + 'wt-active', + 't1' + ) + expect(owner).toBe('wt-x') + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_tab_id_owned_by_multiple_worktrees', + { ownerCount: 2, resolvedToActiveWorktree: false } + ) + }) + + // Why: a truthiness guard on the active id would drop this back to first-match. + it('prefers a falsy-but-valid active worktree id', () => { + const owner = resolveActiveTabOwnerWorktreeId( + { 'wt-other': [tab('t1', 'wt-other')], '': [tab('t1', '')] }, + '', + 't1' + ) + expect(owner).toBe('') + }) + + it('breadcrumbs a given tab id once per verdict so it cannot flood the ring', () => { + const maps = { 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t1', 'wt-b')] } + for (let i = 0; i < 5; i += 1) { + resolveActiveTabOwnerWorktreeId(maps, 'wt-a', 't1') + } + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(1) + }) + + // Why: the active worktree changes under a persisting duplicate, and coalescing + // keeps only the newest payload — keyed on the tab id alone, whichever verdict + // a tab reported first would suppress the other for the rest of the session. + it('still reports a non-converging verdict after that tab id reported a converging one', () => { + const maps = { 'wt-other': [tab('t1', 'wt-other')], 'wt-active': [tab('t1', 'wt-active')] } + resolveActiveTabOwnerWorktreeId(maps, 'wt-active', 't1') + resolveActiveTabOwnerWorktreeId(maps, 'wt-third', 't1') + resolveActiveTabOwnerWorktreeId(maps, 'wt-third', 't1') + + expect(recordRendererCrashBreadcrumb.mock.calls).toEqual([ + [ + 'terminal_tab_id_owned_by_multiple_worktrees', + { ownerCount: 2, resolvedToActiveWorktree: true } + ], + [ + 'terminal_tab_id_owned_by_multiple_worktrees', + { ownerCount: 2, resolvedToActiveWorktree: false } + ] + ]) + }) + + // Why the count and not just "reports twice": a guard keyed on the active + // worktree id passes the two tests above yet emits once per worktree, which is + // the flood this guard exists to prevent. + it('never exceeds two crumbs for one tab id however the active worktree moves', () => { + const maps = { 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t1', 'wt-b')] } + const activeWorktreeIds = ['wt-a', 'wt-b', 'wt-c', '', 'wt-d', 'wt-a'] + for (let i = 0; i < 600; i += 1) { + resolveActiveTabOwnerWorktreeId(maps, activeWorktreeIds[i % activeWorktreeIds.length], 't1') + } + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(2) + }) + + // Why: the guard set is never pruned and tab ids are minted per created tab. + it('stops recording once the per-session sample cap is reached', () => { + for (let i = 0; i < 400; i += 1) { + const id = `t-${i}` + const maps = { 'wt-a': [tab(id, 'wt-a')], 'wt-b': [tab(id, 'wt-b')] } + resolveActiveTabOwnerWorktreeId(maps, 'wt-a', id) + resolveActiveTabOwnerWorktreeId(maps, 'wt-c', id) + } + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(256) + }) +}) diff --git a/src/renderer/src/store/slices/active-tab-owner-worktree.ts b/src/renderer/src/store/slices/active-tab-owner-worktree.ts new file mode 100644 index 00000000000..270c5d44f1c --- /dev/null +++ b/src/renderer/src/store/slices/active-tab-owner-worktree.ts @@ -0,0 +1,82 @@ +import type { TerminalTab } from '../../../../shared/types' +import { recordRendererCrashBreadcrumb } from '../../lib/crash-breadcrumb-recorder' + +const reportedDuplicateTabVerdicts = new Set() +// Why capped: this set is never pruned and each tab id adds up to two verdict +// keys. 256 keys cover 128–256 duplicated ids, enough evidence for a bundle. +const MAX_REPORTED_DUPLICATE_TAB_VERDICTS = 256 + +/** Test seam: the duplicate breadcrumb is once-per-tab-id-per-verdict per session. */ +export function _resetDuplicateTabOwnerBreadcrumbsForTests(): void { + reportedDuplicateTabVerdicts.clear() +} + +/** + * Resolve which worktree owns a terminal tab, preferring the active worktree. + * + * Why the preference: a stale map can leave one tab id under two worktrees, and + * attributing it to an arbitrary first match leaves `activeTabId` permanently + * unconvergeable — which strands Terminal's active-terminal repair effect in a + * self-retriggering loop (React #185). + */ +export function resolveActiveTabOwnerWorktreeId( + tabsByWorktree: Record, + activeWorktreeId: string | null, + tabId: string +): string | null { + let firstOwnerId: string | null = null + let ownerCount = 0 + // Why tracked in-loop rather than re-read by key: `tabsByWorktree[activeWorktreeId]` + // resolves inherited members for ids like `toString`, and `?.some` would then throw. + // Why the id and not a boolean: a falsy-but-valid active id ('') would fail a + // truthiness guard below and silently fall back to the first match — the very + // misattribution this function exists to remove. + let activeOwnerId: string | null = null + // Why keys and not entries: entries allocates a pair array per worktree on a path + // that runs per tab activation. Own keys stay safe to index by. + for (const worktreeId of Object.keys(tabsByWorktree)) { + const tabs = tabsByWorktree[worktreeId] + if (!tabs.some((tab) => tab.id === tabId)) { + continue + } + ownerCount += 1 + if (firstOwnerId === null) { + firstOwnerId = worktreeId + } + if (worktreeId === activeWorktreeId) { + activeOwnerId = worktreeId + } + } + + // Why breadcrumb: hydration can retain duplicates after a worktree id change, + // but current field reports predate this signal. + // Reading it: `ownerCount > 1` is the load-bearing datum; the verdict only + // hints at the caller. A sustained repair loop shows up as `true`, since that + // effect picks from the active worktree's own list — but it does not prove the + // caller, and the repair effect can emit `false` too: its closure holds the + // worktree from its render while this runs against live state, so a worktree + // switch landing in between (an earlier-flushed effect, or IPC before the + // passive flush) reattributes the tab. So `false` covers that race as well as + // a deliberate background activation such as jump-to-agent — discard neither. + // Why the verdict is in the guard key: it flips under a persisting duplicate, + // and coalescing keeps only the newest payload, so one would erase the other. + // Still at most two crumbs per tab id. + const resolvedToActiveWorktree = activeOwnerId !== null + const verdictKey = `${tabId}:${resolvedToActiveWorktree}` + if ( + ownerCount > 1 && + !reportedDuplicateTabVerdicts.has(verdictKey) && + reportedDuplicateTabVerdicts.size < MAX_REPORTED_DUPLICATE_TAB_VERDICTS + ) { + reportedDuplicateTabVerdicts.add(verdictKey) + recordRendererCrashBreadcrumb('terminal_tab_id_owned_by_multiple_worktrees', { + ownerCount, + resolvedToActiveWorktree + }) + } + + if (ownerCount > 1 && activeOwnerId !== null) { + return activeOwnerId + } + return firstOwnerId +} diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 9574cd3a6d1..72e22adc136 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -120,7 +120,7 @@ export type TabsSlice = { entityId: string, contentType?: TabContentType ) => Tab | null - activateTab: (tabId: string, opts?: { preservePreview?: boolean }) => void + activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void closeUnifiedTab: ( tabId: string, opts?: { recordInteraction?: boolean; terminalRetirementHandled?: boolean } @@ -826,7 +826,17 @@ export const createTabsSlice: StateCreator = (set, activateTab: (tabId, opts) => { set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const scopedWorktreeId = opts?.worktreeId + let found: ReturnType + if (scopedWorktreeId !== undefined) { + const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId) + ? state.unifiedTabsByWorktree[scopedWorktreeId] + : [] + const scopedTab = scopedTabs.find((tab) => tab.id === tabId) + found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null + } else { + found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + } if (!found) { return {} } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 11fbb55c158..cb1b5809018 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -38,6 +38,7 @@ import { } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' +import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree' import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' import { getRepoIdFromWorktreeId, @@ -1903,17 +1904,18 @@ export const createTerminalSlice: StateCreator }, setActiveTab: (tabId) => { + let tabOwnerWorktreeId: string | null = null set((s) => { // Why: focusing a terminal tab clears its bell, but only for the active worktree — clearing a not-yet-visible background tab (worktree activation / jump-to-agent) would swallow the signal. - let tabOwnerWorktreeId: string | null = null - for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { - if (tabs.some((t) => t.id === tabId)) { - tabOwnerWorktreeId = wId - break - } - } + tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( + s.tabsByWorktree, + s.activeWorktreeId, + tabId + ) + const isActiveWorktreeTab = + tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId const nextUnreadTerminalTabs = - tabOwnerWorktreeId === s.activeWorktreeId && s.unreadTerminalTabs[tabId] + isActiveWorktreeTab && s.unreadTerminalTabs[tabId] ? (() => { const copy = { ...s.unreadTerminalTabs } delete copy[tabId] @@ -1921,20 +1923,37 @@ export const createTerminalSlice: StateCreator })() : s.unreadTerminalTabs // Why: only pin global activeTabId to active-worktree tabs — markTerminalTabUnread treats it as "the visible tab" and would swallow BELs on a background tab (e.g. jump-to-agent). - const isActiveWorktreeTab = tabOwnerWorktreeId === s.activeWorktreeId return { activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, - activeTabIdByWorktree: tabOwnerWorktreeId - ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } - : s.activeTabIdByWorktree, + // Why: a redundant activation must not reallocate this map — Terminal's + // active-terminal repair effect depends on it, so a re-activation that + // can't converge activeTabId (tab id reused by an earlier-scanned + // worktree) would otherwise re-trigger itself into React error #185. + activeTabIdByWorktree: + tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId + ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } + : s.activeTabIdByWorktree, unreadTerminalTabs: nextUnreadTerminalTabs } }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + const state = get() + const ownerUnifiedTabs = + tabOwnerWorktreeId !== null && Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId) + ? state.unifiedTabsByWorktree[tabOwnerWorktreeId] + : [] + // Why: a duplicated entity id must activate the same owner chosen above. + const item = + ownerUnifiedTabs.find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) ?? + Object.values(state.unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) if (item) { - get().activateTab(item.id) + state.activateTab( + item.id, + tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined + ) } }, From 15420829ee65797262aca78683df29fdf48d5969 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:10:27 -0700 Subject: [PATCH 04/64] test(terminal): release-gate duplicate PTY renderer restore (#11947) --- config/reliability-gates.jsonc | 122 ++++++++ ...inal-duplicate-pty-renderer-reveal.spec.ts | 264 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 80cb0d5865f..ded4255d731 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1271,6 +1271,128 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.layout-pty-ownership", + "title": "Restored terminal layouts retain one renderer owner per PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-electron-restore", + "surfaces": [ + "persisted terminal layout restore", + "daemon PTY reattach", + "terminal tab hide and reveal", + "xterm renderer ownership", + "remote terminal layout mirroring" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Provider-independent unit tests cover persisted replay, state-boundary authority transfer, remote-runtime mirroring, rootless layouts, repeated leaf ids, metadata repair, and 12,000 nested duplicate leaves. A two-launch macOS Electron journey covers a real surviving local-daemon PTY, persisted duplicate ownership, synchronized streaming output, and tab hide/reveal. Live Linux, Windows, SSH, WSL, and remote-runtime restore journeys remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11757", + "https://github.com/stablyai/orca/pull/11726" + ], + "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", + "oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "assertions": [ + "replays one surface when restored leaves point to the same PTY", + "reattaches one PTY when the split repeats its bound leaf id" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "keeps the active leaf and prunes the stale surface plus its metadata", + "is idempotent and preserves one owner across PTY and focus permutations" + ] + }, + { + "file": "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "normalizes duplicate PTY surfaces at the renderer state boundary", + "moves hydrated pane authority onto the retained PTY leaf" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": ["deduplicates mirrored leaves that claim the same remote PTY"] + }, + { + "file": "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts", + "assertions": ["repairs duplicate persisted PTY renderers before streaming tab reveal"] + } + ], + "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-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.33, + "summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.7, + "summary": "The two-launch Electron journey preserved one real daemon PTY and one renderer through duplicate-layout repair and post-relaunch tab reveal." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused ownership unit suite plus one prebuilt two-launch Electron journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic unit and Electron gates pass locally on macOS; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Orca 1.4.161 visibly strands and fragments the synchronized stream after restoring duplicate PTY renderers (https://github.com/user-attachments/assets/6917ac6f-9ba3-4f6f-a082-fb62932d511f). The post-resize capture remains corrupted, with duplicated lower rows and the terminal still stranded on the right; it is not recovery evidence (https://github.com/user-attachments/assets/32f7be79-0919-48c2-bcad-e4b7f1e14f2d). After the #11726 ownership repair, the equivalent stream remains continuous in one full-width renderer (https://github.com/user-attachments/assets/8c9cc5a2-dbe3-4348-b05c-f8268b98c476), and the final automated journey reports one owner at every layer after relaunch and tab reveal (https://github.com/user-attachments/assets/f19b46aa-e56f-476c-9e2c-e4c3a784c805)." + }, + "performanceBudget": { + "required": true, + "evidence": "Ownership normalization is bounded by layout size, uses iterative traversal, and passes a 12,000-leaf duplicate layout without recursive stack growth. The follow-up adds no production polling, renderer work, listeners, persistence scans, or retained payloads." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the live two-launch journey on Linux and Windows.", + "Add live SSH or remote-runtime duplicate-restore evidence before claiming provider-complete coverage." + ], + "knownGaps": [ + "Live Electron evidence currently covers macOS with a local daemon PTY only.", + "The live journey uses a git-backed test workspace; folder-workspace restoration currently relies on the same provider-independent normalization contracts rather than a separate Electron run.", + "The gate deterministically seeds the historical persisted-state shape; it does not depend on reproducing the unknown UI sequence that originally wrote duplicate ownership.", + "The visual old-release evidence and the structural current-version oracle are separate runs because the current test harness did not exist in Orca 1.4.161." + ], + "demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization." + }, { "id": "terminal-session.kill-all-surface-cleanup", "title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings", diff --git a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts new file mode 100644 index 00000000000..b11c74c86be --- /dev/null +++ b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { TerminalLayoutSnapshot } from '../../src/shared/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { test, expect } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForSessionReady +} from './helpers/store' +import { TEST_REPO_PATH_FILE } from './global-setup' + +type PersistedData = { + workspaceSession?: { + activeTabId?: string | null + terminalLayoutsByTabId?: Record + } +} + +type RendererOwnershipSnapshot = { + paneCount: number + xtermCount: number + rootLeafCount: number + ptyBindingCount: number + uniquePtyCount: number +} + +function streamingTuiSource(marker: string): string { + return ` +let frame = 0 +process.stdout.write('\\x1b[?1049h\\x1b[?25l') +setInterval(() => { + frame += 1 + const lines = [${JSON.stringify(marker)} + ' frame ' + String(frame).padStart(6, '0')] + for (let row = 1; row <= 32; row += 1) { + const width = 8 + ((frame + row * 7) % 48) + lines.push(String(row).padStart(2, '0') + ' OpenCode tool output ' + '#'.repeat(width)) + } + process.stdout.write('\\x1b[?2026h\\x1b[H' + lines.join('\\r\\n') + '\\x1b[J\\x1b[?2026l') +}, 32) +`.trim() +} + +function persistedDataPath(userDataDir: string): string { + return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') +} + +function seedDuplicatePtyOwnership(userDataDir: string): void { + const dataPath = persistedDataPath(userDataDir) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData + const session = data.workspaceSession + const tabId = session?.activeTabId + const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined + const retainedLeafId = layout?.activeLeafId + const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined + if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { + throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') + } + + const duplicateLeafId = randomUUID() + session.terminalLayoutsByTabId[tabId] = { + ...layout, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: retainedLeafId }, + second: { type: 'leaf', leafId: duplicateLeafId } + }, + activeLeafId: retainedLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [retainedLeafId]: ptyId, + [duplicateLeafId]: ptyId + } + } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +async function waitForRestoredTerminal(page: Page, worktreeId: string): Promise { + await waitForSessionReady(page) + await expect.poll(() => getActiveWorktreeId(page), { timeout: 15_000 }).toBe(worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('Restored terminal tab was unavailable') + } + return tabId +} + +async function readRendererOwnership( + page: Page, + tabId: string +): Promise { + return page.evaluate((tabId) => { + const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] + const manager = window.__paneManagers?.get(tabId) + const surface = document.querySelector( + `[data-terminal-tab-id="${CSS.escape(tabId)}"][data-terminal-layout-leaf-ids]` + ) + const countLeaves = (node: TerminalLayoutSnapshot['root']): number => + !node ? 0 : node.type === 'leaf' ? 1 : countLeaves(node.first) + countLeaves(node.second) + const ptyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) + return { + paneCount: manager?.getPanes?.().length ?? 0, + xtermCount: surface?.querySelectorAll('.xterm').length ?? 0, + rootLeafCount: countLeaves(layout?.root ?? null), + ptyBindingCount: ptyIds.length, + uniquePtyCount: new Set(ptyIds).size + } + }, tabId) +} + +async function readStreamingFrame( + page: Page, + tabId: string, + marker: string +): Promise { + const content = await page.evaluate((tabId) => { + const pane = window.__paneManagers?.get(tabId)?.getPanes?.()[0] + return pane?.serializeAddon?.serialize?.() ?? null + }, tabId) + return parseStreamingFrame(content, marker) +} + +async function readMainStreamingFrame( + page: Page, + ptyId: string, + marker: string +): Promise { + const content = await page.evaluate(async (ptyId) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 0 }) + return snapshot?.data ?? null + }, ptyId) + return parseStreamingFrame(content, marker) +} + +function parseStreamingFrame(content: string | null, marker: string): number | null { + const prefix = `${marker} frame ` + const start = content?.indexOf(prefix) ?? -1 + if (!content || start < 0) { + return null + } + const digits = content.slice(start + prefix.length).match(/^\d+/)?.[0] + return digits ? Number(digits) : null +} + +test('repairs duplicate persisted PTY renderers before streaming tab reveal', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches. +{}, testInfo) => { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Seeded E2E repository is unavailable') + + const session = createRestartSession(testInfo) + const marker = `DUPLICATE_PTY_REVEAL_${randomUUID()}` + const tui = stageNodeScriptForTerminal(streamingTuiSource(marker)) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + await ensureTerminalVisible(firstLaunch.page) + await waitForActiveTerminalManager(firstLaunch.page, 30_000) + const firstPtyId = await waitForActivePanePtyId(firstLaunch.page) + await execInTerminal(firstLaunch.page, firstPtyId, tui.command) + await waitForTerminalOutput(firstLaunch.page, marker, 20_000) + tui.cleanup() + + await session.close(firstApp) + firstApp = null + seedDuplicatePtyOwnership(session.userDataDir) + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + const restoredTabId = await waitForRestoredTerminal(secondLaunch.page, worktreeId) + await waitForTerminalOutput(secondLaunch.page, marker, 20_000) + const frameBeforeHide = await readMainStreamingFrame(secondLaunch.page, firstPtyId, marker) + if (frameBeforeHide === null) { + throw new Error('Authoritative TUI frame was unavailable before hiding the restored tab') + } + + const siblingTabId = await secondLaunch.page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + return store.getState().createTab(worktreeId, undefined, undefined, { activate: false }).id + }, worktreeId) + await secondLaunch.page.evaluate( + (tabId) => window.__store?.getState().setActiveTab(tabId), + siblingTabId + ) + await expect + .poll(() => getActiveTabId(secondLaunch.page), { timeout: 10_000 }) + .toBe(siblingTabId) + const restoredSurface = secondLaunch.page.locator( + `[data-terminal-tab-id=${JSON.stringify(restoredTabId)}]` + ) + await expect(restoredSurface).toBeHidden() + await expect + .poll(() => readMainStreamingFrame(secondLaunch.page, firstPtyId, marker), { + timeout: 10_000, + message: 'Authoritative TUI output did not advance while the restored tab was hidden' + }) + .toBeGreaterThan(frameBeforeHide) + const hiddenFrame = await readMainStreamingFrame(secondLaunch.page, firstPtyId, marker) + if (hiddenFrame === null || hiddenFrame <= frameBeforeHide) { + throw new Error('Authoritative TUI output did not remain advanced while the tab was hidden') + } + await secondLaunch.page.evaluate( + (tabId) => window.__store?.getState().setActiveTab(tabId), + restoredTabId + ) + await expect + .poll(() => getActiveTabId(secondLaunch.page), { timeout: 10_000 }) + .toBe(restoredTabId) + await expect + .poll(() => readStreamingFrame(secondLaunch.page, restoredTabId, marker), { + timeout: 20_000, + message: 'Revealed renderer did not catch up to hidden authoritative output' + }) + .toBeGreaterThanOrEqual(hiddenFrame) + + await expect + .poll(() => readRendererOwnership(secondLaunch.page, restoredTabId), { timeout: 10_000 }) + .toEqual({ + paneCount: 1, + xtermCount: 1, + rootLeafCount: 1, + ptyBindingCount: 1, + uniquePtyCount: 1 + }) + await testInfo.attach('duplicate-pty-renderer-after-reveal.png', { + body: await secondLaunch.page.screenshot(), + contentType: 'image/png' + }) + } finally { + tui.cleanup() + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) From 2b44e9ed9e66b4c5fba62d57d8e92da1df5ed98a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:22:50 -0700 Subject: [PATCH 05/64] fix(updater): notarize hourly macOS builds so TCC grants survive updates (#12007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS anchors a notarized Developer ID app's TCC grants on identifier + team, which is cdhash-independent and so survives an in-place update. Without a notarization ticket there is no such stable identity, so every hourly reads as a different client: the grant row stays but stops matching, and file access under Documents/Desktop/Downloads fails with EPERM and no re-prompt. `tccutil reset` fixes it until the next build — and orca-hourly has shipped as many as 14 builds in a day. Skipping notarization was chosen because Squirrel.Mac validates the replacement bundle's signature, not its notarization. That is true, but it is the wrong requirement; the in-place swap was never the problem. Budgets grow to absorb the notary round trip (publish 2x45, job 150), and the App token is re-minted after the build so its one-hour life starts at the first call that uses it rather than during `pnpm install`. --- .github/workflows/hourly-mac-build.yml | 76 +++++++++++++------ config/electron-builder.config.cjs | 19 +++-- .../scripts/electron-builder-config.test.mjs | 11 ++- .../settings/ReleaseChannelSection.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 2 +- 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 7576fabc6a0..ebe544436a6 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -7,9 +7,11 @@ name: Hourly macOS Dev Build # - macOS only. Other platforms keep using RC/stable. # - No tests, no lint, no e2e. This channel trades safety for latency; PR CI # and release-cut remain the gates that matter. -# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's -# signature, not its notarization, so in-place updates still work while the -# ~10min notary round trip is skipped 24x a day. +# - Signed AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every hourly reads as a new client and silently loses file access +# under Documents/Desktop/Downloads — 24 times a day. # # Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main # repo's releases atom feed exposes only its 10 newest entries, so 24 hourly @@ -24,10 +26,14 @@ name: Hourly macOS Dev Build # HOURLY_RELEASE_APP_ID the App's numeric id # HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key # -# Installation tokens live one hour, which is ample here: this job skips tests, -# notarization, and Windows signing entirely, so a run is pack + upload and lands -# well inside that. If one ever did overrun, the release is still an unpublished -# draft at that point, so nothing user-visible breaks. +# Installation tokens live one hour, which is why this mints twice. Install and +# build need no token at all, and notarization can hold the publish step for tens +# of minutes; minting again once the build is done starts the clock at the first +# call that actually uses it rather than burning a third of it on `pnpm install`. +# A pathological retry can still outrun the second token, but the release is an +# unpublished draft until the manifest check passes, so the damage is a stranded +# invisible draft — and the build-number query counts drafts, so it holds its +# number and the next run does not reuse it. on: schedule: @@ -57,11 +63,11 @@ jobs: build-hourly-mac: if: github.repository == 'stablyai/orca' runs-on: blacksmith-6vcpu-macos-15 - # Why 120: it must exceed the worst case the retry budgets below can produce - # (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or # the job is killed mid-retry and no cleanup step runs at all. A typical run - # is far shorter — there is no notarization, Windows signing, or test phase. - timeout-minutes: 120 + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 env: NODE_OPTIONS: --max-old-space-size=4096 steps: @@ -212,12 +218,27 @@ jobs: # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + # Why a second mint: everything from here on writes to the hourly repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint hourly repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-hourly + - name: Create hourly release id: release if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: v${{ steps.hourly.outputs.version }} NAME: ${{ steps.hourly.outputs.name }} SHA: ${{ steps.freshness.outputs.head_sha }} @@ -244,30 +265,37 @@ jobs: Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA). - **Unvetted.** No tests ran. Signed but not notarized — installable - through Orca's in-app updater, but a manual download will be - Gatekeeper-quarantined." + **Unvetted.** No tests ran. Signed and notarized like a release, so it + installs through Orca's in-app updater and opens from a manual download + without a Gatekeeper prompt — but nothing here has been reviewed." echo "tag=$TAG" >>"$GITHUB_OUTPUT" - name: Publish hourly macOS artifacts if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 with: - # Why lower than the release pipeline's 3x45: that budget is sized for - # notarization and Windows signing, neither of which runs here. An attempt - # is pack + upload only. - timeout_minutes: 25 + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed hourly costs an hour, and the next cron picks the same + # commit up, so a third attempt buys less than it costs in runner time. + timeout_minutes: 45 max_attempts: 2 retry_wait_seconds: 30 command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always env: # Why: electron-builder's github publisher targets the repo named in the # config; the token must therefore carry write access to orca-hourly. - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }} ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }} CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A @@ -277,7 +305,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail @@ -307,7 +335,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} NAME: ${{ steps.hourly.outputs.name }} run: | @@ -333,7 +361,7 @@ jobs: steps.publish_live.outcome != 'success' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} run: | set -uo pipefail @@ -346,7 +374,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} run: | set -euo pipefail # Why: --cleanup-tag so pruning does not leave orphan tags behind that diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 39a6df73b05..c8b788769ac 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -16,9 +16,9 @@ const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibilit const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') -// Why: hourly dev builds must carry the *release* identity — same bundle id and -// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed -// Orca. Only notarization is skipped, which in-place updates never check. +// Why: hourly dev builds must carry the *release* identity — same bundle id, +// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to +// swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -330,11 +330,14 @@ module.exports = { // explicit release path so production artifacts remain strict while dev // artifacts do not fail with broken ad-hoc launch behavior. hardenedRuntime: isMacRelease, - // Why: Squirrel.Mac validates the replacement bundle's signature, not its - // notarization, so hourly builds stay installable while skipping the ~10min - // notary round trip 24x a day. A manually-downloaded hourly zip will be - // Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel. - notarize: isMacRelease && !isMacHourly, + // Why hourly builds notarize too, despite the ~10min notary round trip: TCC + // anchors a notarized Developer ID app's permission grants on identifier + + // team, which is cdhash-independent and so survives an update. Without a + // ticket there is no such stable identity, so every hourly reads as a + // different client — the grant row stays but stops matching, and file access + // under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24 + // builds a day that revokes the user's grants faster than they can re-grant. + notarize: isMacRelease, extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('darwin'), diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index b9c4a499118..546384b48d3 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -313,15 +313,18 @@ describe('electron-builder config', () => { }) }) - // Why: notarization is the one release step hourly skips; in-place updates never - // check it, and 24 notary round trips a day is the cost being avoided. - it('skips notarization only for hourly builds', () => { + // Why hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(false) + expect(config.mac.notarize).toBe(true) }) withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { expect(config.mac.notarize).toBe(true) }) + expect(electronBuilderConfig.mac.notarize).toBe(false) }) // Why: the main repo's releases atom feed exposes only its 10 newest entries. diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 4911965938a..3834fe75e0a 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -27,7 +27,7 @@ const CHANNEL_LABELS: Record = { const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', - hourly: 'macOS only. Unvetted builds from main, built every hour. No tests, no notarization.' + hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.' } function formatBuildLabel(build: ReleaseBuild): string { @@ -226,7 +226,7 @@ export function ReleaseChannelSection(): React.JSX.Element {

{translate( 'auto.components.settings.ReleaseChannelSection.hourlyWarning', - 'Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.' + 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a648e68d409..bdbda8512e9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10089,7 +10089,7 @@ "description": "Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.", "devOnly": "Dev only", "channelAriaLabel": "Update channel", - "hourlyWarning": "Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.", + "hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", "loadingBuilds": "Loading builds…", "noBuilds": "No builds found", "refresh": "Refresh build list", From 05206046f68e52e62a55904306c05408754e6679 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:24:31 -0700 Subject: [PATCH 06/64] chore: condense code comments (#12008) * chore: condense code comments * chore: shorten more code comments * clarify PTY agent session descendant cleanup behavior Refine the comment on ptyAgentSessionIds to more accurately describe when agent sessions sweep their descendant process trees and note the exception on immediate Windows shutdown. --- src/cli/handlers/orchestration.ts | 4 +- src/cli/selectors.ts | 2 +- src/main/claude/hook-service.ts | 22 ++++----- src/main/cursor/hook-service.ts | 20 ++++---- src/main/git/runner.ts | 2 +- src/main/git/worktree.ts | 4 +- src/main/memory/hydrate-local-pty-registry.ts | 10 ++-- src/main/opencode/hook-service.ts | 4 +- src/main/providers/local-pty-provider.ts | 2 +- src/main/providers/local-pty-shell-ready.ts | 4 +- src/main/rate-limits/codex-fetcher.ts | 20 ++++---- src/main/rate-limits/service.ts | 2 +- src/preload/index.ts | 5 +- src/relay/agent-hook-server.ts | 10 ++-- src/relay/dispatcher.ts | 2 +- src/relay/git-handler-status-ops.ts | 4 +- src/relay/git-handler.ts | 24 +++++----- src/relay/pty-handler.ts | 14 +++--- src/relay/relay-handshake.ts | 6 +-- .../src/runtime/sync-runtime-graph.ts | 2 +- .../src/runtime/web-session-tabs-sync.ts | 2 +- src/renderer/src/store/slices/diffComments.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 48 +++++++++---------- 23 files changed, 107 insertions(+), 108 deletions(-) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index d797cfbab1b..0b7ccdb7d6b 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -527,7 +527,7 @@ export const ORCHESTRATION_HANDLERS: Record = { throwNoActiveSenderTerminal() } - // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle). + // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') const sendParams = { from, @@ -1123,7 +1123,7 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') - // Why: same read posture as task-list — an explicitly named Run stays inspectable without a bound pane, so identity is only resolved for the implicit "my own Run" case. + // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 2b53c8fbc0a..3729b8598a7 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -211,7 +211,7 @@ export async function getComputerCommandTarget( } } -// Mirror getBrowserCommandTarget / getBrowserWorktreeSelector for emulator (workspace scoped by default + explicit --device/--emulator/--worktree; active from bridge for unqualified). +// Match browser targeting: workspace by default, explicit device/emulator/worktree overrides. export type EmulatorCliTarget = { worktree?: string device?: string diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 96cbe301dcf..67b66c34a93 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -67,10 +67,10 @@ function getManagedScript( `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` ] : []), - // Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing. + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - // Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook. + // Why: use curl.exe to avoid an extra PowerShell startup per hook. buildWindowsAgentHookCurlPostCommand('claude'), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), @@ -89,16 +89,16 @@ function getManagedScript( 'fi' ] : []), - // Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing. - // Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e. + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse. - // Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). + // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -136,7 +136,7 @@ export class ClaudeHookService { } } - // Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`. + // Why: report partial registration instead of a false installed state. const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 @@ -227,11 +227,11 @@ export class ClaudeHookService { // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this. + // Why: remote Windows is unsupported; local process.platform cannot identify the remote OS. const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` - // Why: SFTP I/O fails often (network/EACCES/disk); wrap install so transient failures surface as structured state:'error' rather than an unhandled rejection. + // Why: surface fallible SFTP installs as structured errors. try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -248,8 +248,8 @@ export class ClaudeHookService { const command = getRemoteManagedCommand(remoteScriptPath) const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) - // Why: write script before settings — a mid-install failure then leaves a harmless orphan script, not settings.json pointing at a missing one. - // Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from the local OS. + // Why: write scripts before settings to avoid settings pointing to missing scripts. + // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. await writeManagedScriptRemote( sftp, remoteScriptPath, diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 9ce9172f8fb..7b5f3b90dda 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -26,8 +26,8 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection. -// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache. +// Subscribe only to Cursor hooks needed for spinner and turn detection. +// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. const CURSOR_EVENTS = [ 'beforeSubmitPrompt', 'stop', @@ -62,7 +62,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts). + // Why: source current endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('cursor'), @@ -75,15 +75,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), - // Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). + // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead. - // Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). + // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -167,7 +167,7 @@ export class CursorHookService { const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) - // Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path. + // Why: match filenames to remove stale hooks from prior builds and user-data paths. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) // Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks. @@ -192,7 +192,7 @@ export class CursorHookService { for (const eventName of CURSOR_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - // Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry. + // Sweep Claude- and Cursor-shaped variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( (definition) => !isManagedCommand(definition.command as string | undefined) ) @@ -201,7 +201,7 @@ export class CursorHookService { nextHooks[eventName] = [...cleaned, definition] } - // Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value. + // Why: Cursor requires `version: 1`; preserve user-pinned values. const nextConfig: Record = { ...config, hooks: nextHooks } if (nextConfig.version === undefined) { nextConfig.version = 1 @@ -247,7 +247,7 @@ export class CursorHookService { } // Why: script-then-config order so a partial mid-install leaves a working script nothing points at. - // Why: SSH remotes always use POSIX `.sh` hook paths even when Orca runs on Windows; never derive from local OS. + // Why: SSH hooks always use POSIX .sh paths, regardless of the local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index b75cfa4ab5e..9ef510968b7 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -256,7 +256,7 @@ function resolveCommand( // ─── Git-specific runners ─────────────────────────────────────────── -// Why: execFile disables its cap when maxBuffer is undefined; unbounded output over V8's string max crashes main uncatchably — keep in sync with relay MAX_GIT_BUFFER. +// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 type GitExecOptions = { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index f2f883f0d90..6862b97230d 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -683,10 +683,10 @@ export async function listWorktreeGraph( } } -// Why: cold start triggers many concurrent `git worktree list` spawns per repo (expensive on Windows, #7225); share the in-flight promise to collapse duplicates. +// Why: share concurrent `git worktree list` scans, which are expensive on Windows. const inFlightWorktreeScans = new Map>() -// Why: a listing after a mutation must not join a scan that predates it; bumping the generation on mutation retires older in-flight scans from sharing. +// Why: mutation generations prevent listings from joining stale scans. const worktreeScanGenerations = new Map() function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index ec672d7e9c8..6608fd2641c 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -19,7 +19,7 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { splitWorktreeId } from '../../shared/worktree-id' import type { Store } from '../persistence' -// Why: attachMainWindowServices reruns on every macOS dock re-activation; guard against re-running git I/O + daemon RPC. +// Why: macOS dock reactivation reruns setup; avoid repeating Git I/O and daemon RPC. let hasHydrated = false /** @@ -42,7 +42,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick [repo.id, repo])) // Why: verify via live git that a referenced worktree still exists, to avoid resurrecting removed ones. const liveLocalWorktreeIds = new Set() @@ -51,7 +51,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick p.ptyId)) - // Why: git enumeration is slow, so re-read daemon+registry each pass to avoid resurrecting exited sessions; terminates as each pass resolves >=1 new repo. + // Why: re-read live sessions each pass to avoid resurrecting exited sessions. for (;;) { const newlyReferencedRepos = new Map[number]>() for (const info of sessionInfos) { @@ -65,7 +65,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick 0 && id.length <= 1024 } @@ -40,7 +40,7 @@ export function getOpenCodePluginSource(): string { } export function getOpenCodeFamilyPluginSource(hookPathname: string): string { - // Why: plugin runs in OpenCode's Node process and POSTs Orca's ORCA_* PTY env to the shared agent-hooks server; events are mapped plugin-side to fit the server's per-case switch. + // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', "// endpoint file does not spam OpenCode's stderr once per hook post.", diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index c3ad6f36103..39a00c823d7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -76,7 +76,7 @@ const PANE_IDENTITY_ENV_KEYS = [ let ptyCounter = 0 const ptyProcesses = new Map() const ptyIncarnations = new Map() -// Why: only agent sessions get descendant tree-kill (tool children run in detached groups SIGHUP can't reach); plain terminals skip it so nohup-detached children survive. +// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. const ptyAgentSessionIds = new Set() // Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. type PtyShutdownOperation = { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7fc84d85ca0..e3f06c563d6 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -63,12 +63,12 @@ function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) } -// Why: an inherited ZDOTDIR pointing at an Orca wrapper dir (`.../shell-ready/zsh`) makes the wrapper source itself recursively (zsh recursion limit); treat it as unset so the caller falls back to HOME. +// Why: an Orca wrapper ZDOTDIR recurses; treat it as unset and fall back to HOME. function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { if (!value) { return null } - // Why: strip trailing slashes so `ZDOTDIR="$dir/"` still matches the self-loop suffix check; `/` collapses to empty → HOME fallback. + // Why: strip trailing slashes so wrapper paths match; `/` falls back to HOME. const normalized = value.replace(/\/+$/, '') if (!normalized || normalized.endsWith('/shell-ready/zsh')) { return null diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 685aa1d506f..9173d192038 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -168,7 +168,7 @@ function buildWslCodexCommand( const execSuffix = `${args.map(shellQuote).join(' ')}${ options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' }` - // Why: npm/nvm launchers use `#!/usr/bin/env node`; exec'ing them from plain sh loses Node's PATH and pins stale installs. + // Why: npm/nvm launchers need Node's PATH; plain sh can select stale installs. const loginShellCommand = buildWslLoginShellCommand( [setupCommands, `exec codex ${execSuffix}`].join(' && ') ) @@ -313,7 +313,7 @@ function getBackendAuthRead( if (existing) { return existing } - // Why: Node can't cancel an in-flight UNC read; keep one read per auth path so repeated refreshes don't stack them. + // Why: dedupe UNC reads because Node cannot cancel an in-flight read. const read = createAuthFilesystemOperation(authPath, () => readFile(authPath, 'utf8').then( (content) => ({ content }), @@ -528,7 +528,7 @@ async function fetchViaBackend( if (!auth || signal.aborted) { return null } - // Why: reuse Codex's own get_rate_limit_status endpoint, avoiding a hidden app-server (and WSL login shell) per refresh. + // Why: reuse Codex's endpoint to avoid an app server and WSL login shell per refresh. const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { ...auth, signal @@ -538,7 +538,7 @@ async function fetchViaBackend( return null } const payload = (await response.json()) as BackendUsageResponse - // Why: plan_type is required by Codex's RateLimitStatusPayload; reject malformed JSON so the app-server fallback still runs. + // Why: reject missing plan_type so the app-server fallback runs. if (typeof payload.plan_type !== 'string') { return null } @@ -619,10 +619,10 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise => ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), @@ -1945,7 +1944,7 @@ const api = { onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') }, - // Why: deliberately loose — main's validator (src/main/telemetry/validator.ts) is the single enforcement point; call sites use the typed wrappers in src/renderer/src/lib/telemetry.ts. + // Why: main validates telemetry; renderer call sites use typed wrappers. telemetryTrack: (name: string, props: Record): Promise => ipcRenderer.invoke('telemetry:track', name, props), telemetrySetOptIn: (optedIn: boolean): Promise => diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb938f55d2b..f64a5282e10 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -40,10 +40,10 @@ const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const CODEX_SUBAGENT_POLL_MS = 1_000 -// Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. +// Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. const MAX_HOOK_META_LEN = 64 -// Why: WSL relay has no per-pane teardown (PTYs live on the Windows host), so the replay cache would grow forever without a recency cap. +// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 function defaultEndpointDir(): string { @@ -96,7 +96,7 @@ export class RelayAgentHookServer { private endpointFilePath: string private endpointFileWritten = false private state: HookListenerState = createHookListenerState() - // Why: shared status cache drops wire-envelope fields; this sidecar holds source/env/version so replay matches the live POST path. + // Why: retain envelope metadata so replays match live POSTs. // Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep. private lastEnvelopeMetaByPaneKey: Map< string, @@ -128,7 +128,7 @@ export class RelayAgentHookServer { try { await this.listenOn(this.preferredPort) } catch (err) { - // Why: preferred port is best-effort; on EADDRINUSE fall back to ephemeral and let clients re-coordinate via the endpoint file. + // Why: fall back to an ephemeral port on EADDRINUSE; clients use the endpoint file. if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') { this.portFallbackApplied = true await this.listenOn(0) @@ -151,7 +151,7 @@ export class RelayAgentHookServer { return new Promise((resolve, reject) => { const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) - // Why: null the server ref on bind failure so a later start() can retry (else the early-return at top of start() wedges it). + // Why: clear failed server refs so later start() calls can retry. this.server = null reject(err) } diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index c6a1a77ec96..7149144c9a5 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -111,7 +111,7 @@ export class RelayDispatcher { } // Why: redirect outgoing frames to the reconnected socket without rebuilding the dispatcher + handler tree. - // Why: the new client's multiplexer restarts at seq=1, so reset seq/decoder state or acks stall and fire a false connection-dead signal. + // Why: a new multiplexer restarts at seq=1; reset state to avoid stalled acknowledgements. setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.closed = true diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 1bd05821f50..880e690ee54 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -133,7 +133,7 @@ export async function getStatusOp( const branchName = getShortBranchName(branch) if (branchName) { try { - // Why: this probe coalesces across concurrent status reads, so one request's abort must not reject the shared in-flight promise. + // Why: one request's abort must not reject this shared status probe. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), @@ -171,7 +171,7 @@ export async function getStatusOp( // not a git repo or git not available } - // Why: skip line-stats when the limit was hit — numstat over a huge change set would reintroduce the cost the limit avoids. + // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d883c742425..c574e7aaa62 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -112,7 +112,7 @@ function resolveRelayPath(repoPath: string, value: string): string { if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { return value } - // Old git ignores `--path-format=absolute`; resolve relative toplevel/git-dir against repoPath, picking the win32/posix resolver by its shape. + // Old Git ignores `--path-format=absolute`; resolve relative paths against repoPath by path shape. return isWindowsAbsolutePath(repoPath) ? path.win32.resolve(repoPath, value) : path.posix.resolve(repoPath, value) @@ -176,10 +176,10 @@ export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() private readonly gitCapabilities = new GitCapabilityCache() - // Why: large diff/exec responses go on the bulk lane so they don't head-of-line-block interactive pty.data echo on the shared SSH channel. + // Why: use the bulk lane so large responses do not block interactive PTY echo. private readonly responseStreams = new GitResponseStreamRegistry() - // Why: instance-level TTL cache avoids re-reading `.gitmodules` per diff click over SSH; per-instance so it can't leak across tests. + // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() // Why: RelayContext accepted for protocol back-compat (docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. @@ -360,7 +360,7 @@ export class GitHandler { }) } - // Why: parent status lists one gitlink row per submodule; fetch inner per-file changes by running status inside the submodule's own worktree. + // Why: fetch per-file submodule changes from the submodule worktree. private async getSubmoduleStatus(params: Record, context: RequestContext) { const worktreePath = params.worktreePath as string const submodulePath = params.submodulePath as string @@ -383,7 +383,7 @@ export class GitHandler { // Why: pointer/range probes are part of the same SSH request and must not outlive its cancellation. const requestGit: GitExec = (args, cwd, options) => this.git(args, cwd, { ...options, signal: context.signal }) - // Why: a moved gitlink (clean worktree) has no uncommitted rows; surface files changed between recorded and checked-out commits so it isn't empty. + // Why: moved clean gitlinks need committed changes surfaced. const { fromOid, toOid } = await resolveSubmoduleCommitRange( requestGit, worktreePath, @@ -426,7 +426,7 @@ export class GitHandler { private async getDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string - // Why: filePath is relative and joined for readWorkingFile; validate or `../../etc/passwd` traverses outside the worktree. + // Why: validate relative paths to prevent traversal outside the worktree. const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { @@ -434,11 +434,11 @@ export class GitHandler { } const staged = params.staged as boolean const compareAgainstHead = params.compareAgainstHead as boolean | undefined - // Why: register the in-flight dedupe synchronously (before any await) so concurrent identical reads coalesce; submodule routing happens inside. + // Why: register dedupe before awaiting so identical reads coalesce. const result = await this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => { - // Why: gitlinks can't be read as blobs, so route the gitlink root to a pointer diff and inner files into the submodule's own worktree. + // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. const submodulePaths = await listSubmodulePathsCached( this.git.bind(this), worktreePath, @@ -775,13 +775,13 @@ export class GitHandler { private async branchCompare(params: Record) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string - // Why: a baseRef starting with '-' would be read as a git rev-parse flag, potentially leaking environment variables or config. + // Why: reject flag-like base refs to prevent rev-parse option injection. if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } const gitBound = this.git.bind(this) return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { - // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; without it parseBranchDiff would get C-style octal-escaped paths. + // Why: preserve non-ASCII filenames as UTF-8 for parseBranchDiff. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitBound( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], @@ -821,7 +821,7 @@ export class GitHandler { (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) ) } catch (error) { - // Why: swallow only 'no upstream configured' (an expected state); other errors (auth, corruption, network) must surface to the user. + // Why: suppress only the expected no-upstream error; surface all others. if (isNoUpstreamError(error)) { return { hasUpstream: false, ahead: 0, behind: 0 } } @@ -1113,7 +1113,7 @@ export class GitHandler { } private async pull(params: Record) { - // Why: plain `git pull` honors the user's merge/rebase/ff policy; with none, Git's policy error is normalized with setup guidance. + // Why: plain `git pull` honors user merge/rebase/ff policy. await this.pullWithArgs(params, []) } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cc685389f0a..243cc758943 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -180,7 +180,7 @@ type ManagedStartupCommand = { timer: ReturnType | null } -// Why: node-pty's Windows agent throws on any signal arg (ConPTY has no signal semantics); drop it there, forward on POSIX. +// Why: Windows ConPTY rejects signals; forward them only on POSIX. function killPtyProcess(pty: IPty, signal: string): void { if (process.platform === 'win32') { pty.kill() @@ -374,7 +374,7 @@ export class PtyHandler { 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. + // Why: augment environment on every spawn so PTYs receive current hook coordinates. private envAugmenters: PtyEnvAugmenter[] = [] private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionCreateOperations = new Map< @@ -1590,7 +1590,7 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } - // Why: a shell can die without node-pty firing onExit (reaped out-of-band); prove liveness so attach doesn't strand a dead, lingering lease. + // Why: verify liveness because shells can exit without node-pty onExit. if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() this.releaseRelayIngress(managed) @@ -1603,7 +1603,7 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } - // Why: a relay generation reset can reuse pty-N for a different pane; reject on identity disagreement (absent identity permissive). + // Why: generation resets can reuse PTY IDs; reject conflicting identities. const mismatch = attachIdentityMismatches( { paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined, @@ -1646,8 +1646,8 @@ export class PtyHandler { } } - // Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early. - // Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history. + // Why: return replay during spawn before renderer handlers register. + // Why: retain replay buffers so later restarts receive full history. const replay = managed.buffered.read() if (replay) { // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. @@ -1715,7 +1715,7 @@ export class PtyHandler { this.releaseStartupCommand(managed) this.flushPtyOutput(id) this.requestForceKill(managed) - // Why: remote Git deletion must not race the child's native handles; on timeout keep the map entry so onExit/retry still owns it. + // Why: preserve timed-out entries so onExit/retry owns native handles. await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) } else { this.releaseStartupCommand(managed) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index bb61e8a08af..fbbe0283045 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -13,10 +13,10 @@ import { } from './protocol' import { relayLogLine } from './relay-diagnostic-log' -// Why: client maps this exit code to a non-retryable error so it skips reconnect backoff; other non-zero exits are treated as transient. +// Why: clients treat this exit code as non-retryable; other non-zero exits are transient. export const EXIT_CODE_VERSION_MISMATCH = 42 -// Why: read .version next to the resolved script path (realpathSync, not cwd) so arbitrary-cwd or symlink-launched spawns still report a coherent version. +// Why: read .version beside the resolved script path, not the arbitrary launch cwd. export function readLaunchVersion(): string { try { const entry = process.argv[1] @@ -161,7 +161,7 @@ export type ConnectHandshakeCallbacks = { onAccepted: (leftover: Buffer) => void } -// Why: bridge-side version handshake; defense-in-depth so a bad .version can't let a v2 bridge drive a v1 daemon (cf. VS Code remoteExtensionHostAgentServer.ts:340). +// Why: defense-in-depth prevents a bad .version from pairing incompatible bridge and daemon versions. export function runConnectHandshake( sock: Socket, myVersion: string, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 6a7db6cf022..410645c999c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -244,7 +244,7 @@ async function runRuntimeGraphSync(): Promise { } export type RuntimeMobileSessionSyncKey = { - // Why: compared by reference; reallocation signals a real layout/title change, avoiding stringifying thousands of tabs. See docs/agent-working-pane-typing-lag.md. + // Why: reference changes signal layout/title updates without stringifying thousands of tabs. terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 0a52cdadc06..0b8b3908675 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -243,7 +243,7 @@ export function shouldApplyWebSessionTabsSnapshot( snapshot.publicationEpoch === replayable.publicationEpoch && snapshot.snapshotVersion === replayable.snapshotVersion ) - // Why: snapshotVersion is monotonic only within one publicationEpoch (resets on host restart); reject as stale only within the same epoch, since a different epoch is a new generation and must apply. + // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. if ( current && current.publicationEpoch === snapshot.publicationEpoch && diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index ce11b5b4eb6..1154fd649b5 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -85,7 +85,7 @@ function deliverySnapshotMatches( ) } -// Why: one shared frozen sentinel so selectors don't return a fresh [] (avoids re-renders); freezing stops a stray push corrupting the shared instance for every consumer. +// Why: a frozen shared sentinel avoids selector re-renders and mutation. const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([]) async function persist( diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index cb1b5809018..443032a5e4d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1831,7 +1831,7 @@ export const createTerminalSlice: StateCreator : {}) } }) - // Why: closing a tab sweeps live and retained agent-status for it; use dropAgentStatusByTabPrefix so retention suppressors block a same-frame live→gone re-snapshot. + // Why: sweep tab agent status through its suppressor-aware removal path. // Why: Pi can leave a completed row keyed under an already-missing tab id; pass the worktree to sweep that orphan while preserving active pre-render child rows. get().dropAgentStatusByTabPrefix( tabId, @@ -1906,7 +1906,7 @@ export const createTerminalSlice: StateCreator setActiveTab: (tabId) => { let tabOwnerWorktreeId: string | null = null set((s) => { - // Why: focusing a terminal tab clears its bell, but only for the active worktree — clearing a not-yet-visible background tab (worktree activation / jump-to-agent) would swallow the signal. + // Why: clear bells only for visible tabs; background bells must persist. tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( s.tabsByWorktree, s.activeWorktreeId, @@ -1922,7 +1922,7 @@ export const createTerminalSlice: StateCreator return copy })() : s.unreadTerminalTabs - // Why: only pin global activeTabId to active-worktree tabs — markTerminalTabUnread treats it as "the visible tab" and would swallow BELs on a background tab (e.g. jump-to-agent). + // Why: activeTabId marks the visible tab, so background bells remain unread. return { activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, // Why: a redundant activation must not reallocate this map — Terminal's @@ -1968,7 +1968,7 @@ export const createTerminalSlice: StateCreator updateTabTitle: (tabId, title) => { set((s) => { - // Why: mutate only the owning worktree's tab array — rebuilding all of them would break shallow-equality selectors and spuriously re-render background worktrees on every OSC title frame. + // Why: update only the owner to preserve selector equality for background worktrees. const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) if (!ownerWorktreeId) { return s @@ -2026,7 +2026,7 @@ export const createTerminalSlice: StateCreator ) scheduleRuntimeGraphSync() const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } - // Why: title changes affect agent-status sort scoring, so re-sort — but only background worktrees; active-worktree changes are click-driven PTY-remount side-effects (bug PR #209). + // Why: title changes affect sorting, except active-worktree remount side effects. const isActive = ownerWorktreeId === s.activeWorktreeId const nextState: Partial = isActive ? { tabsByWorktree: nextTabsByWorktree } @@ -2148,10 +2148,10 @@ export const createTerminalSlice: StateCreator if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) { return s } - // Why: smart sort's title-heuristic fallback (Edge case 9) reads this map; a hookless 'working' → 'permission' title change must re-sort, but only on classification change. + // Why: re-sort hookless title changes only when their activity classification changes. const classificationChanged = classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) - // Why: suppress the sortEpoch bump when the pane lives in the active worktree — its title change is a click side-effect (PTY remount), the bug PR #209 fixed; skip if orphaned. + // Why: skip active-worktree remount side effects and orphaned panes. const ownerWorktreeId = classificationChanged ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) : null @@ -2207,7 +2207,7 @@ export const createTerminalSlice: StateCreator if (!ownerTab) { return } - // Why: terminal attention persists until real interaction ("show until interact"); keystroke/pointerdown clears via clearTerminalTabUnread, tab/group activation clears directly. + // Why: terminal attention persists until real interaction. set((s) => { if (s.unreadTerminalTabs[tabId]) { return s @@ -2370,7 +2370,7 @@ export const createTerminalSlice: StateCreator if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { wasActivationSpawn = true } - // Why: consume one pendingActivationSpawn unit — a split remounts several panes per click, each activation callback suppressed without hiding later real activity. + // Why: consume one suppression per split-pane activation callback. const { pendingActivationSpawn: _unused, ...rest } = tab void _unused // Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY. @@ -2390,7 +2390,7 @@ export const createTerminalSlice: StateCreator } break } - // Why: a new active-worktree tab's first PTY flips the live-tab signal (+12), so bump sortEpoch — but suppress on activation spawns (click side-effect, not activity). + // Why: the first active PTY changes sorting, except activation-spawn side effects. const isFirstPty = existingPtyIds.length === 0 const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn @@ -2608,7 +2608,7 @@ export const createTerminalSlice: StateCreator delete nextCodexRestartNoticeByPtyId[currentPtyId] } } - // Why: a passed ptyId means the PTY actually exited — drop its lastKnown so restart won't reattach a dead relay; bulk clear (connection_lost) keeps it during relay grace. + // Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace. const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } if (ptyId && nextLastKnownRelay[tabId] === ptyId) { // Why: the relay slot holds ONE id per tab (the last pane to bind). If @@ -2731,7 +2731,7 @@ export const createTerminalSlice: StateCreator } } - // Why: pty:exit can reach the renderer before the kill promise resolves, so the sleeping record must be in the store before the kill (and rolled back on failure). + // Why: store sleeping records before kill, since pty:exit can arrive first. const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) const replacedSleepingRecords: Record = {} for (const key of sleepingRecordKeys) { @@ -3126,8 +3126,8 @@ export const createTerminalSlice: StateCreator clearCommittedPtyShutdownSettlements(exitGuardPtyIds) } - // Why (ordering invariant, DESIGN_DOC §3.3.c): capture serializer buffers before pty.kill (panes unmount on exit); SSH-critical since the relay drops remote history on kill. - // Why: capture() writes through the store via setTabLayout, so the set() below must spread s.terminalLayoutsByTabId in a functional updater, not a captured snapshot, or it clobbers the capture. + // Why: capture buffers before kill, which unmounts panes and drops SSH relay history. + // Why: capture() updates layouts, so the following updater must merge current state. if (keepIdentifiers) { for (const tab of tabs) { const capture = shutdownBufferCaptures.get(tab.id) @@ -3841,7 +3841,7 @@ export const createTerminalSlice: StateCreator validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - // Why pendingActivationSpawn: a restored worktree's first mount calls updateTabPtyId, which would bump lastActivityAt and bounce it to the top of Recent; the tag (consumed on the first pty update) suppresses that so only real activity bumps. + // Why: suppress restored mounts so only real activity updates Recent. const tabsByWorktree: Record = Object.fromEntries( Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) @@ -3919,7 +3919,7 @@ export const createTerminalSlice: StateCreator ? session.activeRepoId : null - // Why: workspaceSessionReady stays false here; reconnectPersistedTerminals sets it true after eager spawns so TerminalPane can't mount and spawn duplicate PTYs first. + // Why: reconnect enables the session after eager spawns to prevent duplicate PTYs. // Why: activeWorktreeIdsOnShutdown is authoritative when present; persisted tab/layout PTY IDs are only wake hints, not a full active-workspace list. const shutdownIds = session.activeWorktreeIdsOnShutdown ?? @@ -3928,7 +3928,7 @@ export const createTerminalSlice: StateCreator .map(([wId]) => wId) const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) - // Why: record which tabs had live PTYs from raw session data before clearTransientTerminalState nulls ptyIds, so reconnect binds the right tabs in multi-tab worktrees (not just tabs[0]). + // Why: capture live PTY tabs before transient state clears their IDs. // Also include tabs whose relay session id survived in remoteSessionIdsByTabId (ptyId was null but the relay PTY is alive). const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} const pendingReconnectTabByWorktree: Record = {} @@ -3989,7 +3989,7 @@ export const createTerminalSlice: StateCreator } } - // Why: SSH worktrees aren't persisted in worktreesByRepo (discovered via relay); synthesize placeholders from session tabs so the sidebar shows them until SSH reconnect + fetchWorktrees replace them. + // Why: synthesize SSH worktrees from session tabs until relay discovery replaces them. // Why: only SSH gets placeholders; local metadata comes from the next successful fetch. const sshRepoIds = new Set( runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id) @@ -4032,7 +4032,7 @@ export const createTerminalSlice: StateCreator worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder] } - // Why: the restored-active worktree bypasses setActiveWorktree, so record it in everActivatedWorktreeIds here to keep a later re-click from re-tagging (which would suppress real activity). + // Why: record restored active worktrees to avoid suppressing later real activity. const nextEverActivated = new Set(s.everActivatedWorktreeIds) if (activeWorktreeId) { nextEverActivated.add(activeWorktreeId) @@ -4065,11 +4065,11 @@ export const createTerminalSlice: StateCreator pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, everActivatedWorktreeIds: nextEverActivated, - // Why: seed nav history with the hydrated active worktree so the first activation has a Back target; hydration bypasses recordWorktreeVisit, so otherwise Back stays disabled until a second click. + // Why: seed hydrated active worktrees so the first activation has a Back target. worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)), - // Why: daemon ptyIds survive app restart; preserve ptyIdsByLeafId so reconnect can reattach each split-pane leaf to its own session, not just the tab-level ptyId. + // Why: preserve daemon leaf PTY IDs to reattach every split-pane session. terminalLayoutsByTabId: Object.fromEntries( Object.entries(session.terminalLayoutsByTabId) .filter(([tabId]) => validTabIds.has(tabId)) @@ -4141,7 +4141,7 @@ export const createTerminalSlice: StateCreator return } - // Why: defer daemon createOrAttach to connectPanePty (real fitAddon dims) instead of eager-spawning at 80×24 and garbling on flush; this loop only records the session IDs to reattach. + // Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output. let reconnectedTabsByWorktree: Record | null = null let reconnectedPtyIdsByTabId: Record | null = null // Why indexed: the loop neither sets state nor awaits, so one index over the @@ -4183,7 +4183,7 @@ export const createTerminalSlice: StateCreator : pendingPtyId const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - // Why: set the wake-hint (tab.ptyId) and live-pty map (ptyIdsByTabId) so the worktree dot goes green before the pane mounts; actual reattach happens later in pty-connection.ts. + // Why: publish live PTY hints before mount; pty-connection reattaches later. console.debug( `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) @@ -4208,7 +4208,7 @@ export const createTerminalSlice: StateCreator } } - // Why: deferred SSH targets haven't connected yet, so their ptyIds weren't restored above; stash session IDs in a map that survives cleanup for pty-connection.ts's deferred reconnect. + // Why: keep deferred SSH session IDs for post-cleanup reconnect. const scopedTabIds = new Set( [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) From a07427e970ed4fee7a9ee0d35b022ac11951b28d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:27:16 -0700 Subject: [PATCH 07/64] fix(ssh, relay): keep remote sessions alive through reconnects and backpressure (#11999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh,relay): stop remote connections from being killed by backoff and frame caps Three independent connection killers found in the SSH/remote freeze audit. FINDING A - the reconnect ladder never escalated for post-handshake drops. scheduleReconnect() used the single published state.reconnectAttempt for both the delay index and the give-up test, and runReconnectAttempt() zeroed it before connecting (ssh.ts gates the relay redeploy on 0-at-connected). Every post-handshake drop therefore re-entered at 1000ms forever, ~3600 relay redeploys/hour, and 'reconnection-failed' was unreachable for a flapping host. New SshReconnectLadder splits the delay index (advanced by every retry) from the failure streak (advanced only by a failed handshake), so flaps back off while give-up semantics stay byte-identical to shipped. FINDING B - notify() closed the client whenever a frame exceeded the producer frame capacity, conflating a permanently un-sendable frame with transient backpressure. A 5000-event fs.changed is 425KB against a 49KB cap, so the watcher flood killed the link and re-killed on every reattach+replay. notify() now drops and logs once per generation; fs.changed is chunked to each sink's capacity with a control-lane overflow marker as the resync fallback; agent-hook envelopes shed lastAssistantMessage/interactivePrompt/subagents to fit. FINDING B2 - sendResponse routed >1MB responses to a lane whose admission ignores the frame cap and closed the client on rejection, so a large fs.listFiles dropped the SSH host. It now substitutes a JSON-RPC error so the request fails instead of the connection. Also moves fs.streamEnd/fs.streamError to the control lane so a terminal frame cannot be dropped by the producer-lane check. Co-authored-by: Orca * fix(relay): stop the overflow marker from re-killing the link it protects Round-1 review fixes on the P0 freeze work. The control-lane overflow marker could reinstate the exact failure this P0 removes: dispatcher-client-writer closes the client when control-lane admission fails, and admitControl is the only lane that returns an error, so one marker per failing batch accumulated to the 256-frame/1MB bound and dropped the link. Markers are now deduped to one outstanding per (client, root), cleared on settle. Chunking also defeated the renderer's per-payload directory dedupe -- events are now stable-grouped by parent directory so one directory lands in one chunk -- and the halving walk overshot the byte minimum ~1.7x while the fast path paid three JSON encodes; both are fixed by publishing first and sizing from a measured bytes-per-event estimate. Agent-hook shedding now surrenders the blocking interactive prompt LAST rather than first, so a degraded envelope cannot strand a pane at state=waiting with no answerable question card. The dropped-notification log now distinguishes over-capacity from producer queue backpressure and no longer lets the first dropped method silence every other producer for the life of the connection. * fix(relay,ssh): keep status delivery and terminal frames from trading one freeze for another Round-2 review fixes. The round-0 change from close-on-rejection to silent drop removed the only redelivery path for agent.hook envelopes: they are fire-and-forget and the per-pane cache only replays on handler install, so a saturated link stranded a pane on a stale Working spinner until reconnect. Closing used to guarantee delivery by forcing that replay. Envelopes now publish per client and pend for bounded latest-wins redelivery when the producer queue rejects them. Shed fields are now named on the wire. The subagent roster is not cosmetic -- the renderer replaces rather than merges it, and hibernation gates on its length -- so an unmarked shed could sleep a live pane. fs.streamEnd rode the control lane because it must not be dropped, but that lane kills rather than drops. The stream's concurrency slot is now held until the terminal frame settles rather than until the fd closes, capping queued terminal frames well under the control budget; overflow costs one refused read instead of the connection. The watcher chunk walk now stops while producer retention sits past its reserve and degrades to a resync, so a 5000-event flood cannot fill the queue that interactive PTY traffic shares and stall every remote terminal. The reconnect ladder caps its flap-path delay so delay plus handshake timeout cannot cross the relay grace floor and let the remote daemon kill live PTYs. Also: the suppression key no longer embeds a NUL byte, which had made the file binary to git and grep; producerEnvelopeBudget no longer reports infinite capacity for a departed client; the drop logger no longer encodes a frame it will not log; and an over-capacity response substitution no longer settles as if the result had been delivered. * fix(relay,ssh): restore relay-shed status fields and scope backpressure per client Round 3 + 4 review fixes. Watcher chunking is now gated on the *client's* retention reserve rather than the dispatcher-wide one, so one stalled peer no longer forces a healthy client into a full file-tree resync. The relay-lost redeploy ladder no longer burns its 6-attempt budget while the SSH transport itself is down: it holds at the 15s step with a non-terminal status and rearms, so a laptop that slept past the ladder comes back instead of landing on a terminal "give up" banner. The shedFields wire marker had no consumer, so an agent-hook envelope whose subagent roster was dropped to fit the frame read as "roster cleared" on the Orca side: live child rows blanked and a done pane became hibernation-eligible while its teammates were still running. ingestRemote now restores shed fields from the cached payload (interactivePrompt deliberately excluded — a stale answerable question card is worse than none). Also: stream terminal-frame slots are counted per client, since the control queue they protect is per client; the chunking fast path no longer logs a drop for a batch it goes on to deliver in full; -32010 is now RelayErrorCode.ResponseOverCapacity. Test debt from the review: pending-pane eviction, per-client stream isolation, and the reconnect budget are now asserted rather than assumed; four fragile exact-byte pins dropped in favour of the tier comparisons that carry the requirement. * fix(relay,ssh): restore relay-shed status fields and scope backpressure - Oversized relay responses now fail their request instead of closing the connection, preventing one frame from killing every pane on the host - Restore subagent state for correct hibernation; don't resurrect stale prose across turns - Account for relay re-establishment and PTY reattach time in SSH flap delay caps - Only log drops of final unsendable envelopes, not temporary rejections during measurement probes - Fix watcher overflow marker release race when notification admission rejects without settlement; use precise byte counting for event batching * Restore relay-shed fields with digest validation and scoped backpressure Validate that shed subagent rosters match their wire digest and turn identity before restoration, preventing stale roster resurrection. Compact interactive prompts for waiting states instead of dropping them. Demote control-queue overflow to non-fatal rejection so clients can retry on capacity recovery, keeping the link alive during transient backpressure. * fix(relay): correct ResponseOverCapacity error code ResponseOverCapacity should use -33008 to stay in the -33xxx range for relay protocol errors, not -32010. * fix(relay): close client when pty.replay overflows control queue Replay is never retried, so it uses the control lane where overflow is fatal — the writer closes the client and reconnect reloads history rather than stranding a short buffer. * fix(relay): prevent infinite redeploy on flapping SSH transports Charge reconnect attempts when connection restores mid-backoff, preventing infinite loop on transports that flap between states. Refactor control overflow handling to use entry property instead of WeakSet marker for clarity. --------- Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 46 ++ src/main/agent-hooks/server.ts | 14 +- src/main/ipc/ssh.test.ts | 147 ++++ src/main/ipc/ssh.ts | 75 +- src/main/ssh/ssh-connection.test.ts | 226 +++++- src/main/ssh/ssh-connection.ts | 26 +- src/main/ssh/ssh-reconnect-ladder.test.ts | 191 ++++++ src/main/ssh/ssh-reconnect-ladder.ts | 78 +++ src/main/ssh/ssh-relay-session.ts | 3 + .../agent-hook-envelope-publication.test.ts | 642 ++++++++++++++++++ src/relay/agent-hook-envelope-publication.ts | 332 +++++++++ src/relay/agent-hook-integration.test.ts | 63 +- .../dispatcher-capacity-degradation.test.ts | 469 +++++++++++++ src/relay/dispatcher-client-writer.ts | 29 +- src/relay/dispatcher-writer-admission.ts | 11 +- src/relay/dispatcher.ts | 202 ++++-- src/relay/fs-handler-file-read.ts | 102 ++- src/relay/fs-handler-stream.test.ts | 170 ++++- src/relay/protocol.ts | 5 +- .../relay-filesystem-watch-registry.test.ts | 94 ++- src/relay/relay-filesystem-watch-registry.ts | 2 +- ...ay-oversized-notification-survival.test.ts | 270 ++++++++ src/relay/relay-watcher-event-emitter.test.ts | 631 ++++++++++++++++- src/relay/relay-watcher-event-emitter.ts | 206 +++++- .../relay-watcher-frame-chunking.test.ts | 54 +- src/relay/relay.ts | 11 +- src/relay/wsl-agent-hook-relay.ts | 8 +- src/shared/agent-hook-relay.test.ts | 78 +++ src/shared/agent-hook-relay.ts | 80 ++- .../tools/benchmarks/terminal-perf-bench.mjs | 3 +- 30 files changed, 4024 insertions(+), 244 deletions(-) create mode 100644 src/main/ssh/ssh-reconnect-ladder.test.ts create mode 100644 src/main/ssh/ssh-reconnect-ladder.ts create mode 100644 src/relay/agent-hook-envelope-publication.test.ts create mode 100644 src/relay/agent-hook-envelope-publication.ts create mode 100644 src/relay/dispatcher-capacity-degradation.test.ts create mode 100644 src/relay/relay-oversized-notification-survival.test.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index c8baaa6e69c..f9c0b46271f 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -31,6 +31,7 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import { makePaneKey } from '../../shared/stable-pane-id' +import { createShedSubagentsField } from '../../shared/agent-hook-relay' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -187,6 +188,51 @@ describe('AgentHookServer listener replay', () => { expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')]) }) + it('restores a subagent roster the relay shed to fit the frame', () => { + const server = new AgentHookServer() + const roster = [ + { id: 'reviewer-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 } + ] + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'review', agentType: 'claude', subagents: roster } + }, + 'conn-1' + ) + // The relay dropped the roster to fit an oversized frame and said so on the wire. + server.ingestRemote( + { + paneKey: PANE, + shedFields: ['lastAssistantMessage', createShedSubagentsField(roster)], + payload: { state: 'done', prompt: 'review', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', subagents: roster }) + }) + + it('lets an unmarked absent roster clear, so a finished team still retires', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review', + agentType: 'claude', + subagents: [{ id: 'reviewer-1', agentType: 'reviewer', state: 'working', startedAt: 1 }] + } + }, + 'conn-1' + ) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'done', prompt: 'review', agentType: 'claude' } }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }) + it('retains root Codex identity when relay child events omit it', () => { const server = new AgentHookServer() const providerSession = { key: 'session_id' as const, id: 'root-session' } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 8d5e57943b6..c8b0cb2f320 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -41,7 +41,7 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots } from '../../shared/claude-subagent-roster' -import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay' import { CLAUDE_STATUSLINE_PATHNAME, parseClaudeStatusLineBody, @@ -1718,6 +1718,8 @@ export class AgentHookServer { providerSession?: unknown providerSessionOnly?: unknown isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown payload: unknown }, connectionId: string @@ -1796,10 +1798,16 @@ export class AgentHookServer { : undefined const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!normalizedPayload) { + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { return } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + const normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) if ( envelope.providerSessionOnly === true && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 58b7b44637a..09b741076cc 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -246,6 +246,7 @@ import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-err import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshConnectionState, + type SshConnectionStatus, type SshTarget } from '../../shared/ssh-types' import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' @@ -1062,6 +1063,152 @@ describe('SSH IPC handlers', () => { } }) + describe('relay loss while the SSH transport is down', () => { + const relayLostTarget: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const transportState = (status: SshConnectionStatus): SshConnectionState => ({ + targetId: 'ssh-1', + status, + error: null, + reconnectAttempt: 0 + }) + const setTransportStatus = (status: SshConnectionStatus): void => { + mockConnectionManager.getState.mockReturnValue(transportState(status)) + } + const maxRelayDelayMs = relayReconnectDelaysMs.at(-1)! + const connectWithLiveTransport = async (): Promise => { + mockSshStore.getTarget.mockReturnValue(relayLostTarget) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getConnection.mockReturnValue({}) + setTransportStatus('connected') + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockDeployAndLaunchRelay.mockClear() + } + + it('does not consume attempts or publish the manual-reconnect banner', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + setTransportStatus('reconnecting') + getLatestRelayDisposeCallback()('connection_lost') + + // Well past the whole 6-step ladder: a redeploy cannot ride a dead transport, so nothing is spent. + const fullLadderMs = relayReconnectDelaysMs.reduce((sum, delay) => sum + delay, 0) + await vi.advanceTimersByTimeAsync(fullLadderMs + relayLostStabilizedMs) + + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel lost. Reconnecting...', + reconnectAttempt: 0, + providerEpoch: expect.any(String), + connectionGeneration: 2 + }) + + setTransportStatus('connected') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + expect( + (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status + ).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + + it('stops retrying once the transport reaches a terminal state', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + setTransportStatus('reconnecting') + getLatestRelayDisposeCallback()('connection_lost') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + + setTransportStatus('reconnection-failed') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + + // The wait loop is gone: only onStateChange's redeploy may revive the relay after this. + setTransportStatus('connected') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs * 4) + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('resets the relay budget when the connection disappears before retry', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + getLatestRelayDisposeCallback()('connection_lost') + + mockConnectionManager.getConnection.mockReturnValue(undefined) + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + + mockConnectionManager.getConnection.mockReturnValue({}) + getLatestRelayDisposeCallback()('connection_lost') + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual( + expect.objectContaining({ reconnectAttempt: 1 }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('still reaches the manual-reconnect banner when the transport is healthy', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) + getLatestRelayDisposeCallback()('connection_lost') + for (const delayMs of relayReconnectDelaysMs) { + await vi.advanceTimersByTimeAsync(delayMs) + } + + expect( + (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).error + ).toBe('Relay channel kept dropping. Click Reconnect on the SSH target before retrying.') + } finally { + vi.useRealTimers() + } + }) + + it('restores the full relay budget once the transport reconnects', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) + getLatestRelayDisposeCallback()('connection_lost') + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: SshConnectionState) => void + } + callbacks.onStateChange('ssh-1', transportState('reconnecting')) + callbacks.onStateChange('ssh-1', transportState('connected')) + await vi.advanceTimersByTimeAsync(0) + + // Budget reset: the next loss waits the base delay again instead of the third ladder step. + mockDeployAndLaunchRelay.mockClear() + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + }) + it('reuses a fast relay reconnect after the post-ready stabilization window', async () => { vi.useFakeTimers() vi.setSystemTime(0) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 7e1a54026df..0227b84f36a 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -295,6 +295,13 @@ const RELAY_LOST_BASE_DELAY_MS = 500 const RELAY_LOST_MAX_DELAY_MS = 15_000 // Why: a reconnect whose mux dies within this window was a flap, not a recovery — don't reset the attempt counter. 5s covers provider re-registration + PTY reattach. const RELAY_LOST_STABILIZED_MS = 5_000 +// Why: transport states the SSH ladder never leaves on its own — waiting for a relay redeploy past one of these is an unbounded loop. +const TRANSPORT_TERMINAL_STATUSES = new Set([ + 'disconnected', + 'auth-failed', + 'reconnection-failed', + 'error' +]) function clearRelayLostBackoff(targetId: string): void { const state = relayLostBackoff.get(targetId) @@ -615,6 +622,8 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { relayStateOverrides.has(targetId) if (shouldReconnectRelay) { + // Why: this branch redeploys the relay itself over a fresh transport, so any pending relay-lost retry is stale — dropping it also gives the new transport generation a full attempt budget. + clearRelayLostBackoff(targetId) // Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again. publishRelayOverride( getCurrentMainWindow, @@ -714,7 +723,13 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { return } rotateSshProviderAuthority(tid) - if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + + // Why: re-deploying the relay rides the SSH transport, so while the transport is itself down no attempt + // can succeed. Waiting at the max delay without consuming the budget keeps a flapping host off the + // manual-reconnect banner, which would tell the user to act on a link that is still auto-recovering. + const transportStatus = connectionManager?.getState(tid)?.status + const transportConnected = transportStatus === 'connected' + if (transportConnected && state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { console.warn( `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` ) @@ -729,6 +744,53 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { ) return } + + const scheduleRelayRedeploy = (delay: number, attemptCharged: boolean): void => { + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + clearRelayLostBackoff(tid) + return + } + const status = connectionManager?.getState(tid)?.status + if (status === 'connected') { + if (!attemptCharged) { + // Why: waiting is free, but the deploy it defers is real — charge it here so a transport that + // flaps back to 'connected' can't redeploy forever on an uncharged budget. + state.attempts += 1 + } + void s.reconnect(liveConn, relayGracePeriodForTarget(t)) + return + } + if (status === undefined || TRANSPORT_TERMINAL_STATUSES.has(status)) { + // Why: the transport gave up for good; its own state is what the user acts on, so stop waiting for a redeploy that can never run. + clearRelayLostBackoff(tid) + return + } + // Why: still mid-transition — re-arm at the max delay without consuming an attempt. It ends once + // the transport settles: 'connected' redeploys, a terminal status or a dropped session clears above. + scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) + }, delay) + relayLostBackoff.set(tid, state) + } + + if (!transportConnected) { + publishRelayOverride( + getCurrentMainWindow, + tid, + 'reconnecting', + 'Relay channel lost. Reconnecting...', + state.attempts + ) + scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) + console.warn( + `[ssh] Relay channel for ${tid} lost while the SSH transport is ${transportStatus ?? 'unknown'}; waiting ${RELAY_LOST_MAX_DELAY_MS}ms without consuming an attempt` + ) + return + } + const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) state.attempts += 1 publishRelayOverride( @@ -738,16 +800,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { 'Relay channel lost. Reconnecting...', state.attempts ) - state.reconnectTimer = setTimeout(() => { - state.reconnectTimer = null - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - return - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - }, delay) - relayLostBackoff.set(tid, state) + scheduleRelayRedeploy(delay, true) console.warn( `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` ) diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index a3bcadf66dc..db5287d2aef 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -12,6 +12,7 @@ let connectErrorMessage = '' let connectErrorCode = '' let destroyErrorMessage = '' let connectSequence: ('ready' | Error)[] = [] +let connectAttempts = 0 let execBehavior: 'callback' | 'pending' = 'callback' let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null let sftpBehavior: 'callback' | 'pending' = 'callback' @@ -34,6 +35,27 @@ function emitSshEvent(event: string, ...args: unknown[]): void { } } +function nextSshClientCreation(): Promise { + return new Promise((resolve) => { + notifyClientCreated = resolve + }) +} + +async function connectWithFakeTimers(conn: SshConnection): Promise { + const clientCreated = nextSshClientCreation() + const connected = conn.connect() + await clientCreated + await vi.advanceTimersByTimeAsync(1) + await connected +} + +async function advanceToNextSshClient(delayMs: number): Promise { + const clientCreated = nextSshClientCreation() + await vi.advanceTimersByTimeAsync(delayMs) + await clientCreated + await vi.advanceTimersByTimeAsync(1) +} + vi.mock('ssh2', () => { class MockBaseAgent {} class MockSshClient { @@ -62,6 +84,7 @@ vi.mock('ssh2', () => { } } connect(config?: unknown) { + connectAttempts += 1 this.lastConnectConfig = config const hostVerifier = (config as { hostVerifier?: (key: Buffer) => boolean } | undefined) ?.hostVerifier @@ -170,7 +193,8 @@ import { writeFileViaSystemSsh } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshTarget } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../shared/ssh-types' import { createOpenSshPrivateKeyFixture, createOpenSshPublicKeyFixture @@ -281,6 +305,7 @@ describe('SshConnection', () => { connectErrorCode = '' destroyErrorMessage = '' connectSequence = [] + connectAttempts = 0 execBehavior = 'callback' pendingExecCallback = null sftpBehavior = 'callback' @@ -420,6 +445,205 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('connected') }) + it('escalates the backoff across repeated post-handshake drops', async () => { + vi.useFakeTimers() + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + expect(clientInstances).toHaveLength(1) + + emitSshEvent('close') + await vi.advanceTimersByTimeAsync(999) + expect(clientInstances).toHaveLength(1) + await advanceToNextSshClient(1) + expect(clientInstances).toHaveLength(2) + + // Why: a single published counter pinned every post-handshake drop at the 1000ms head step. + emitSshEvent('close') + await vi.advanceTimersByTimeAsync(1999) + expect(clientInstances).toHaveLength(2) + await advanceToNextSshClient(1) + expect(clientInstances).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('publishes reconnectAttempt=0 on the reconnected state and the escalating step while reconnecting', async () => { + vi.useFakeTimers() + try { + const published: { status: string; reconnectAttempt: number }[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => + published.push({ status: state.status, reconnectAttempt: state.reconnectAttempt }) + ) + }) + ) + await connectWithFakeTimers(conn) + + for (let drop = 0; drop < 3; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(30_000) + } + + expect( + published.filter((e) => e.status === 'reconnecting').map((e) => e.reconnectAttempt) + ).toEqual([0, 1, 2]) + // src/main/ipc/ssh.ts gates the relay redeploy on reconnectAttempt === 0 at 'connected'. + expect( + published.filter((e) => e.status === 'connected').map((e) => e.reconnectAttempt) + ).toEqual([0, 0, 0, 0]) + } finally { + vi.useRealTimers() + } + }) + + it('restarts the ladder at the head when an explicit reconnect fails', async () => { + // Accepted delta: reset() puts the ladder at the head and the explicit attempt consumes no + // step, so the first failure publishes 0/1000ms where the single-counter version published 1/2000ms. + vi.useFakeTimers() + try { + const published: { status: string; reconnectAttempt: number }[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => + published.push({ status: state.status, reconnectAttempt: state.reconnectAttempt }) + ) + }) + ) + await connectWithFakeTimers(conn) + + connectBehavior = 'error' + connectErrorMessage = 'connect ETIMEDOUT 10.0.0.5:22' + connectErrorCode = 'ETIMEDOUT' + published.length = 0 + const clientCreated = nextSshClientCreation() + const reconnected = conn.reconnect() + await clientCreated + await vi.advanceTimersByTimeAsync(1) + await reconnected + + // Shipped published [0, 1] here; the ladder's reset() keeps the retry at the head instead. + expect( + published.filter((e) => e.status === 'reconnecting').map((e) => e.reconnectAttempt) + ).toEqual([0, 0]) + } finally { + vi.useRealTimers() + } + }) + + it('reaches reconnection-failed after 9 consecutive handshake failures', async () => { + vi.useFakeTimers() + try { + const statuses: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => statuses.push(state.status)) + }) + ) + await connectWithFakeTimers(conn) + + connectBehavior = 'error' + connectErrorMessage = 'connect ETIMEDOUT 10.0.0.5:22' + connectErrorCode = 'ETIMEDOUT' + emitSshEvent('close') + for (const delayMs of RECONNECT_BACKOFF_MS) { + await advanceToNextSshClient(delayMs) + } + + expect(statuses).toContain('reconnection-failed') + // Pin the budget itself: the initial success plus exactly RECONNECT_BACKOFF_MS.length retries. + // Counting a failure twice, or giving up early, would strand a user on a flaky link. + expect(connectAttempts).toBe(1 + RECONNECT_BACKOFF_MS.length) + } finally { + vi.useRealTimers() + } + }) + + it('retries a saturated flap streak while the remote relay is still in grace', async () => { + vi.useFakeTimers() + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + + // Saturate the delay ladder on flaps alone; 45s reconnects each drop while staying under + // STABLE_CONNECTION_MS, so the ladder never resets to the head. + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(45_000) + } + expect(conn.getState().status).toBe('connected') + + const before = clientInstances.length + emitSshEvent('close') + // The retry plus a worst-case handshake must land inside the shortest configurable relay grace, + // or the remote daemon shuts down and takes every PTY on that host with it. + await advanceToNextSshClient( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - CONNECT_TIMEOUT_MS - 1 + ) + expect(clientInstances.length).toBeGreaterThan(before) + } finally { + vi.useRealTimers() + } + }) + + it('logs the delay ladder position separately from the failure streak', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(45_000) + } + + const lastReconnectLog = warn.mock.calls + .map((call) => String(call[0])) + .findLast((line) => line.includes('Reconnecting to')) + // A saturated flap ladder must not read like the connection is one step from giving up. + expect(lastReconnectLog).toContain('delay step 9/9') + expect(lastReconnectLog).toContain('failed handshakes 0/9') + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps retrying when a flap streak is followed by one handshake failure', async () => { + vi.useFakeTimers() + try { + const statuses: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => statuses.push(state.status)) + }) + ) + await connectWithFakeTimers(conn) + + // 12 flaps saturate the delay ladder without ever touching the failure streak. + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(30_000) + } + connectSequence = [new Error('connect ETIMEDOUT 10.0.0.5:22')] + emitSshEvent('close') + await advanceToNextSshClient(30_000) + await advanceToNextSshClient(30_000) + + expect(statuses).not.toContain('reconnection-failed') + expect(conn.getState().status).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + it('transitions through connecting → connected states', async () => { const states: string[] = [] const callbacks = createCallbacks({ diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 37d661587ad..f183a704975 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -40,6 +40,7 @@ import { type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' +import { SshReconnectLadder } from './ssh-reconnect-ladder' import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import { requiresSystemSshForSecurityKey, @@ -126,6 +127,7 @@ export class SshConnection { private callbacks: SshConnectionCallbacks private target: SshTarget private reconnectTimer: ReturnType | null = null + private readonly reconnectLadder = new SshReconnectLadder() private disposed = false private cachedPassphrase: string | null = null private cachedPassword: string | null = null @@ -601,6 +603,8 @@ export class SshConnection { for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { try { await this.attemptConnect() + this.reconnectLadder.reset() + this.reconnectLadder.markConnected(Date.now()) return } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) @@ -852,8 +856,9 @@ export class SshConnection { // Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still connected; tear down and reconnect so the relay can reattach remote PTYs. this.closeTransportsForReconnect() this.state.reconnectAttempt = 0 + this.reconnectLadder.reset() this.setState('reconnecting') - await this.runReconnectAttempt(0) + await this.runReconnectAttempt() } private async doSystemSshProbe(connectGeneration: number): Promise { @@ -1239,26 +1244,32 @@ export class SshConnection { if (this.disposed || this.reconnectTimer) { return } - const attempt = this.state.reconnectAttempt - if (attempt >= RECONNECT_BACKOFF_MS.length) { + const decision = this.reconnectLadder.next(Date.now()) + if (decision.kind === 'give-up') { this.setState('reconnection-failed', 'Max reconnection attempts reached') return } + this.state.reconnectAttempt = decision.attemptIndex this.setState('reconnecting') + console.warn( + `[ssh] Reconnecting to ${this.target.label} in ${decision.delayMs}ms (delay step ${decision.attemptIndex + 1}/${RECONNECT_BACKOFF_MS.length}, failed handshakes ${this.reconnectLadder.failedAttemptStreak}/${RECONNECT_BACKOFF_MS.length})` + ) this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null if (this.disposed) { return } - await this.runReconnectAttempt(attempt) - }, RECONNECT_BACKOFF_MS[attempt]) + await this.runReconnectAttempt() + }, decision.delayMs) } - private async runReconnectAttempt(attempt: number): Promise { + private async runReconnectAttempt(): Promise { try { // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. this.state.reconnectAttempt = 0 await this.attemptConnect() + // Why: attemptConnect resolves only after a real handshake on either transport; the system-ssh probe's 'connected' must not clear the failure streak. + this.reconnectLadder.markConnected(Date.now()) } catch (err) { if (this.disposed) { return @@ -1272,7 +1283,7 @@ export class SshConnection { this.setState('error', error.message) return } - this.state.reconnectAttempt = attempt + 1 + this.reconnectLadder.markAttemptFailed() this.scheduleReconnect() } } @@ -1383,6 +1394,7 @@ export class SshConnection { this.systemSshControlMasterDisabledForSession = false this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false + this.reconnectLadder.reset() this.setState('disconnected') } diff --git a/src/main/ssh/ssh-reconnect-ladder.test.ts b/src/main/ssh/ssh-reconnect-ladder.test.ts new file mode 100644 index 00000000000..c43d4e9e6e9 --- /dev/null +++ b/src/main/ssh/ssh-reconnect-ladder.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { + FLAP_DELAY_CAP_MS, + RELAY_REESTABLISH_BUDGET_MS, + SshReconnectLadder, + STABLE_CONNECTION_MS +} from './ssh-reconnect-ladder' + +describe('SshReconnectLadder', () => { + it('climbs the table across consecutive post-handshake drops', () => { + const ladder = new SshReconnectLadder() + + ladder.markConnected(0) + expect(ladder.next(50)).toEqual({ kind: 'retry', delayMs: 1000, attemptIndex: 0 }) + ladder.markConnected(60) + expect(ladder.next(110)).toEqual({ kind: 'retry', delayMs: 2000, attemptIndex: 1 }) + + // Table steps past FLAP_DELAY_CAP_MS are clamped so the retry still fits inside the relay grace floor. + const rest = [5000, 5000, 10000, 10000, 10000, 30000, 30000] + let now = 200 + rest.forEach((delayMs, index) => { + ladder.markConnected(now) + now += 50 + expect(ladder.next(now)).toEqual({ + kind: 'retry', + delayMs: Math.min(delayMs, FLAP_DELAY_CAP_MS), + attemptIndex: index + 2 + }) + }) + + // Saturates at the last step instead of going terminal. + for (let i = 0; i < 3; i++) { + ladder.markConnected(now) + now += 50 + expect(ladder.next(now)).toEqual({ + kind: 'retry', + delayMs: Math.min(30000, FLAP_DELAY_CAP_MS), + attemptIndex: 8 + }) + } + }) + + it('caps every flap delay so handshake and relay re-establishment beat grace', () => { + const ladder = new SshReconnectLadder() + let now = 0 + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length + 4; i++) { + ladder.markConnected(now) + now += 50 + const decision = ladder.next(now) + expect(decision.kind).toBe('retry') + if (decision.kind === 'retry') { + expect(decision.delayMs + CONNECT_TIMEOUT_MS + RELAY_REESTABLISH_BUDGET_MS).toBeLessThan( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 + ) + now += decision.delayMs + } + } + expect(FLAP_DELAY_CAP_MS).toBe(5_000) + }) + + it('keeps the uncapped table for a host whose handshakes fail', () => { + const ladder = new SshReconnectLadder() + const delays: number[] = [] + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length - 1; i++) { + ladder.markAttemptFailed() + const decision = ladder.next(i * 1000) + if (decision.kind === 'retry') { + delays.push(decision.delayMs) + } + expect(ladder.failedAttemptStreak).toBe(i + 1) + } + + expect(delays).toEqual(RECONNECT_BACKOFF_MS.slice(0, -1)) + expect(delays.some((delayMs) => delayMs > FLAP_DELAY_CAP_MS)).toBe(true) + }) + + it('escalates a post-handshake drop identically to a handshake failure', () => { + const dropLadder = new SshReconnectLadder() + const failureLadder = new SshReconnectLadder() + const dropDelays: number[] = [] + const dropIndexes: number[] = [] + const failureIndexes: number[] = [] + + let now = 0 + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + dropLadder.markConnected(now) + now += 50 + const dropDecision = dropLadder.next(now) + const failureDecision = failureLadder.next(now) + expect(dropDecision.kind).toBe('retry') + expect(failureDecision.kind).toBe('retry') + if (dropDecision.kind === 'retry') { + dropDelays.push(dropDecision.delayMs) + dropIndexes.push(dropDecision.attemptIndex) + } + if (failureDecision.kind === 'retry') { + failureIndexes.push(failureDecision.attemptIndex) + } + failureLadder.markAttemptFailed() + now += 50 + } + + // Same ladder position; the flap path only clamps the wall-clock delay to the grace budget. + expect(dropIndexes).toEqual(failureIndexes) + expect(dropDelays).toEqual(RECONNECT_BACKOFF_MS.map((d) => Math.min(d, FLAP_DELAY_CAP_MS))) + }) + + it('never reaches give-up on a flap streak, even with a later handshake failure', () => { + const ladder = new SshReconnectLadder() + let now = 0 + for (let i = 0; i < 12; i++) { + ladder.markConnected(now) + now += 50 + const decision = ladder.next(now) + expect(decision.kind).toBe('retry') + if (decision.kind === 'retry') { + now += decision.delayMs + } + } + + ladder.markAttemptFailed() + + expect(ladder.next(now)).toEqual({ kind: 'retry', delayMs: 30000, attemptIndex: 8 }) + }) + + it('gives up only after RECONNECT_BACKOFF_MS.length consecutive failed attempts', () => { + const ladder = new SshReconnectLadder() + const decisions: string[] = [] + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + decisions.push(ladder.next(i * 1000).kind) + } + + expect(decisions.slice(0, RECONNECT_BACKOFF_MS.length - 1)).toEqual( + Array(RECONNECT_BACKOFF_MS.length - 1).fill('retry') + ) + expect(decisions.at(-1)).toBe('give-up') + }) + + it('resets the delay ladder exactly once for a stable connection', () => { + const ladder = new SshReconnectLadder() + for (let i = 0; i < 5; i++) { + ladder.next(i) + } + + ladder.markConnected(0) + expect(ladder.next(STABLE_CONNECTION_MS)).toEqual({ + kind: 'retry', + delayMs: 1000, + attemptIndex: 0 + }) + // The consumed timestamp must not reset the ladder again later in the same outage. + expect(ladder.next(STABLE_CONNECTION_MS * 4)).toEqual({ + kind: 'retry', + delayMs: 2000, + attemptIndex: 1 + }) + }) + + it('does not let a dead host self-reset past the give-up bound', () => { + const ladder = new SshReconnectLadder() + ladder.markConnected(0) + + let now = 0 + let last = ladder.next(now) + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + now += STABLE_CONNECTION_MS * 2 + last = ladder.next(now) + } + + expect(last).toEqual({ kind: 'give-up' }) + }) + + it('returns to the head of the ladder after reset()', () => { + const ladder = new SshReconnectLadder() + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + ladder.next(i) + } + + ladder.reset() + + expect(ladder.next(0)).toEqual({ kind: 'retry', delayMs: 1000, attemptIndex: 0 }) + }) +}) diff --git a/src/main/ssh/ssh-reconnect-ladder.ts b/src/main/ssh/ssh-reconnect-ladder.ts new file mode 100644 index 00000000000..bf03154be0e --- /dev/null +++ b/src/main/ssh/ssh-reconnect-ladder.ts @@ -0,0 +1,78 @@ +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' + +// A connection that held this long is treated as healthy, so the next drop restarts the delay ladder. +export const STABLE_CONNECTION_MS = 60_000 +// Existing-relay attach and one bounded PTY reattach attempt each allow 10s. +export const RELAY_REESTABLISH_BUDGET_MS = 20_000 + +/** + * Why: a flap leaves the remote relay in grace, and the shortest grace a target can configure is + * MIN_SSH_RELAY_GRACE_PERIOD_SECONDS. Retrying later than this cap would let even a full-length + * handshake plus relay/mux re-establishment finish after grace expires, so the relay would shut + * down and take every remote PTY with it. Raising the backoff past this bound changes nothing. + */ +export const FLAP_DELAY_CAP_MS = (() => { + const budgetMs = + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - CONNECT_TIMEOUT_MS - RELAY_REESTABLISH_BUDGET_MS + const fitting = RECONNECT_BACKOFF_MS.filter((delayMs) => delayMs < budgetMs) + return fitting.length > 0 ? Math.max(...fitting) : RECONNECT_BACKOFF_MS[0] +})() + +export type SshReconnectDecision = + | { kind: 'retry'; delayMs: number; attemptIndex: number } + | { kind: 'give-up' } + +/** + * Why two counters: `delayIndex` advances on every scheduled retry (flap or handshake failure) so a + * host that keeps dropping after a successful handshake still backs off, while + * `consecutiveFailedAttempts` advances only on a failed connect attempt so 'reconnection-failed' + * stays reachable exactly after RECONNECT_BACKOFF_MS.length failed handshakes — never after flaps. + */ +export class SshReconnectLadder { + private delayIndex = 0 + private consecutiveFailedAttempts = 0 + private connectedAtMs: number | null = null + + next(nowMs: number): SshReconnectDecision { + const connectedAt = this.connectedAtMs + // Why: consume it — one reset per connection, never a rolling reset mid-outage (the table sums past the window). + this.connectedAtMs = null + if (connectedAt !== null && nowMs - connectedAt >= STABLE_CONNECTION_MS) { + this.delayIndex = 0 + } + if (this.consecutiveFailedAttempts >= RECONNECT_BACKOFF_MS.length) { + return { kind: 'give-up' } + } + const attemptIndex = Math.min(this.delayIndex, RECONNECT_BACKOFF_MS.length - 1) + this.delayIndex = attemptIndex + 1 + const tableDelayMs = RECONNECT_BACKOFF_MS[attemptIndex] + // Why: only a flap (no failed handshake) means the remote relay is alive and burning grace; a dead + // host keeps the full table because nothing over there is waiting for us. + const delayMs = + this.consecutiveFailedAttempts === 0 + ? Math.min(tableDelayMs, FLAP_DELAY_CAP_MS) + : tableDelayMs + return { kind: 'retry', delayMs, attemptIndex } + } + + get failedAttemptStreak(): number { + return this.consecutiveFailedAttempts + } + + markConnected(nowMs: number): void { + this.consecutiveFailedAttempts = 0 + this.connectedAtMs = nowMs + } + + markAttemptFailed(): void { + this.consecutiveFailedAttempts += 1 + this.connectedAtMs = null + } + + reset(): void { + this.delayIndex = 0 + this.consecutiveFailedAttempts = 0 + this.connectedAtMs = null + } +} diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 5edce4306a6..a3be2082c0c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1138,6 +1138,7 @@ export class SshRelaySession { isReplay?: unknown providerSession?: unknown providerSessionOnly?: unknown + shedFields?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -1166,6 +1167,8 @@ export class SshRelaySession { isReplay: envelope.isReplay === true ? true : undefined, providerSession: envelope.providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. + shedFields: envelope.shedFields, payload: envelope.payload }, this.targetId diff --git a/src/relay/agent-hook-envelope-publication.test.ts b/src/relay/agent-hook-envelope-publication.test.ts new file mode 100644 index 00000000000..164c36f9c7a --- /dev/null +++ b/src/relay/agent-hook-envelope-publication.test.ts @@ -0,0 +1,642 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { RelayDispatcher, type RelayClientSinkOptions } from './dispatcher' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + createShedSubagentsField +} from '../shared/agent-hook-relay' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import type { AgentSubagentSnapshot } from '../shared/agent-status-types' + +type BoundedClient = { + frames: Buffer[] + closes: number + options: RelayClientSinkOptions + write: (data: Buffer) => boolean + /** Mimics a socket whose kernel buffer is full: writes are accepted but never settle. */ + blocked: boolean + drain: () => void +} + +function makeBoundedClient(highWaterMark: number): BoundedClient { + const drainListeners = new Set<() => void>() + const client: BoundedClient = { + frames: [], + closes: 0, + blocked: false, + write: (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return !client.blocked + }, + drain: () => { + for (const listener of Array.from(drainListeners)) { + drainListeners.delete(listener) + listener() + } + }, + options: { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + waitWriteDrain: (callback: () => void) => { + drainListeners.add(callback) + return () => drainListeners.delete(callback) + }, + close: () => { + client.closes++ + } + } + } + return client +} + +/** Fills the producer queue so the next publish is rejected for backpressure, not for frame size. */ +function saturateProducerQueue(dispatcher: RelayDispatcher, client: BoundedClient): void { + client.blocked = true + // Shrinking chunks close the headroom a rejected 8 KB frame leaves behind, so even a small + // hook envelope is refused. + for (const size of [8_000, 512, 16]) { + const data = 'x'.repeat(size) + let filled = false + for (let attempt = 0; attempt < 5_000 && !filled; attempt++) { + filled = !dispatcher.tryNotifyPtyData({ id: 'pty-1', data }) + } + if (!filled) { + throw new Error('producer queue never filled') + } + } +} + +function decodePayload(frame: Buffer): Record { + const length = frame.readUInt32BE(9) + return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) +} + +/** `shedFields` is relay-emitted wire metadata; the typed envelope does not declare it yet. */ +type PublishedEnvelope = AgentHookRelayEnvelope & { shedFields?: string[] } + +function decodeEnvelopes(client: BoundedClient): PublishedEnvelope[] { + // Keepalive frames carry no payload; only regular frames decode as JSON-RPC. + return client.frames + .filter((frame) => frame.readUInt32BE(9) > 0) + .map((frame) => decodePayload(frame)) + .filter((msg) => msg.method === AGENT_HOOK_NOTIFICATION_METHOD) + .map((msg) => msg.params as unknown as PublishedEnvelope) +} + +function makeSubagents(count: number): AgentSubagentSnapshot[] { + return Array.from({ length: count }, (_, index) => ({ + id: `subagent-${index}-${'s'.repeat(40)}`, + agentType: 'general-purpose', + model: 'claude-opus', + description: 'd'.repeat(200), + state: 'working' as const, + startedAt: 1_700_000_000_000 + index + })) +} + +function makeEnvelope(sizes: { + lastAssistantMessage?: number + interactivePrompt?: number + subagents?: number +}): AgentHookRelayEnvelope { + return { + source: 'claude', + paneKey: 'tab-1:4f1b0f4e-0000-4000-8000-000000000001', + connectionId: null, + worktreeId: 'worktree-1', + payload: { + state: 'working', + prompt: 'p'.repeat(64), + agentType: 'claude', + model: 'claude-opus', + toolName: 'Bash', + toolInput: 'echo hi', + ...(sizes.lastAssistantMessage !== undefined + ? { lastAssistantMessage: 'a'.repeat(sizes.lastAssistantMessage) } + : {}), + ...(sizes.interactivePrompt !== undefined + ? { interactivePrompt: 'q'.repeat(sizes.interactivePrompt) } + : {}), + ...(sizes.subagents !== undefined ? { subagents: makeSubagents(sizes.subagents) } : {}) + } + } +} + +describe('publishAgentHookEnvelope', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('publishes an in-capacity envelope untouched', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ lastAssistantMessage: 128, interactivePrompt: 64 }) + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0]).toEqual(envelope) + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('sheds lastAssistantMessage first and stops as soon as the frame fits', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // Only lastAssistantMessage pushes this past the 12288-byte producer cap. + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 20_000, interactivePrompt: 128, subagents: 2 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(128)) + expect(published[0].payload.subagents).toHaveLength(2) + expect(published[0].payload.state).toBe('working') + expect(published[0].paneKey).toBe('tab-1:4f1b0f4e-0000-4000-8000-000000000001') + expect(published[0].connectionId).toBeNull() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('logs only when the final single-sink envelope is actually dropped', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + expect(decodeEnvelopes(primary)).toHaveLength(1) + expect(stderr).not.toHaveBeenCalled() + + const unsendable = makeEnvelope({}) + unsendable.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, unsendable) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('sheds subagents next when dropping the assistant message is not enough', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 8_000, interactivePrompt: 6_000, subagents: 40 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(6_000)) + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps the blocking question card when a waiting envelope has to shed', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // A blocked pane is only answerable from web/mobile if interactivePrompt survives the ladder. + const envelope = makeEnvelope({ + lastAssistantMessage: 8_000, + interactivePrompt: 4_000, + subagents: 40 + }) + envelope.payload.state = 'waiting' + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('waiting') + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(4_000)) + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('sheds interactivePrompt last and still delivers the status', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 6_000, interactivePrompt: 13_000, subagents: 2 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBeUndefined() + expect(published[0].payload.state).toBe('working') + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('shrinks an oversized waiting card instead of publishing waiting without it', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const envelope = makeEnvelope({ + lastAssistantMessage: 6_000, + subagents: 2 + }) + envelope.payload.state = 'waiting' + envelope.payload.interactivePrompt = JSON.stringify({ + questions: [ + { + question: 'q'.repeat(13_000), + options: [{ label: 'Keep' }, { label: 'Discard' }] + } + ] + }) + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('waiting') + const card = JSON.parse(published[0].payload.interactivePrompt!) as { + questions: { question: string; options: { label: string }[] }[] + } + expect(card.questions[0].question.length).toBeLessThan(13_000) + expect(card.questions[0].options).toEqual([{ label: 'Keep' }, { label: 'Discard' }]) + expect(stderr).not.toHaveBeenCalled() + expect(primary.closes).toBe(0) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('drops an oversized waiting card when preserving its answer labels cannot fit', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const envelope = makeEnvelope({}) + envelope.payload.state = 'waiting' + envelope.payload.interactivePrompt = JSON.stringify({ + questions: [{ options: [{ label: 'answer'.repeat(3_000) }] }] + }) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + expect(primary.closes).toBe(0) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('never probes the budget for a single sink that accepts the frame', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const probe = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + try { + // One sink writes nothing when it rejects, so the publish attempt is itself the measurement. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 128 })) + expect(probe).not.toHaveBeenCalled() + + // Two of the three ladder steps are absent here; skipping them must not cost an extra encode. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ interactivePrompt: 20_000 })) + expect(probe).toHaveBeenCalledTimes(1) + expect(decodeEnvelopes(primary)).toHaveLength(2) + } finally { + probe.mockRestore() + dispatcher.dispose() + } + }) + + it('measures a fan-out up front so both sinks get the same payload', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + const probe = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + try { + dispatcher.attachClient(secondary.write, secondary.options) + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + // Probing first is what keeps the larger sink from being written a frame we then have to shed. + expect(probe).toHaveBeenCalledTimes(2) + for (const client of [primary, secondary]) { + expect(decodeEnvelopes(client)).toHaveLength(1) + } + } finally { + probe.mockRestore() + dispatcher.dispose() + } + }) + + it('skips absent fields instead of consuming a shed step on them', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // No lastAssistantMessage at all: the ladder must move on to interactivePrompt. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ interactivePrompt: 20_000 })) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.interactivePrompt).toBeUndefined() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('publishes without closing the client when even the fully shed envelope is oversized', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({}) + envelope.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(primary.closes).toBe(0) + + // The sink still accepts the next in-capacity frame. + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) + + it('does not mutate the caller envelope, so the hook-server replay cache stays intact', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ + lastAssistantMessage: 6_000, + interactivePrompt: 6_000, + subagents: 40 + }) + const before = structuredClone(envelope) + + publishAgentHookEnvelope(dispatcher, envelope) + + expect(envelope).toEqual(before) + expect(envelope.payload.lastAssistantMessage).toBe('a'.repeat(6_000)) + expect(envelope.payload.subagents).toHaveLength(40) + } finally { + dispatcher.dispose() + } + }) + + it('sheds to the smallest attached sink so a replay reaches every client', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + try { + dispatcher.attachClient(secondary.write, secondary.options) + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + + for (const client of [primary, secondary]) { + const published = decodeEnvelopes(client) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(client.closes).toBe(0) + } + } finally { + dispatcher.dispose() + } + }) + + it('logs one multi-sink drop when the fully shed envelope remains unsendable', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + dispatcher.attachClient(secondary.write, secondary.options) + const envelope = makeEnvelope({}) + envelope.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, envelope) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(decodeEnvelopes(secondary)).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) +}) + +describe('publishAgentHookEnvelope shed marker', () => { + it('names every shed field so a consumer can tell a shed roster from an absent one', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 8_000, interactivePrompt: 6_000, subagents: 40 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + // Without this marker the renderer overwrites a populated roster with undefined, blanking live + // subagent rows and unblocking hibernation for a pane that is still working. + expect(published[0].shedFields).toEqual([ + 'lastAssistantMessage', + createShedSubagentsField(makeSubagents(40)) + ]) + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(6_000)) + } finally { + dispatcher.dispose() + } + }) + + it('omits the marker when nothing was shed and never stamps the caller envelope', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ lastAssistantMessage: 128, subagents: 2 }) + publishAgentHookEnvelope(dispatcher, envelope) + expect(decodeEnvelopes(primary)[0].shedFields).toBeUndefined() + + const shedding = makeEnvelope({ lastAssistantMessage: 20_000, subagents: 2 }) + publishAgentHookEnvelope(dispatcher, shedding) + // The hook server replays this exact object, so the marker must live on the wire copy only. + expect((shedding as PublishedEnvelope).shedFields).toBeUndefined() + expect(shedding.payload.lastAssistantMessage).toBe('a'.repeat(20_000)) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('publishAgentHookEnvelope redelivery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('redelivers a backpressure-rejected envelope once the producer queue drains', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + const envelope = makeEnvelope({}) + envelope.payload.state = 'done' + publishAgentHookEnvelope(dispatcher, envelope) + expect(decodeEnvelopes(primary)).toHaveLength(0) + + primary.blocked = false + primary.drain() + // Nothing in the pipeline republishes a hook envelope: it is fire-and-forget, and the only + // other delivery path is the reattach replay. + expect(decodeEnvelopes(primary)).toHaveLength(0) + + vi.advanceTimersByTime(250) + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('done') + expect(published[0].paneKey).toBe('tab-1:4f1b0f4e-0000-4000-8000-000000000001') + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps only the newest snapshot per pane instead of queueing every drop', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + for (const state of ['working', 'waiting', 'done'] as const) { + const envelope = makeEnvelope({}) + envelope.payload.state = state + publishAgentHookEnvelope(dispatcher, envelope) + } + + primary.blocked = false + primary.drain() + vi.advanceTimersByTime(1_000) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('done') + } finally { + dispatcher.dispose() + } + }) + + it('makes one final retry when capacity returns after the timer budget is exhausted', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + expect(vi.getTimerCount()).toBeGreaterThan(idleTimers) + + vi.advanceTimersByTime(250 * 41) + expect(vi.getTimerCount()).toBe(idleTimers) + + primary.blocked = false + primary.drain() + expect(decodeEnvelopes(primary)).toHaveLength(1) + + primary.drain() + vi.advanceTimersByTime(10_000) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) + + it('evicts the longest-idle pane once the pending map is full, keeping the newest', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + // 65 panes against a 64-pane cap: the first must be evicted, the last must survive. + for (let pane = 0; pane < 65; pane++) { + const envelope = makeEnvelope({}) + envelope.paneKey = `tab-1:4f1b0f4e-0000-4000-8000-${String(pane).padStart(12, '0')}` + publishAgentHookEnvelope(dispatcher, envelope) + } + + primary.blocked = false + primary.drain() + vi.advanceTimersByTime(1_000) + + const redelivered = decodeEnvelopes(primary).map((envelope) => envelope.paneKey) + expect(redelivered).toHaveLength(64) + expect(redelivered).toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000064') + expect(redelivered).not.toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000000') + } finally { + dispatcher.dispose() + } + }) + + it('drops pending redeliveries when the dispatcher is disposed', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + + dispatcher.dispose() + primary.blocked = false + vi.advanceTimersByTime(10_000) + + expect(vi.getTimerCount()).toBeLessThanOrEqual(idleTimers) + expect(decodeEnvelopes(primary)).toHaveLength(0) + }) + + it('cancels the retry when a later snapshot for the same pane gets through', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + + primary.blocked = false + primary.drain() + const accepted = makeEnvelope({}) + accepted.payload.state = 'done' + publishAgentHookEnvelope(dispatcher, accepted) + expect(vi.getTimerCount()).toBe(idleTimers) + + vi.advanceTimersByTime(10_000) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/agent-hook-envelope-publication.ts b/src/relay/agent-hook-envelope-publication.ts new file mode 100644 index 00000000000..96a413a02c7 --- /dev/null +++ b/src/relay/agent-hook-envelope-publication.ts @@ -0,0 +1,332 @@ +import { + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_SHED_FIELDS_KEY, + createShedSubagentsField, + type AgentHookRelayEnvelope +} from '../shared/agent-hook-relay' +import type { RelayDispatcher } from './dispatcher' + +// Why: shed the biggest, most reconstructible fields first — state/paneKey must survive or the pane +// stays stuck on its last spinner, and an over-capacity frame is now dropped rather than sent. +// Why: interactivePrompt is last on purpose — a blocking question is load-bearing (without its card +// the pane sits on `waiting` with no way to answer from web or mobile). The roster is shed earlier +// only because it is cheaper to rebuild, NOT because it is cosmetic: a missing roster blanks live +// subagent rows and unblocks pane hibernation, which is why shed fields are named on the wire — +// `restoreShedStatusFields` re-attaches the restorable ones from Orca's cached payload. +const SHED_ORDER = ['lastAssistantMessage', 'subagents', 'interactivePrompt'] as const + +// Why: these envelopes are fire-and-forget state snapshots — no ack, no producer-side retry — and +// the only other delivery path is the reattach replay. A queue-full drop would otherwise leave the +// pane on a stale spinner until the SSH link cycles, so a rejected snapshot is retried briefly. +const REDELIVERY_INTERVAL_MS = 250 +const MAX_REDELIVERY_ATTEMPTS = 40 +// Why: a wedged link must not pin snapshots for panes that are already gone; recency-evict the rest. +const MAX_PENDING_PANES = 64 + +type PendingEnvelope = { + params: Record + clientIds: readonly number[] + /** Zero means the timer budget is exhausted and one capacity-driven attempt remains. */ + attemptsLeft: number +} + +type RedeliveryState = { + pending: Map + timer: ReturnType | null +} + +const redeliveryByDispatcher = new WeakMap() +// Why: some agents submit option labels verbatim, so only presentation-only fields may shrink. +const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ + 'description', + 'detail', + 'header', + 'question', + 'summary' +]) + +function fitsProducerFrame(dispatcher: RelayDispatcher, params: Record): boolean { + return dispatcher.producerEnvelopeBudget(AGENT_HOOK_NOTIFICATION_METHOD, params) >= 0 +} + +function toNotificationParams( + envelope: AgentHookRelayEnvelope, + shedFields: readonly string[] +): Record { + const params = envelope as unknown as Record + return shedFields.length === 0 + ? params + : { ...params, [AGENT_HOOK_SHED_FIELDS_KEY]: [...shedFields] } +} + +function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown { + if (Array.isArray(value)) { + return value.map((item) => compactInteractivePromptValue(item, maxStringLength)) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) + ? item.slice(0, maxStringLength) + : compactInteractivePromptValue(item, maxStringLength) + ]) + ) +} + +function fitWaitingInteractivePrompt( + dispatcher: RelayDispatcher, + envelope: AgentHookRelayEnvelope, + shedFields: readonly string[] +): AgentHookRelayEnvelope | null { + const prompt = envelope.payload.interactivePrompt + if (!prompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(prompt) + } catch { + parsed = undefined + } + const promptAtLimit = (limit: number): string => + parsed === undefined + ? prompt.slice(0, limit) + : JSON.stringify(compactInteractivePromptValue(parsed, limit)) + let low = 1 + let high = prompt.length + let fitted: AgentHookRelayEnvelope | null = null + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const candidate = { + ...envelope, + payload: { ...envelope.payload, interactivePrompt: promptAtLimit(mid) } + } + if (fitsProducerFrame(dispatcher, toNotificationParams(candidate, shedFields))) { + fitted = candidate + low = mid + 1 + } else { + high = mid - 1 + } + } + return fitted +} + +/** Returns the ids of the clients that rejected the frame; a rejected client is never written to. */ +function publishToClients( + dispatcher: RelayDispatcher, + params: Record, + clientIds: readonly number[], + logDrop?: boolean +): number[] { + const rejected: number[] = [] + for (const clientId of clientIds) { + if ( + !dispatcher.publishProducerNotification( + clientId, + AGENT_HOOK_NOTIFICATION_METHOD, + params, + logDrop === undefined ? undefined : { logDrop } + ) + ) { + rejected.push(clientId) + } + } + return rejected +} + +function logUnsendableEnvelope( + dispatcher: RelayDispatcher, + params: Record, + clientIds: readonly number[] +): void { + const rejectedClientId = clientIds.find( + (clientId) => + dispatcher.producerEnvelopeBudget(AGENT_HOOK_NOTIFICATION_METHOD, params, clientId) < 0 + ) + if (rejectedClientId !== undefined) { + publishToClients(dispatcher, params, [rejectedClientId], true) + } +} + +/** Publishes an agent-hook envelope, dropping optional detail fields until the frame fits the + * smallest attached sink. A frame the sink queue merely has no room for is retried in the + * background; one that no shedding can fit is dropped. */ +export function publishAgentHookEnvelope( + dispatcher: RelayDispatcher, + envelope: AgentHookRelayEnvelope +): void { + const clientIds = dispatcher.activeClientIds() + if (clientIds.length === 0) { + return + } + // Why: a fan-out must choose one payload before it writes anything, or the first client keeps a + // frame a smaller later client forces us to shed. A single sink writes nothing when it rejects, + // so there the attempt itself is the measurement — one encode instead of a probe plus a publish. + const measureBeforePublish = clientIds.length > 1 + let candidate = envelope + const shedFields: string[] = [] + let step = 0 + for (;;) { + const params = toNotificationParams(candidate, shedFields) + while (step < SHED_ORDER.length && candidate.payload[SHED_ORDER[step]] === undefined) { + step += 1 + } + if (!measureBeforePublish || fitsProducerFrame(dispatcher, params)) { + // A rejected intermediate probe is not a drop if a smaller envelope is delivered next. + const rejected = publishToClients( + dispatcher, + params, + clientIds, + measureBeforePublish ? undefined : step >= SHED_ORDER.length + ) + if (rejected.length === 0) { + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + // Rejection is ambiguous: an over-capacity frame must shed, a full producer queue must wait. + if (measureBeforePublish || fitsProducerFrame(dispatcher, params)) { + setPendingEnvelope(dispatcher, envelope.paneKey, params, rejected) + return + } + } + if (step >= SHED_ORDER.length) { + // Nothing left to shed and it still does not fit: waiting cannot make it sendable, and this + // snapshot supersedes any pending one, which is now stale. + if (measureBeforePublish) { + logUnsendableEnvelope(dispatcher, params, clientIds) + } + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + const field = SHED_ORDER[step] + step += 1 + if (field === 'interactivePrompt' && candidate.payload.state === 'waiting') { + const fitted = fitWaitingInteractivePrompt(dispatcher, candidate, shedFields) + if (fitted) { + candidate = fitted + continue + } + // A waiting snapshot without an answerable card must not reach web/mobile. + logUnsendableEnvelope(dispatcher, params, clientIds) + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + // Why: the hook server caches envelopes and replays them after --connect, so shedding in place + // would permanently strip the cached copy too. + candidate = { ...candidate, payload: { ...candidate.payload } } + const shedField = + field === 'subagents' ? createShedSubagentsField(candidate.payload.subagents ?? []) : field + delete candidate.payload[field] + shedFields.push(shedField) + } +} + +function setPendingEnvelope( + dispatcher: RelayDispatcher, + paneKey: string, + params: Record, + clientIds: readonly number[] +): void { + let state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + state = { pending: new Map(), timer: null } + redeliveryByDispatcher.set(dispatcher, state) + // A disposed dispatcher can never accept the retry, and pending params pin the cached payload. + dispatcher.onDisposed(() => stopRedelivery(dispatcher)) + dispatcher.onLegacyPtyCapacity(() => runFinalCapacityPass(dispatcher)) + } + // Latest-wins: one snapshot per pane, never a growing queue. Delete-then-set keeps Map insertion + // order = recency so the cap below evicts the longest-idle pane. + state.pending.delete(paneKey) + state.pending.set(paneKey, { params, clientIds, attemptsLeft: MAX_REDELIVERY_ATTEMPTS }) + while (state.pending.size > MAX_PENDING_PANES) { + state.pending.delete(state.pending.keys().next().value as string) + } + if (!state.timer) { + const timer = setInterval(() => runRedeliveryPass(dispatcher), REDELIVERY_INTERVAL_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + state.timer = timer + } +} + +function clearPendingEnvelope(dispatcher: RelayDispatcher, paneKey: string): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state?.pending.delete(paneKey) || state.pending.size > 0) { + return + } + stopRedelivery(dispatcher) +} + +function stopRedelivery(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + state.pending.clear() + if (state.timer) { + clearInterval(state.timer) + state.timer = null + } +} + +function stopRedeliveryTimer(state: RedeliveryState): void { + if (state.timer) { + clearInterval(state.timer) + state.timer = null + } +} + +function runRedeliveryPass(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + const active = new Set(dispatcher.activeClientIds()) + for (const [paneKey, pending] of Array.from(state.pending)) { + if (pending.attemptsLeft <= 0) { + continue + } + // A client that detached gets caught up by the reattach replay, so stop tracking it. + const targets = pending.clientIds.filter((clientId) => active.has(clientId)) + const rejected = + targets.length === 0 ? [] : publishToClients(dispatcher, pending.params, targets) + pending.attemptsLeft -= 1 + if (rejected.length === 0) { + state.pending.delete(paneKey) + continue + } + pending.clientIds = rejected + } + if (state.pending.size === 0) { + stopRedelivery(dispatcher) + } else if (Array.from(state.pending.values()).every((pending) => pending.attemptsLeft <= 0)) { + stopRedeliveryTimer(state) + } +} + +function runFinalCapacityPass(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + const active = new Set(dispatcher.activeClientIds()) + for (const [paneKey, pending] of Array.from(state.pending)) { + if (pending.attemptsLeft > 0) { + continue + } + // Delete before publishing because a synchronous sink settlement emits capacity reentrantly. + state.pending.delete(paneKey) + const targets = pending.clientIds.filter((clientId) => active.has(clientId)) + if (targets.length > 0) { + publishToClients(dispatcher, pending.params, targets) + } + } + if (state.pending.size === 0) { + stopRedelivery(dispatcher) + } +} diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index 9e3df40a930..3b941e6baee 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -26,7 +26,9 @@ import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH } from '../shared/agent-status-types' import { AgentHookServer } from '../main/agent-hooks/server' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' const LEAF_7 = '77777777-7777-4777-8777-777777777777' const LEAF_9 = '99999999-9999-4999-8999-999999999999' @@ -57,22 +59,25 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot onClose: () => {} } - dispatcher = new RelayDispatcher((data: Buffer) => { - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) + dispatcher = new RelayDispatcher( + (data: Buffer) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }, + { + writableHighWaterMark: () => 4096, + writableLength: () => 0 + } + ) relayFeedFn = (data: Buffer) => dispatcher.feed(data) hookServer = new RelayAgentHookServer({ endpointDir: tmpDir, forward: (envelope) => { - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) + publishAgentHookEnvelope(dispatcher, envelope) } }) await hookServer.start() @@ -157,6 +162,42 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot expect(payload.agentType).toBe('claude') }) + it('sheds an oversized assistant message through the production publication path', async () => { + const events: { payload: { state: string; lastAssistantMessage?: string } }[] = [] + orcaServer.setListener((event) => { + events.push({ payload: event.payload }) + }) + const { port, token } = hookServer.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ paneKey: `tab-7:${LEAF_7}`, payload }) + }) + + await expect( + post({ hook_event_name: 'UserPromptSubmit', prompt: 'bounded publication' }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + post({ + hook_event_name: 'Stop', + last_assistant_message: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) + }) + ).resolves.toMatchObject({ status: 204 }) + + const start = Date.now() + while (events.length < 2 && Date.now() - start < 1500) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(events).toHaveLength(2) + expect(events[1].payload.state).toBe('done') + expect(events[1].payload.lastAssistantMessage).toBeUndefined() + expect(dispatcher.activeClientIds()).toHaveLength(1) + }) + it('clears remote Claude permission when the approved tool starts with matching identity', async () => { const { port, token } = hookServer.getCoordinates() const postClaude = async (payload: Record): Promise => diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts new file mode 100644 index 00000000000..a546989d5df --- /dev/null +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + RelayDispatcher, + type RelayClientSinkOptions, + type SinkWriteSettlement +} from './dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_FRAMES } from './dispatcher-writer-admission' +import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-ledger' +import type * as ProtocolModule from './protocol' +import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' + +// Counts every frame encode (including the estimate-only ones) so a redundant re-encode is observable. +const encodeCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('./protocol', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + encodeJsonRpcFrame: (...args: Parameters) => { + encodeCalls.count++ + return actual.encodeJsonRpcFrame(...args) + } + } +}) + +type BoundedClient = { + frames: Buffer[] + closes: number + options: RelayClientSinkOptions + write: (data: Buffer) => boolean +} + +// Why: a sink that never accepts a write and never drains retains producer bytes, so the queue saturates +// while every individual frame stays comfortably inside the per-frame capacity. +function makeSaturatingClient(highWaterMark: number): BoundedClient { + const client = makeBoundedClient(highWaterMark) + client.write = (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return false + } + return client +} + +function makeBoundedClient(highWaterMark: number): BoundedClient { + const client: BoundedClient = { + frames: [], + closes: 0, + write: (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return true + }, + options: { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + close: () => { + client.closes++ + } + } + } + return client +} + +function decodePayload(frame: Buffer): Record { + const length = frame.readUInt32BE(9) + return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) +} + +describe('RelayDispatcher bounded-capacity degradation', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('producerEnvelopeBudget returns a negative budget for an over-cap envelope', () => { + const primary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + expect( + bounded.producerEnvelopeBudget('fs.changed', { events: ['x'.repeat(64)] }) + ).toBeGreaterThan(0) + expect( + bounded.producerEnvelopeBudget('fs.changed', { events: ['x'.repeat(20_000)] }) + ).toBeLessThan(0) + } finally { + bounded.dispose() + } + }) + + it('producerEnvelopeBudget takes the smallest client capacity unless a client is named', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const params = { events: ['x'.repeat(64)] } + const envelopeBytes = encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'fs.changed', params }, + 0, + 0 + ).length + + expect(bounded.activeClientIds()).toHaveLength(2) + expect(bounded.producerEnvelopeBudget('fs.changed', params)).toBe(12288 - envelopeBytes) + expect( + bounded.producerEnvelopeBudget('fs.changed', params, bounded.activeClientIds()[0]) + ).toBe(49152 - envelopeBytes) + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBe( + 12288 - envelopeBytes + ) + expect(bounded.producerEnvelopeBudget('fs.changed', params, 999)).toBe( + Number.MIN_SAFE_INTEGER + ) + } finally { + bounded.dispose() + } + }) + + it('producerEnvelopeBudget reports no capacity for a named client that is gone', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const params = { events: ['x'.repeat(64)] } + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBeGreaterThan(0) + + // A client that detached between activeClientIds() and publication must never look like it fits. + bounded.detachClient(secondaryId) + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBeLessThan(0) + expect(bounded.producerEnvelopeBudget('fs.changed', params, 999)).toBeLessThan(0) + // The no-target broadcast case still reports unbounded room. + expect(bounded.producerEnvelopeBudget('fs.changed', params)).toBeGreaterThan(0) + } finally { + bounded.dispose() + } + }) + + it('producerDataBudget keeps its pre-delegation value for a pty.data envelope', () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + // Snapshot of the shipped formula: 49152-byte producer capacity minus the 96-byte empty-data frame. + expect(bounded.producerDataBudget('pty.data', { ptyId: 'pty-7', seq: 42 })).toBe(49056) + // A vanished client leaves no room for data at all. + expect(bounded.producerDataBudget('pty.data', { ptyId: 'pty-7', seq: 42 }, 999)).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('producerDataBudget floors at zero when the envelope alone exceeds capacity', () => { + const primary = makeBoundedClient(1030) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + expect(bounded.producerDataBudget('pty.data', { ptyId: 'x'.repeat(4096) })).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('notify drops an over-capacity frame and keeps the client open', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.notify('fs.changed', { events: ['x'.repeat(20_000)] }) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 12288B\)\n$/ + ) + + bounded.notify('pty.exit', { id: 'pty-1' }) + expect(primary.frames).toHaveLength(1) + expect(decodePayload(primary.frames[0]).method).toBe('pty.exit') + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('logs at most one drop per client generation', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + + const reattached = makeBoundedClient(16384) + bounded.setWrite(reattached.write, reattached.options) + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(2) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('distinguishes an over-capacity drop from a producer-queue-full drop on one client', () => { + const primary = makeSaturatingClient(4 * 1024 * 1024) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + // The first 1.5MB frame is retained by the stalled sink; the second exceeds the 2MB producer queue. + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + expect(primary.frames).toHaveLength(1) + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + bounded.notify('fs.changed', { events: ['x'.repeat(5_000_000)] }) + + expect(primary.closes).toBe(0) + expect(stderr).toHaveBeenCalledTimes(2) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B, producer queue full; frame capacity 4128768B\)\n$/ + ) + expect(stderr.mock.calls[1][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 4128768B\)\n$/ + ) + + // Both classifications stay one-per-generation once logged. + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + bounded.notify('fs.changed', { events: ['x'.repeat(5_000_000)] }) + expect(stderr).toHaveBeenCalledTimes(2) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('logs each dropped method once instead of letting the first method silence the rest', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + bounded.notify('pty.data', flood) + bounded.notify('fs.changed', flood) + bounded.notify('pty.data', flood) + + expect(stderr).toHaveBeenCalledTimes(2) + expect(stderr.mock.calls[0][0]).toContain('Dropped fs.changed') + expect(stderr.mock.calls[1][0]).toContain('Dropped pty.data') + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('publishProducerNotification publishes on the producer lane and never closes', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', { events: [] })).toBe( + true + ) + expect(secondary.frames).toHaveLength(1) + expect(primary.frames).toHaveLength(0) + + expect( + bounded.publishProducerNotification(secondaryId, 'fs.changed', { + events: ['x'.repeat(20_000)] + }) + ).toBe(false) + expect(secondary.closes).toBe(0) + expect(secondary.frames).toHaveLength(1) + + expect(bounded.publishProducerNotification(999, 'fs.changed')).toBe(false) + } finally { + bounded.dispose() + } + }) + + it('still closes on protocol-critical control queue overflow', () => { + const primary = makeSaturatingClient(65536) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + expect(primary.closes).toBe(0) + + bounded.notifyClient(clientId, 'control.overflow') + expect(primary.closes).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('closes the client when pty.replay overflows the control queue', () => { + const primary = makeSaturatingClient(65536) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + expect(primary.closes).toBe(0) + + // Replay is never retried, so an unnoticed drop would strand the pane on a short buffer. + bounded.notify('pty.replay', { paneKey: 'tab-1:pane-1', data: 'x' }) + expect(primary.closes).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('publishProducerNotification reports its drops like notify', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const flood = { events: ['x'.repeat(20_000)] } + + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', flood)).toBe(false) + expect(stderr).toHaveBeenCalledTimes(1) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 12288B\)\n$/ + ) + + // Same one-line-per-generation budget as notify(). + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', flood)).toBe(false) + expect(stderr).toHaveBeenCalledTimes(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('does not re-encode a dropped frame when its log line is suppressed', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + + // The suppressed drop must size the frame once, not once to publish and again to log. + encodeCalls.count = 0 + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + expect(encodeCalls.count).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('sendResponse substitutes a JSON-RPC error instead of closing when the legacy lane rejects', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.onRequest('fs.listFiles', async () => ({ paths: 'x'.repeat(3 * 1024 * 1024) })) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 77, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(1) + const response = decodePayload(primary.frames[0]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(response.id).toBe(77) + expect(response.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(response.error.message).toBe('Relay response exceeded the bounded transport capacity') + } finally { + bounded.dispose() + } + }) + + it('settles the response fence as failed when the substitute error replaces the result', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const settlements: SinkWriteSettlement[] = [] + bounded.onRequest('fs.listFiles', async (_params, context) => { + context.onResponseSettled?.((result) => settlements.push(result)) + return { paths: 'x'.repeat(3 * 1024 * 1024) } + }) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 79, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(settlements).toHaveLength(1) + // The peer got a capacity error, not the result — the fence must not claim delivery. + expect(settlements[0]).toEqual({ + ok: false, + error: new Error('Relay response exceeded the bounded transport capacity') + }) + } finally { + bounded.dispose() + } + }) + + it('producerRetentionBelowLowWater reports the reserve of one client, not the dispatcher', () => { + const stalled = makeSaturatingClient(65536) + const healthy = makeBoundedClient(65536) + const bounded = new RelayDispatcher(stalled.write, stalled.options) + try { + const stalledId = bounded.activeClientIds()[0] + const healthyId = bounded.attachClient(healthy.write, healthy.options) + let parked = 0 + while ( + parked <= LEGACY_CLIENT_RETAINED_BYTES_LOW && + bounded.publishProducerNotification(stalledId, 'pty.data', { + paneId: 'pane', + data: 'x'.repeat(40_000) + }) + ) { + parked += 40_000 + } + expect(parked).toBeGreaterThan(LEGACY_CLIENT_RETAINED_BYTES_LOW) + + expect(bounded.producerRetentionBelowLowWater(stalledId)).toBe(false) + expect(bounded.producerRetentionBelowLowWater(healthyId)).toBe(true) + // The dispatcher-wide signal is the one that lets a stalled peer speak for a healthy client. + expect(bounded.legacyRetentionBelowLowWater).toBe(false) + + // A client that cannot be written to has no headroom at all. + expect(bounded.producerRetentionBelowLowWater(999)).toBe(false) + bounded.detachClient(healthyId) + expect(bounded.producerRetentionBelowLowWater(healthyId)).toBe(false) + } finally { + bounded.dispose() + } + }) + + it('sendResponse settles an already-failed oversized response without closing', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.onRequest('fs.listFiles', async () => { + throw new Error('x'.repeat(3 * 1024 * 1024)) + }) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 78, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(1) + const response = decodePayload(primary.frames[0]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(response.id).toBe(78) + expect(response.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(response.error.message).toBe('Relay response exceeded the bounded transport capacity') + } finally { + bounded.dispose() + } + }) +}) diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index 1966ce31dfc..58be59173cf 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -83,7 +83,8 @@ export class DispatcherClientWriter { lane: DispatcherWriterLane, encode: () => Buffer, estimatedBytes: number, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + overflowIsNonFatal = false ): boolean { if (this.closed) { onSettled({ ok: false, error: new Error('Relay writer is closed') }) @@ -94,7 +95,8 @@ export class DispatcherClientWriter { encode, estimatedBytes, onSettled: onceDispatcherWriterSettlement(onSettled), - settled: false + settled: false, + overflowIsNonFatal } const admission = this.admission.admit(entry, this.sink.frameCapacity(lane, this.saturated)) if (!admission.accepted) { @@ -176,29 +178,18 @@ export class DispatcherClientWriter { } return this.laneScheduler.select( this.admission, - (entry) => this.canWriteControl(entry), - (entry) => this.canWriteProducer(entry) + (entry) => this.canWrite(entry, this.sink.highWaterMark), + (entry) => this.canWrite(entry, this.sink.producerFrameCapacity) ) } - private canWriteControl(entry: DispatcherWriterEntry): boolean { - const highWaterMark = this.sink.highWaterMark - if (!Number.isFinite(highWaterMark)) { + // Why: a zero-length writable always takes one frame, else a single oversized frame could never drain. + private canWrite(entry: DispatcherWriterEntry, capacity: number): boolean { + if (!Number.isFinite(this.sink.highWaterMark)) { return true } const writableLength = this.sink.writableLength - return writableLength + entry.estimatedBytes <= highWaterMark || writableLength === 0 - } - - private canWriteProducer(entry: DispatcherWriterEntry): boolean { - const highWaterMark = this.sink.highWaterMark - if (!Number.isFinite(highWaterMark)) { - return true - } - return ( - this.sink.writableLength + entry.estimatedBytes <= this.sink.producerFrameCapacity || - this.sink.writableLength === 0 - ) + return writableLength + entry.estimatedBytes <= capacity || writableLength === 0 } private writeEntry(entry: DispatcherWriterEntry): void { diff --git a/src/relay/dispatcher-writer-admission.ts b/src/relay/dispatcher-writer-admission.ts index a49627d4dcd..07db61c12e8 100644 --- a/src/relay/dispatcher-writer-admission.ts +++ b/src/relay/dispatcher-writer-admission.ts @@ -15,13 +15,15 @@ export type DispatcherWriterEntry = { estimatedBytes: number onSettled: (result: DispatcherWriterSettlement) => void settled: boolean + // Why: control overflow closes the client by default; best-effort frames opt out of that instead. + overflowIsNonFatal?: boolean } type AdmissionResult = | { accepted: true; replaced?: DispatcherWriterEntry } | { accepted: false; error?: Error } -const CONTROL_QUEUE_MAX_FRAMES = 256 +export const DISPATCHER_CONTROL_QUEUE_MAX_FRAMES = 256 export const DISPATCHER_CONTROL_QUEUE_MAX_BYTES = 1024 * 1024 const LIVENESS_QUEUE_MAX_FRAMES = 2 @@ -143,10 +145,15 @@ export class DispatcherWriterAdmission { } private admitControl(entry: DispatcherWriterEntry): AdmissionResult { + // Why: best-effort controls may fail soft without weakening protocol-critical admission. + const overflowIsNonFatal = entry.overflowIsNonFatal === true if ( - this.controlFrames >= CONTROL_QUEUE_MAX_FRAMES || + this.controlFrames >= DISPATCHER_CONTROL_QUEUE_MAX_FRAMES || this.controlBytes + entry.estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ) { + if (overflowIsNonFatal) { + return { accepted: false } + } return { accepted: false, error: new Error('Relay control queue exceeded its bounded capacity') diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 7149144c9a5..e8766dc347d 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -6,6 +6,7 @@ import { encodeKeepAliveFrame, parseJsonRpcMessage, KEEPALIVE_SEND_MS, + RelayErrorCode, type DecodedFrame, type JsonRpcRequest, type JsonRpcNotification, @@ -69,9 +70,21 @@ type RelayClient = { highestReceivedSeq: number generation: number closed: boolean + droppedNotificationLog: DroppedProducerNotificationLog | null sessionIdentity: RelayClientSessionIdentity } +// Why: the log key set is rebuilt per generation, but a producer minting synthetic method names would still +// grow it inside one generation — cap it well above the fixed relay method vocabulary. +const DROPPED_NOTIFICATION_LOG_KEY_LIMIT = 64 + +const RESPONSE_OVER_CAPACITY_MESSAGE = 'Relay response exceeded the bounded transport capacity' + +type DroppedProducerNotificationLog = { + generation: number + loggedKeys: Set +} + type PendingRelayRequest = { resolve: (result: unknown) => void reject: (error: Error) => void @@ -180,6 +193,19 @@ export class RelayDispatcher { return this.publicationLedger.belowLowWater(this.activeClientKeys()) } + /** + * Same reserve as legacyRetentionBelowLowWater, but scoped to one client: a paced bulk producer + * gated on the dispatcher-wide signal stops for a peer's stall and degrades a healthy link. + * The relay-wide aggregate still counts — that ceiling is shared by every client. + */ + producerRetentionBelowLowWater(clientId: number): boolean { + const client = this.clients.get(clientId) + if (!client || client.closed) { + return false + } + return this.publicationLedger.belowLowWater([this.clientKey(client)]) + } + writePrimaryBytes(data: Buffer, lane: 'control' | 'ordinary' = 'control'): boolean { if (this.disposed || this.primaryClient.closed) { return false @@ -365,26 +391,44 @@ export class RelayDispatcher { ) } + activeClientIds(): number[] { + return this.activeClients().map((client) => client.id) + } + + // Why: signed on purpose — `budget >= 0` is an exact fits-check that a floored budget cannot express. + producerEnvelopeBudget( + method: string, + params: Record, + clientId?: number + ): number { + if (clientId !== undefined) { + const client = this.clients.get(clientId) + // Why: a detached or closed target has no room at all — reporting infinite capacity passes a + // fits-check and the frame is then dropped by the publish seam instead. + if (!client || client.closed) { + return Number.MIN_SAFE_INTEGER + } + return ( + client.writer.producerFrameCapacity - + this.estimateFrameBytes({ jsonrpc: '2.0', method, params }) + ) + } + const targets = this.activeClients() + if (targets.length === 0) { + return Number.MAX_SAFE_INTEGER + } + const frameBytes = this.estimateFrameBytes({ jsonrpc: '2.0', method, params }) + return Math.min(...targets.map((client) => client.writer.producerFrameCapacity - frameBytes)) + } + producerDataBudget( method: string, paramsWithoutData: Record, clientId?: number ): number { - const targets = - clientId === undefined - ? this.activeClients() - : [this.clients.get(clientId)].filter((client): client is RelayClient => !!client) - if (targets.length === 0) { - return Number.MAX_SAFE_INTEGER - } - const emptyFrameBytes = this.estimateFrameBytes({ - jsonrpc: '2.0', - method, - params: { ...paramsWithoutData, data: '' } - }) return Math.max( 0, - Math.min(...targets.map((client) => client.writer.producerFrameCapacity - emptyFrameBytes)) + this.producerEnvelopeBudget(method, { ...paramsWithoutData, data: '' }, clientId) ) } @@ -434,26 +478,82 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } + const frameBytes = this.estimateFrameBytes(msg) this.runPublicationTransaction(() => { for (const client of this.clients.values()) { if (client.closed) { continue } if (method === 'pty.replay') { - this.enqueueFrame(client, msg, 'control') + // Why: replay is never re-sent, so it takes the control lane where overflow is fatal — the + // writer closes the client and reconnect reloads history rather than stranding a short buffer. + this.enqueueFrame(client, msg, 'control', undefined, frameBytes) continue } - if (!this.publishToClient(client, msg, 'ordinary')) { - this.closeClient( - client, - new Error('Relay ordinary publication capacity exceeded'), - client !== this.primaryClient - ) + // Why: closing can never make an oversized frame sendable — the producer regenerates it after + // reattach and re-kills the link, turning a recoverable drop into an endless reconnect loop. + if (!this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + this.logDroppedProducerNotification(client, method, frameBytes) } } }) } + // Why: producer-lane publication for a single client; notifyClient/tryNotifyClient use the control lane, + // which floods must never occupy. Rejection drops the frame and never closes the client. + publishProducerNotification( + clientId: number, + method: string, + params?: Record, + // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in + // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. + options?: { logDrop?: boolean } + ): boolean { + if (this.disposed) { + return false + } + const client = this.clients.get(clientId) + if (!client || client.closed) { + return false + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + const frameBytes = this.estimateFrameBytes(msg) + if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + return true + } + // Why: same diagnostics as notify() — a producer that drops here must not do so silently. + if (options?.logDrop !== false) { + this.logDroppedProducerNotification(client, method, frameBytes) + } + return false + } + + // Why: one line per generation, method and drop reason — a flooding producer retries every batch and would + // spam stderr, but a transient queue-full drop must not consume the slot a real over-capacity drop needs. + private logDroppedProducerNotification(client: RelayClient, method: string, bytes: number): void { + const capacity = client.writer.producerFrameCapacity + const overCapacity = bytes > capacity + const key = `${method}:${overCapacity ? 'over-capacity' : 'queue-full'}` + let log = client.droppedNotificationLog + if (!log || log.generation !== client.generation) { + log = { generation: client.generation, loggedKeys: new Set() } + client.droppedNotificationLog = log + } + if (log.loggedKeys.has(key) || log.loggedKeys.size >= DROPPED_NOTIFICATION_LOG_KEY_LIMIT) { + return + } + log.loggedKeys.add(key) + process.stderr.write( + overCapacity + ? `[relay] Dropped ${method} (${bytes}B > producer frame capacity ${capacity}B)\n` + : `[relay] Dropped ${method} (${bytes}B, producer queue full; frame capacity ${capacity}B)\n` + ) + } + notifyClient(clientId: number, method: string, params?: Record): void { this.tryNotifyClient(clientId, method, params) } @@ -462,7 +562,8 @@ export class RelayDispatcher { clientId: number, method: string, params?: Record, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + options: { controlOverflow?: 'close-client' | 'reject' } = {} ): boolean { if (this.disposed) { onSettled({ ok: false, error: new Error('Relay dispatcher is disposed') }) @@ -481,7 +582,9 @@ export class RelayDispatcher { ...(params !== undefined ? { params } : {}) }, 'control', - onSettled + onSettled, + undefined, + options.controlOverflow ) } @@ -646,6 +749,7 @@ export class RelayDispatcher { highestReceivedSeq: 0, generation: 0, closed: false, + droppedNotificationLog: null, sessionIdentity: sessionIdentity ?? { principal: `unproved:${id}`, authenticated: false, @@ -840,16 +944,31 @@ export class RelayDispatcher { const estimatedBytes = this.estimateFrameBytes(msg) const lane = estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' const accepted = this.enqueueFrame(client, msg, lane, onSettled) - if (!accepted) { - this.closeClient( - client, - new Error( - `Relay response exceeds the bounded ${DEFAULT_PRODUCER_QUEUE_MAX_BYTES}-byte legacy lane` - ), - client !== this.primaryClient - ) + if (accepted) { + return true } - return accepted + // Why: an oversized response must fail its own request; closing would kill every pane on the host. + // A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens. + return this.enqueueFrame( + client, + { + jsonrpc: '2.0', + id, + error: { + code: RelayErrorCode.ResponseOverCapacity, + message: RESPONSE_OVER_CAPACITY_MESSAGE + } + }, + 'control', + // Why: writing the substitute is not delivering the result — a settlement fence must never read + // the capacity error's successful write as "the peer received your result". + (settlement) => + onSettled( + settlement.ok + ? { ok: false, error: new Error(RESPONSE_OVER_CAPACITY_MESSAGE) } + : settlement + ) + ) } private enqueueFrame( @@ -858,20 +977,23 @@ export class RelayDispatcher { lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, // Why: publish paths already sized the frame; avoid a redundant encode. - estimatedBytes?: number + estimatedBytes?: number, + controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false } const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) + const encode = (): Buffer => { + const seq = client.nextOutgoingSeq++ + return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) + } return client.writer.enqueue( lane, - () => { - const seq = client.nextOutgoingSeq++ - return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) - }, + encode, frameBytes, - onSettled + onSettled, + lane === 'control' && controlOverflow === 'reject' ) } @@ -972,9 +1094,11 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + // Why: broadcast callers size the frame once for every client; avoid a redundant encode. + estimatedBytes?: number ): boolean { - const bytes = this.estimateFrameBytes(msg) + const bytes = estimatedBytes ?? this.estimateFrameBytes(msg) const fixedBlocked = lane === 'fixed-bulk' && (client.writer.retainedProducerBytes > 0 || bytes > client.writer.fixedFrameCapacity) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 003172c08fd..14672457cc6 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,8 +1,14 @@ import { open, readFile, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' -import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' +import { + MAX_CONCURRENT_STREAMS, + STREAM_ACK_WINDOW_CHUNKS, + STREAM_CHUNK_SIZE, + RelayErrorCode +} from './protocol' +import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -102,12 +108,17 @@ export async function readRelayFileStreamMetadata( return { totalSize: 0, isBinary: true, empty: true } } - const handle = await open(filePath, 'r') + // Why: reserved before the fd opens so a refusal costs nothing, and released only + // once the terminal frame settles — see reserveTerminalFrameSlot. + const releaseTerminalFrameSlot = reserveTerminalFrameSlot(registry, context.clientId) + let handle: FileHandle | undefined let streamId: number try { + handle = await open(filePath, 'r') streamId = registry.register(handle) } catch (err) { - await handle.close() + await handle?.close() + releaseTerminalFrameSlot() throw err } @@ -118,7 +129,15 @@ export async function readRelayFileStreamMetadata( // sees the response before the first chunk frame. const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) + void pumpChunks( + streamId, + stats.size, + dispatcher, + registry, + context, + resolvedPumpOptions, + releaseTerminalFrameSlot + ) }) return { @@ -132,16 +151,60 @@ export async function readRelayFileStreamMetadata( } } +/** + * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not + * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's + * registry slot is gone the moment its last chunk is read, so on a socket that is not + * draining, back-to-back reads could stack one undelivered terminal frame each until + * that budget blew. Holding the slot until the frame settles keeps the number of queued + * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the + * overflow now costs one refused read (TooManyStreams, which clients already handle) + * instead of the whole connection. + * + * Counted per client, because the control queue this protects is per client: one peer whose + * socket stopped draining must not refuse reads for every other peer on the same relay. + */ +const pendingTerminalFramesByClient = new WeakMap>() + +function reserveTerminalFrameSlot(registry: RelayStreamRegistry, clientId: number): () => void { + let byClient = pendingTerminalFramesByClient.get(registry) + if (!byClient) { + byClient = new Map() + pendingTerminalFramesByClient.set(registry, byClient) + } + const pending = byClient.get(clientId) ?? 0 + if (pending >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + byClient.set(clientId, pending + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (byClient.get(clientId) ?? 1) - 1 + // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. + if (remaining <= 0) { + byClient.delete(clientId) + return + } + byClient.set(clientId, remaining) + } +} + async function pumpChunks( streamId: number, totalSize: number, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, context: RequestContext, - pumpOptions: StreamPumpOptions + pumpOptions: StreamPumpOptions, + releaseTerminalFrameSlot: () => void ): Promise { const entry = registry.get(streamId) if (!entry) { + releaseTerminalFrameSlot() return } const buffer = Buffer.allocUnsafe(STREAM_CHUNK_SIZE) @@ -150,6 +213,7 @@ async function pumpChunks( let endReason: 'end' | 'aborted' | 'stale' | 'error' = 'end' let errorCode: string | null = null let errorMessage: string | null = null + let slotReleaseDeferred = false try { try { @@ -226,11 +290,28 @@ async function pumpChunks( } try { + // Why: a dropped terminal frame hangs the reader forever, so it takes the control lane, which + // never drops — but that lane KILLS the link when it overflows, hence the reserved slot held + // until this frame settles. The per-chunk await already settled every chunk, so the control + // frame cannot overtake stream data. + const publishTerminal = (method: string, params: Record): void => { + if (pumpOptions.clientId === undefined) { + // Legacy broadcast path (direct calls/tests): no per-frame settlement to hold the slot on. + dispatcher.notifyControl(method, params) + return + } + slotReleaseDeferred = dispatcher.tryNotifyClient( + pumpOptions.clientId, + method, + params, + releaseTerminalFrameSlot + ) + } if (endReason === 'end') { - dispatcher.notify('fs.streamEnd', { streamId }) + publishTerminal('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) } else if (endReason === 'error') { - dispatcher.notify('fs.streamError', { + publishTerminal('fs.streamError', { streamId, code: errorCode ?? 'ESTREAMERROR', message: errorMessage ?? 'stream error' @@ -247,7 +328,12 @@ async function pumpChunks( ) } } finally { + // Why: the fd goes back first — a terminal frame that can never be delivered must not + // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. await registry.release(streamId) + if (!slotReleaseDeferred) { + releaseTerminalFrameSlot() + } } } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index c7624afce65..ba6cfbf1877 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' import type { RelayDispatcher } from './dispatcher' -import { STREAM_CHUNK_SIZE } from './protocol' +import { MAX_CONCURRENT_STREAMS, STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync } from 'node:fs' @@ -10,15 +10,27 @@ import { tmpdir } from 'node:os' vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -type Notification = { method: string; params?: Record } +type NotificationLane = 'producer' | 'bulk' | 'control' +type Notification = { + method: string + params?: Record + lane: NotificationLane + clientId?: number +} +type MockRequestContext = { clientId?: number; isStale: () => boolean } function createMockDispatcher() { const requestHandlers = new Map< string, - (params: Record, context?: { isStale: () => boolean }) => Promise + (params: Record, context?: MockRequestContext) => Promise >() const notificationHandlers = new Map) => void>() const notifications: Notification[] = [] + const droppedProducerFrames: Notification[] = [] + // Mirrors the real lane split: a saturated producer queue drops frames, control frames still land. + let producerSaturated = false + let holdControlSettlement = false + const heldControlSettlements: (() => void)[] = [] return { onRequest: vi.fn( ( @@ -32,16 +44,65 @@ function createMockDispatcher() { notificationHandlers.set(method, handler) }), notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) + const frame: Notification = { method, params, lane: 'producer' } + if (producerSaturated) { + droppedProducerFrames.push(frame) + return + } + notifications.push(frame) }), - notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { - notifications.push({ method, params }) + notifyBulk: vi.fn( + async ( + method: string, + params?: Record, + opts?: { clientId?: number } + ): Promise => { + notifications.push({ method, params, lane: 'bulk', clientId: opts?.clientId }) + } + ), + notifyClient: vi.fn((clientId: number, method: string, params?: Record) => { + notifications.push({ method, params, lane: 'control', clientId }) + }), + // Mirrors the real control lane: the frame is queued on enqueue, but settles only once the + // sink actually wrote it — holding settlement models a socket that is not draining. + tryNotifyClient: vi.fn( + ( + clientId: number, + method: string, + params?: Record, + onSettled?: (result: { ok: boolean }) => void + ): boolean => { + notifications.push({ method, params, lane: 'control', clientId }) + const settle = () => onSettled?.({ ok: true }) + if (holdControlSettlement) { + heldControlSettlements.push(settle) + } else { + settle() + } + return true + } + ), + notifyControl: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params, lane: 'control' }) }), _notifications: notifications, + _droppedProducerFrames: droppedProducerFrames, + saturateProducerLane() { + producerSaturated = true + }, + holdControlSettlements() { + holdControlSettlement = true + }, + settleHeldControlFrames() { + holdControlSettlement = false + for (const settle of heldControlSettlements.splice(0)) { + settle() + } + }, callRequest( method: string, params: Record = {}, - context?: { isStale: () => boolean } + context?: MockRequestContext ) { const handler = requestHandlers.get(method) if (!handler) { @@ -138,6 +199,60 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) + it('publishes fs.streamEnd after the final fs.streamChunk and targets the same client', async () => { + const filePath = path.join(tmpDir, 'targeted.png') + writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 7, isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).end !== null) + const frames = dispatcher._notifications + const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') + const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') + expect(lastChunkIndex).toBeGreaterThanOrEqual(0) + expect(endIndex).toBe(lastChunkIndex + 1) + expect(frames[endIndex]).toEqual({ + method: 'fs.streamEnd', + params: { streamId: meta.streamId }, + lane: 'control', + clientId: 7 + }) + expect(frames[lastChunkIndex].clientId).toBe(7) + }) + + // Real saturation is not constructible: the fixed-bulk lane admits a chunk only while producerBytes is 0, + // so a real backlog parks the chunk pump long before the terminal frame — hence the lane-tagging mock. + it('orders fs.streamEnd after the final chunk on the control lane and releases the stream', async () => { + const filePath = path.join(tmpDir, 'stream-end-release.png') + writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) + // Producer-lane frames now divert to the drop log, so an empty log proves no stream frame took that lane. + dispatcher.saturateProducerLane() + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 3, isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).end !== null) + expect(dispatcher._droppedProducerFrames).toHaveLength(0) + expect(collectStream(dispatcher).end).toEqual({ streamId: meta.streamId }) + + const frames = dispatcher._notifications + const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') + const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') + expect(lastChunkIndex).toBeGreaterThanOrEqual(0) + expect(endIndex).toBe(lastChunkIndex + 1) + expect(frames[endIndex].lane).toBe('control') + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + }) + it('fills protocol chunks when fs.read returns short before EOF', async () => { const filePath = path.join(tmpDir, 'short-read.png') const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) @@ -336,6 +451,47 @@ describe('FsHandler readFileStream', () => { expect(err).toBeNull() }) + it('caps undelivered terminal frames so they cannot overflow the link-killing control lane', async () => { + const filePath = path.join(tmpDir, 'terminal-budget.png') + writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) // one chunk per stream + // A socket that accepts nothing: control frames queue, and the real writer destroys the + // client once 256 of them pile up. + dispatcher.holdControlSettlements() + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + const context = { clientId: 5, isStale: () => false } + const terminalFrames = () => + dispatcher._notifications.filter( + (n) => n.method === 'fs.streamEnd' || n.method === 'fs.streamError' + ) + + for (let i = 0; i < MAX_CONCURRENT_STREAMS; i++) { + await dispatcher.callRequest('fs.readFileStream', { filePath }, context) + await waitFor(() => terminalFrames().length === i + 1) + } + // The fd must go back even though its terminal frame is still undelivered. + await waitFor(() => registry.size() === 0) + + await expect( + dispatcher.callRequest('fs.readFileStream', { filePath }, context) + ).rejects.toThrow(/Too many concurrent streams/) + await flush(10) + expect(terminalFrames()).toHaveLength(MAX_CONCURRENT_STREAMS) + + // The budget is per client, so a second peer on the same relay still reads. + await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 9, isStale: () => false } + ) + await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 1) + + // Draining the sink settles the queued frames and hands the budget back. + dispatcher.settleHeldControlFrames() + await dispatcher.callRequest('fs.readFileStream', { filePath }, context) + await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) + }) + it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 0fbd85cc8fb..8cb4ab57a75 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -102,7 +102,10 @@ export type GitResponseStreamMarker = { export const RelayErrorCode = { TooManyStreams: -33006, - StreamProtocolError: -33007 + StreamProtocolError: -33007, + /** Substituted for a response too large for the sink's frame capacity; the request fails + * instead of the whole link, so a caller can retry with a narrower scope. */ + ResponseOverCapacity: -33008 } as const export type JsonRpcRequest = { diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 8c4c0134a2a..0c6109a55ea 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -61,11 +61,38 @@ class FakeWatcherPool { function createDispatcher() { const detached = new Set<(clientId: number) => void>() + // Batches ride the producer lane and markers the control lane, so record both in one ordered log. + const fsChanged: Record[] = [] + const record = (_clientId: number, method: string, params?: Record): void => { + if (method === 'fs.changed' && params) { + fsChanged.push(params) + } + } return { + fsChanged, notify: vi.fn(), - notifyClient: vi.fn(), - broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), - notificationFrameBytes: vi.fn(() => 64), + notifyClient: vi.fn(record), + tryNotifyClient: vi.fn( + ( + clientId: number, + method: string, + params?: Record, + onSettled?: (result: { ok: true } | { ok: false; error: Error }) => void + ) => { + record(clientId, method, params) + // A healthy sink settles as the frame is written, which releases the emitter's outstanding-marker slot. + onSettled?.({ ok: true }) + return true + } + ), + activeClientIds: vi.fn(() => [1]), + producerEnvelopeBudget: vi.fn(() => Number.MAX_SAFE_INTEGER), + publishProducerNotification: vi.fn( + (clientId: number, method: string, params?: Record) => { + record(clientId, method, params) + return true + } + ), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detached.add(listener) return () => detached.delete(listener) @@ -73,6 +100,10 @@ function createDispatcher() { } } +function watchFailedCalls(dispatcher: ReturnType): unknown[][] { + return dispatcher.notifyClient.mock.calls.filter(([, method]) => method === 'fs.watchFailed') +} + function context(clientId: number): RequestContext { return { clientId, isStale: () => false } } @@ -96,10 +127,10 @@ describe('RelayFilesystemWatchRegistry', () => { first.hooks.onInterruption?.() first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }]) - expect(dispatcher.notify.mock.calls).toEqual([ - ['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }], - ['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }], - ['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }] + expect(dispatcher.fsChanged).toEqual([ + { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }, + { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, + { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] } ]) }) @@ -118,12 +149,10 @@ describe('RelayFilesystemWatchRegistry', () => { expect(pool.installed).toHaveLength(2) pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', { - events: [{ kind: 'overflow', absolutePath: '/repo' }] - }) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', { - events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] - }) + expect(dispatcher.fsChanged).toEqual([ + { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, + { events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] } + ]) registry.unwatch('/repo', context(1)) expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled() @@ -145,17 +174,19 @@ describe('RelayFilesystemWatchRegistry', () => { ) ) - await vi.waitFor(() => expect(dispatcher.notifyClient).toHaveBeenCalledTimes(2)) - expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(1, 1, 'fs.watchFailed', { - rootPath: '/repo', - watchId: 101, - message: 'quarantine recovery failed' - }) - expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(2, 2, 'fs.watchFailed', { - rootPath: '/repo', - watchId: 202, - message: 'quarantine recovery failed' - }) + await vi.waitFor(() => expect(watchFailedCalls(dispatcher)).toHaveLength(2)) + expect(watchFailedCalls(dispatcher)).toEqual([ + [ + 1, + 'fs.watchFailed', + { rootPath: '/repo', watchId: 101, message: 'quarantine recovery failed' } + ], + [ + 2, + 'fs.watchFailed', + { rootPath: '/repo', watchId: 202, message: 'quarantine recovery failed' } + ] + ]) }) it('aborts a pending crawl only after the last same-root client leaves', async () => { @@ -400,21 +431,18 @@ describe('createRelayWatcherProcessPool', () => { expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual( roots.map((root) => [root]) ) - expect(dispatcher.notify.mock.calls.slice(0, roots.length)).toEqual( - roots.map((root) => ['fs.changed', { events: [{ kind: 'overflow', absolutePath: root }] }]) + expect(dispatcher.fsChanged.slice(0, roots.length)).toEqual( + roots.map((root) => ({ events: [{ kind: 'overflow', absolutePath: root }] })) ) for (const watches of recoveredWatches) { const [{ callback, dir }] = watches callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }]) } - expect(dispatcher.notify.mock.calls.slice(roots.length)).toEqual( - roots.map((root) => [ - 'fs.changed', - { - events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] - } - ]) + expect(dispatcher.fsChanged.slice(roots.length)).toEqual( + roots.map((root) => ({ + events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] + })) ) } finally { registry.dispose() diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index 4acb786fa23..de583b2f50a 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -223,7 +223,7 @@ export class RelayFilesystemWatchRegistry { emitOverflow() return } - emitRelayWatcherEvents(this.dispatcher, state.closed, events) + emitRelayWatcherEvents(this.dispatcher, state.rootPath, state.closed, events) }, RELAY_WATCH_OPTIONS, { diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts new file mode 100644 index 00000000000..41d3dd833e5 --- /dev/null +++ b/src/relay/relay-oversized-notification-survival.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' +import { encodeJsonRpcFrame, HEADER_LENGTH, parseJsonRpcMessage } from './protocol' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + type AgentHookRelayEnvelope +} from '../shared/agent-hook-relay' +import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, + AGENT_STATUS_MAX_SUBAGENTS, + normalizeAgentStatusPayload +} from '../shared/agent-status-types' + +// The relay runs on the REMOTE host, so the stream default is that host's Node major. +const NODE22_HWM = 64 * 1024 // Node >= 22 default +const NODE21_HWM = 16 * 1024 // Node <= 21 default + +const WATCHER_ROOT = '/home/dev/project' + +function capacityFor(hwm: number): number { + return hwm - relayWriterControlReserve(hwm) +} + +/** Largest envelope the normalizer accepts: every field at its own documented cap. */ +function buildMaximalHookEnvelope(): Record { + const payload = normalizeAgentStatusPayload({ + state: 'waiting', + prompt: 'x'.repeat(200), + interactivePrompt: 'q'.repeat(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH), + lastAssistantMessage: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH), + toolName: 'Bash', + toolInput: 'i'.repeat(160), + model: 'claude-opus-5', + agentType: 'general-purpose', + subagents: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, i) => ({ + id: `subagent-${i}`.padEnd(64, '0'), + state: 'working', + agentType: 'general-purpose', + model: 'claude-opus-5', + toolName: 'Grep', + toolInput: 'i'.repeat(160) + })) + }) + expect(payload, 'normalizer must accept this payload').not.toBeNull() + return { + paneKey: 'worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d', + connectionId: 'ssh-target-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Notification', + payload + } as unknown as Record +} + +function watcherProcessEvents(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, i) => ({ + type: 'update', + path: `${WATCHER_ROOT}/node_modules/.vite/deps/chunk-${String(i).padStart(6, '0')}.js` + })) as unknown as WatcherProcessEvent[] +} + +/** The exact `fs.changed` params emitRelayWatcherEvents publishes, so the pinned sizes measure reality. */ +function buildWatcherBatch(count: number): Record { + return { + events: watcherProcessEvents(count).map((event) => ({ + kind: event.type, + absolutePath: event.path + })) + } +} + +function frameBytes(method: string, params: Record): number { + return encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, 1, 0).length +} + +function frameParams(frame: Buffer): Record { + const payloadLength = frame.readUInt32BE(9) + const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) + return 'method' in message ? ((message.params ?? {}) as Record) : {} +} + +function createDispatcher(hwm: number, onClose: () => void, sink: Buffer[]): RelayDispatcher { + return new RelayDispatcher( + (data: Buffer) => { + sink.push(data) + return true + }, + { + writableHighWaterMark: () => hwm, + writableLength: () => 0, + supportsWriteCallback: false, + close: onClose + } + ) +} + +describe('relay oversized notification survival', () => { + // Without this the byte measurements below could describe a payload the emitter never sends. + it('measures the params emitRelayWatcherEvents actually publishes', () => { + const sink: Buffer[] = [] + const dispatcher = createDispatcher(NODE22_HWM, () => {}, sink) + + try { + emitRelayWatcherEvents(dispatcher, WATCHER_ROOT, false, watcherProcessEvents(1)) + expect(sink).toHaveLength(1) + expect(frameParams(sink[0])).toEqual(buildWatcherBatch(1)) + } finally { + dispatcher.dispose() + } + }) + + it('measures both producers against both Node tiers', () => { + const hookBytes = frameBytes(AGENT_HOOK_NOTIFICATION_METHOD, buildMaximalHookEnvelope()) + const fullBatchBytes = frameBytes('fs.changed', buildWatcherBatch(MAX_BATCHED_WATCHER_EVENTS)) + + // Binary-search the largest watcher batch that still fits each tier. + const trip = (cap: number): number => { + let lo = 1 + let hi = MAX_BATCHED_WATCHER_EVENTS + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (frameBytes('fs.changed', buildWatcherBatch(mid)) <= cap) { + lo = mid + } else { + hi = mid - 1 + } + } + return lo + } + + // Capacity is a fixed fraction of the sink's high-water mark, so these are exact. + expect(capacityFor(NODE22_HWM)).toBe(49152) + expect(capacityFor(NODE21_HWM)).toBe(12288) + + // agent.hook: fits a modern remote, over-cap on an older one — which is why the remote's + // Node major, not Orca's, decides whether a maximal envelope has to shed. + expect(hookBytes).toBeLessThan(capacityFor(NODE22_HWM)) + expect(hookBytes).toBeGreaterThan(capacityFor(NODE21_HWM)) + + // fs.changed: the batch cap is orders of magnitude above what one frame can carry, on BOTH tiers. + expect(fullBatchBytes).toBeGreaterThan(capacityFor(NODE22_HWM)) + expect(trip(capacityFor(NODE22_HWM)) * 5).toBeLessThan(MAX_BATCHED_WATCHER_EVENTS) + expect(trip(capacityFor(NODE21_HWM))).toBeLessThan(trip(capacityFor(NODE22_HWM))) + }) + + it('a 5000-event fs.changed no longer closes the client on a Node>=22-sized sink', () => { + const sink: Buffer[] = [] + let closes = 0 + const dispatcher = createDispatcher(NODE22_HWM, () => (closes += 1), sink) + + try { + dispatcher.notify('pty.exit', { id: 'pty-1', exitCode: 0 }) + const before = sink.length + expect(before).toBeGreaterThan(0) + + // The batch the registry is configured to allow (maxEventsPerBatch = MAX_BATCHED_WATCHER_EVENTS). + emitRelayWatcherEvents( + dispatcher, + WATCHER_ROOT, + false, + watcherProcessEvents(MAX_BATCHED_WATCHER_EVENTS) + ) + expect(closes).toBe(0) + expect(sink.length).toBeGreaterThan(before) + expect(sink.every((frame) => frame.length <= capacityFor(NODE22_HWM))).toBe(true) + + // The link is still live: the next frame reaches the sink. + const afterBatch = sink.length + dispatcher.notify('pty.exit', { id: 'pty-2', exitCode: 0 }) + expect(sink.length).toBe(afterBatch + 1) + } finally { + dispatcher.dispose() + } + }) + + it('a maximal agent.hook envelope survives a Node<=21-sized sink through publishAgentHookEnvelope', () => { + const sink: Buffer[] = [] + let closes = 0 + const dispatcher = createDispatcher(NODE21_HWM, () => (closes += 1), sink) + + try { + publishAgentHookEnvelope( + dispatcher, + buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope + ) + + expect(closes).toBe(0) + expect(sink).toHaveLength(1) + expect(sink[0].length).toBeLessThanOrEqual(capacityFor(NODE21_HWM)) + + const params = frameParams(sink[0]) + expect(params.paneKey).toBe('worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d') + expect(params.connectionId).toBe('ssh-target-1') + const payload = params.payload as Record + expect(payload.state).toBe('waiting') + expect(payload.lastAssistantMessage).toBeUndefined() + } finally { + dispatcher.dispose() + } + }) + + it("publishAgentHookEnvelope does not mutate the caller's envelope", () => { + const sink: Buffer[] = [] + const dispatcher = createDispatcher(NODE21_HWM, () => {}, sink) + const envelope = buildMaximalHookEnvelope() + const before = structuredClone(envelope) + + try { + publishAgentHookEnvelope(dispatcher, envelope as unknown as AgentHookRelayEnvelope) + // The hook server replays this exact object after --connect, so shedding must never touch it. + expect(envelope).toEqual(before) + } finally { + dispatcher.dispose() + } + }) + + it('reattach + replay does not re-kill', () => { + const sink: Buffer[] = [] + let killCloses = 0 + let retiredSinkCloses = 0 + let replacingSink = false + // setWrite deliberately closes the sink it retires; only a close on the LIVE sink is a kill. + const onClose = (): void => { + if (replacingSink) { + retiredSinkCloses += 1 + } else { + killCloses += 1 + } + } + const dispatcher = createDispatcher(NODE21_HWM, onClose, sink) + const envelope = buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope + + try { + publishAgentHookEnvelope(dispatcher, envelope) + expect(sink).toHaveLength(1) + + for (let reconnect = 0; reconnect < 4; reconnect += 1) { + // Real reattach: relay.ts swaps the sink onto the new SSH channel, then the + // hook server replays its per-paneKey cache (agent-hook-server.ts). + replacingSink = true + dispatcher.setWrite( + (data: Buffer) => { + sink.push(data) + return true + }, + { + writableHighWaterMark: () => NODE21_HWM, + writableLength: () => 0, + supportsWriteCallback: false, + close: onClose + } + ) + replacingSink = false + publishAgentHookEnvelope(dispatcher, envelope) + } + + expect(killCloses).toBe(0) + expect(retiredSinkCloses).toBe(4) + expect(sink).toHaveLength(5) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index ae5781c5745..35b448e8588 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,51 +1,618 @@ -import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' import { RelayDispatcher } from './dispatcher' +import { + DEFAULT_PRODUCER_QUEUE_MAX_BYTES, + DISPATCHER_CONTROL_QUEUE_MAX_FRAMES +} from './dispatcher-writer-admission' +import type { RelayClientSinkOptions, RelayClientWrite } from './dispatcher-writer-sink' +import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-ledger' import { HEADER_LENGTH, parseJsonRpcMessage } from './protocol' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -function frameMethod(frame: Buffer): string { +type WatcherFrameEvent = { kind: string; absolutePath: string; isDirectory?: boolean } + +function frameMessage(frame: Buffer): { method: string; params?: Record } { const payloadLength = frame.readUInt32BE(9) const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) - return 'method' in message ? message.method : '' + return 'method' in message + ? { method: message.method, params: message.params as Record | undefined } + : { method: '' } +} + +function frameMethod(frame: Buffer): string { + return frameMessage(frame).method +} + +function frameEvents(frame: Buffer): WatcherFrameEvent[] { + const params = frameMessage(frame).params + return (params?.events ?? []) as WatcherFrameEvent[] +} + +function createRecordingSink(highWaterMark?: number): { + frames: Buffer[] + closes: () => number + drainWaiters: (() => void)[] + blockNextWrite: () => void + write: RelayClientWrite + options: RelayClientSinkOptions +} { + const frames: Buffer[] = [] + const drainWaiters: (() => void)[] = [] + let closes = 0 + let blocked = false + const write: RelayClientWrite = (frame) => { + frames.push(Buffer.from(frame)) + if (blocked) { + blocked = false + return false + } + return true + } + const options: RelayClientSinkOptions = { + ...(highWaterMark === undefined ? {} : { writableHighWaterMark: () => highWaterMark }), + close: () => { + closes += 1 + }, + waitWriteDrain: (callback) => { + drainWaiters.push(callback) + return () => { + const index = drainWaiters.indexOf(callback) + if (index >= 0) { + drainWaiters.splice(index, 1) + } + } + } + } + return { + frames, + closes: () => closes, + drainWaiters, + blockNextWrite: () => { + blocked = true + }, + write, + options + } +} + +function watcherBatch(count: number): { type: 'create'; path: string; isDirectory: false }[] { + return Array.from({ length: count }, (_unused, index) => ({ + type: 'create' as const, + path: `/workspace/project/src/module-${index}/component-${index}.tsx`, + isDirectory: false as const + })) +} + +function parentDir(absolutePath: string): string { + return absolutePath.slice(0, absolutePath.lastIndexOf('/')) +} + +// Fills the 2 MB producer queue so every subsequent watcher batch is rejected on the producer lane. +function saturateProducerQueue(dispatcher: RelayDispatcher): void { + let admitted = 0 + for (const chunk of [40_000, 1_000, 1]) { + while ( + admitted < 5_000 && + dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x'.repeat(chunk) }) + ) { + admitted += 1 + } + } + expect(admitted).toBeGreaterThan(0) + expect(admitted).toBeLessThan(5_000) +} + +// Parks PTY frames in the producer queue of a stalled sink without filling it. +function parkProducerBytes(dispatcher: RelayDispatcher, bytes: number): number { + const chunk = 'x'.repeat(40_000) + let parked = 0 + while (parked < bytes && dispatcher.tryNotifyPtyData({ paneId: 'pane', data: chunk })) { + parked += chunk.length + } + expect(parked).toBeGreaterThanOrEqual(bytes) + return parked +} + +// Parks PTY frames in ONE client's producer queue, leaving every peer's retention untouched. +function parkProducerBytesForClient( + dispatcher: RelayDispatcher, + clientId: number, + bytes: number +): number { + const chunk = 'x'.repeat(40_000) + let parked = 0 + while ( + parked < bytes && + dispatcher.publishProducerNotification(clientId, 'pty.data', { paneId: 'pane', data: chunk }) + ) { + parked += chunk.length + } + expect(parked).toBeGreaterThanOrEqual(bytes) + return parked +} + +function isOverflowFrame(frame: Buffer): boolean { + return frameEvents(frame).some((event) => event.kind === 'overflow') +} + +function watcherFrames(frames: readonly Buffer[]): Buffer[] { + return frames.filter((frame) => frameMethod(frame) === 'fs.changed') +} + +function deliveredEvents(events: readonly WatcherProcessEvent[]): WatcherFrameEvent[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) } describe('relay watcher writer admission', () => { - it('keeps watcher batches on the bounded ordinary lane under saturation', () => { - const frames: Buffer[] = [] - const drainWaiters: (() => void)[] = [] - let saturate = true - const dispatcher = new RelayDispatcher( - (frame) => { - frames.push(Buffer.from(frame)) - if (saturate) { - saturate = false - return false - } - return true - }, - { - waitWriteDrain: (callback) => { - drainWaiters.push(callback) - return () => { - const index = drainWaiters.indexOf(callback) - if (index >= 0) { - drainWaiters.splice(index, 1) - } - } - } - } - ) + it('keeps watcher batches on the producer lane while overflow markers ride the control lane', () => { + const sink = createRecordingSink() + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) try { - emitRelayWatcherEvents(dispatcher, false, [ + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ { type: 'create', path: '/workspace/first', isDirectory: false } ]) emitRelayWatcherOverflow(dispatcher, '/workspace', false) dispatcher.notifyClient(1, 'control.event') - expect(frames.map(frameMethod)).toEqual(['fs.changed']) - drainWaiters.shift()?.() - expect(frames.map(frameMethod)).toEqual(['fs.changed', 'control.event', 'fs.changed']) + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) + sink.drainWaiters.shift()?.() + // The marker now shares the control lane with control.event and drains ahead of the producer lanes. + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed', 'fs.changed', 'control.event']) + expect(frameEvents(sink.frames[1])).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('relay watcher overflow suppression key', () => { + it('keeps the source free of NUL bytes so git and grep still see text', () => { + const source = readFileSync( + fileURLToPath(new URL('./relay-watcher-event-emitter.ts', import.meta.url)) + ) + expect(source.includes(0)).toBe(false) + }) + + it('scopes the outstanding marker per client as well as per root', () => { + const primary = createRecordingSink(65536) + const secondary = createRecordingSink(65536) + primary.blockNextWrite() + secondary.blockNextWrite() + const dispatcher = new RelayDispatcher(primary.write, primary.options) + + try { + dispatcher.attachClient(secondary.write, secondary.options) + // Two clients, same root: a key that collapses them would silently desync the second tree. + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + primary.drainWaiters.shift()?.() + secondary.drainWaiters.shift()?.() + + for (const sink of [primary, secondary]) { + expect(sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed')).toHaveLength(1) + expect(sink.closes()).toBe(0) + } + } finally { + dispatcher.dispose() + } + }) + + it('releases the outstanding key when notification admission rejects without settlement', () => { + const sink = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const notify = vi.spyOn(dispatcher, 'tryNotifyClient').mockReturnValue(false) + + try { + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(notify).toHaveBeenCalledTimes(2) + } finally { + notify.mockRestore() + dispatcher.dispose() + } + }) + + it('fails soft and releases the outstanding key when the control queue is full', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(sink.frames)).toHaveLength(0) + expect(sink.closes()).toBe(0) + + sink.drainWaiters.shift()?.() + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('relay watcher batch chunking', () => { + it.each([ + [65536, 49152], + [16384, 12288] + ])('splits a 5000-event batch into frames that fit a %i-byte sink', (highWaterMark, capacity) => { + const sink = createRecordingSink(highWaterMark) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + expect(sink.frames.every((frame) => frameMethod(frame) === 'fs.changed')).toBe(true) + expect(sink.frames.every((frame) => frame.length <= capacity)).toBe(true) + expect(sink.frames.flatMap(frameEvents)).toEqual( + events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) + ) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('encodes the empty envelope once instead of re-encoding candidate chunks', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const budget = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + expect(budget).toHaveBeenCalledTimes(1) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(5000) + } finally { + budget.mockRestore() + dispatcher.dispose() + } + }) + + it('sizes each event once across clients that need different chunk cuts', () => { + const primary = createRecordingSink(16384) + const secondary = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stringify = vi.spyOn(JSON, 'stringify') + const events = watcherBatch(5000) + + try { + dispatcher.attachClient(secondary.write, secondary.options) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const sizedEvents = stringify.mock.calls.filter(([value]) => { + if (typeof value !== 'object' || value === null) { + return false + } + return 'kind' in value && 'absolutePath' in value + }) + expect(sizedEvents).toHaveLength(events.length) + expect(primary.frames.flatMap(frameEvents)).toHaveLength(events.length) + expect(secondary.frames.flatMap(frameEvents)).toHaveLength(events.length) + } finally { + stringify.mockRestore() + dispatcher.dispose() + } + }) + + it('falls back to one overflow marker when a single event exceeds the frame budget', () => { + const sink = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'update', path: `/workspace/${'p'.repeat(60_000)}`, isDirectory: false } + ]) + + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) + expect(frameEvents(sink.frames[0])).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('emits the overflow marker on the control lane when a chunk is rejected by a full producer queue', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + // Fill the 2 MB producer queue down to a residue smaller than a one-event fs.changed frame. + saturateProducerQueue(dispatcher) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: '/workspace/first', isDirectory: false } + ]) + sink.drainWaiters.shift()?.() + + const markers = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') + expect(markers).toHaveLength(1) + expect(frameEvents(markers[0])).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) + // Control lane preempts: the marker lands before the producer frames still queued behind it. + expect(sink.frames.indexOf(markers[0])).toBeLessThan(sink.frames.length - 1) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps at most one outstanding overflow marker per root without closing the client', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + saturateProducerQueue(dispatcher) + + // More rejected batches than the 256-frame control queue can hold: one marker each would close the link. + for (let index = 0; index < 300; index += 1) { + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: `/workspace/file-${index}`, isDirectory: false } + ]) + } + // A second root must still get its own marker, or that tree silently desyncs. + emitRelayWatcherEvents(dispatcher, '/other', false, [ + { type: 'create', path: '/other/file', isDirectory: false } + ]) + expect(sink.closes()).toBe(0) + + sink.drainWaiters.shift()?.() + const markers = sink.frames + .filter((frame) => frameMethod(frame) === 'fs.changed') + .flatMap(frameEvents) + expect(markers).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' }, + { kind: 'overflow', absolutePath: '/other' } + ]) + expect(sink.closes()).toBe(0) + + // The slot clears once the marker drains, so a later batch can still resync the same root. + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: '/workspace/later', isDirectory: false } + ]) + expect( + sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed').flatMap(frameEvents) + ).toHaveLength(3) + } finally { + dispatcher.dispose() + } + }) + + it('keeps one directory in one chunk when the batch interleaves directories', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const directoryCount = 8 + const replacedPath = '/workspace/project/src/module-7/component-0.tsx' + const events: WatcherProcessEvent[] = [ + ...Array.from({ length: 200 }, (_unused, index) => ({ + type: 'create' as const, + path: `/workspace/project/src/module-${index % directoryCount}/component-${Math.floor(index / directoryCount)}.tsx`, + isDirectory: false + })), + { type: 'delete' as const, path: replacedPath } + ] + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + const frameDirs = sink.frames.map( + (frame) => new Set(frameEvents(frame).map((event) => parentDir(event.absolutePath))) + ) + const dirs = Array.from(new Set(events.map((event) => parentDir(event.path)))) + expect(dirs).toHaveLength(directoryCount) + // Each directory in one frame; the renderer dedupes refreshes per payload, so extra frames = extra readDir RPCs. + const refreshes = frameDirs.reduce((total, frameDir) => total + frameDir.size, 0) + expect(refreshes).toBeLessThanOrEqual(directoryCount + sink.frames.length - 1) + + const delivered = sink.frames.flatMap(frameEvents) + for (const dir of dirs) { + const positions = delivered + .map((event, index) => ({ event, index })) + .filter((entry) => parentDir(entry.event.absolutePath) === dir) + .map((entry) => entry.index) + expect(positions.at(-1)! - positions[0]).toBe(positions.length - 1) + } + // Stable grouping keeps per-path order: the create still precedes the delete of the same path. + expect( + delivered.filter((event) => event.absolutePath === replacedPath).map((event) => event.kind) + ).toEqual(['create', 'delete']) + expect(delivered).toHaveLength(events.length) + } finally { + dispatcher.dispose() + } + }) + + it('does not report a drop for a batch it goes on to deliver in full', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + try { + const events = watcherBatch(5000) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) + // A "Dropped fs.changed" line here would both lie and burn the one-per-generation + // suppression slot a genuine over-capacity drop needs. + const lines = stderr.mock.calls.map(([line]) => String(line)) + expect(lines.filter((line) => line.includes('fs.changed'))).toEqual([]) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('sizes chunks near the byte minimum instead of overshooting it', () => { + const sink = createRecordingSink(16384) + const capacity = 12288 + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + + const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) + expect(sink.frames.length).toBeLessThanOrEqual(Math.ceil(totalBytes / capacity) + 1) + for (const frame of sink.frames.slice(0, -1)) { + expect(frame.length).toBeGreaterThanOrEqual(capacity * 0.9) + } + } finally { + dispatcher.dispose() + } + }) + + it('stops the chunk walk while the producer queue sits past its retention reserve', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + // Past the retention reserve but far from the 2 MB queue limit: every chunk would still be admitted. + const parked = parkProducerBytes(dispatcher, LEGACY_CLIENT_RETAINED_BYTES_LOW) + expect(parked).toBeLessThan(DEFAULT_PRODUCER_QUEUE_MAX_BYTES / 2 + 256 * 1024) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + sink.drainWaiters.shift()?.() + + const watcherFrames = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') + expect(watcherFrames.map(isOverflowFrame)).toEqual([true]) + // The flood injected nothing: the interactive lane keeps the whole reserve instead of ~425 KB less. + const injected = watcherFrames + .filter((frame) => !isOverflowFrame(frame)) + .reduce((total, frame) => total + frame.length, 0) + expect(injected).toBe(0) + expect(injected).toBeLessThan( + DEFAULT_PRODUCER_QUEUE_MAX_BYTES - LEGACY_CLIENT_RETAINED_BYTES_LOW + ) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('delivers a whole flood to a healthy client while a second client sits past its reserve', () => { + const stalled = createRecordingSink(65536) + const healthy = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + const events = watcherBatch(5000) + + try { + dispatcher.attachClient(healthy.write, healthy.options) + const stalledId = dispatcher.activeClientIds()[0] + parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + stalled.drainWaiters.shift()?.() + + // A resync forces a readDir per directory, so an unrelated peer's stall must never trigger one here. + expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) + expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) + expect(watcherFrames(stalled.frames).map(isOverflowFrame)).toEqual([true]) + expect(healthy.closes()).toBe(0) + expect(stalled.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('stops the walk for the congested client while a healthy peer takes the whole flood', () => { + const healthy = createRecordingSink(65536) + const stalled = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(healthy.write, healthy.options) + const events = watcherBatch(5000) + + try { + // Reversed attach order from the peer-stall case: the gate must read the client being written to. + const stalledId = dispatcher.attachClient(stalled.write, stalled.options) + parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + stalled.drainWaiters.shift()?.() + + const stalledWatcher = watcherFrames(stalled.frames) + expect(stalledWatcher.map(isOverflowFrame)).toEqual([true]) + const injected = stalledWatcher + .filter((frame) => !isOverflowFrame(frame)) + .reduce((total, frame) => total + frame.length, 0) + // Nothing injected: the congested client keeps its whole reserve for interactive PTY frames. + expect(injected).toBe(0) + expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) + expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) + expect(healthy.closes()).toBe(0) + expect(stalled.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('still delivers a flood larger than the reserve while the sink keeps draining', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const events = watcherBatch(20_000) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) + expect(totalBytes).toBeGreaterThan(LEGACY_CLIENT_RETAINED_BYTES_LOW) + expect(sink.frames.some(isOverflowFrame)).toBe(false) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) + } finally { + dispatcher.dispose() + } + }) + + it('chunks per client so a small sink never degrades a healthy one', () => { + const healthy = createRecordingSink(65536) + const small = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(healthy.write, healthy.options) + + try { + dispatcher.attachClient(small.write, small.options) + const events = watcherBatch(200) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const expected = events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) + expect(healthy.frames).toHaveLength(1) + expect(frameEvents(healthy.frames[0])).toEqual(expected) + expect(small.frames.length).toBeGreaterThan(1) + expect(small.frames.every((frame) => frame.length <= 12288)).toBe(true) + expect(small.frames.flatMap(frameEvents)).toEqual(expected) + expect(healthy.closes()).toBe(0) + expect(small.closes()).toBe(0) } finally { dispatcher.dispose() } diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 05cdd68c281..54aa6af8ca2 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -1,52 +1,197 @@ import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { resolveRuntimePath } from '../shared/cross-platform-path' import type { RelayDispatcher } from './dispatcher' -const FS_CHANGED = 'fs.changed' - -type RelayWatcherEventPayload = { +type MappedWatcherEvent = { kind: string absolutePath: string isDirectory?: boolean } +type WatcherBatchSizing = { + eventBytes: Map + batchBytes: number +} + +// Why: one outstanding marker per (client, root) keeps sustained backpressure bounded. +const outstandingOverflowMarkers = new WeakMap>() + export function emitRelayWatcherEvents( dispatcher: RelayDispatcher, + rootPath: string, closed: boolean, events: readonly WatcherProcessEvent[] ): void { if (closed || events.length === 0) { return } - const frameCapacity = dispatcher.broadcastProducerFrameCapacity() - if (frameCapacity === undefined) { + const mapped: MappedWatcherEvent[] = events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) + // Grouping walks every path, so only the chunking path pays for it — and only once across all clients. + let grouped: MappedWatcherEvent[] | null = null + const groupedByDirectory = (): MappedWatcherEvent[] => + (grouped ??= groupWatcherEventsByDirectory(mapped)) + let sizing: WatcherBatchSizing | null = null + const batchSizing = (): WatcherBatchSizing => (sizing ??= measureWatcherBatch(mapped)) + for (const clientId of dispatcher.activeClientIds()) { + publishWatcherBatchToClient( + dispatcher, + clientId, + rootPath, + mapped, + groupedByDirectory, + batchSizing + ) + } +} + +/** + * Why: the renderer dedupes directory refreshes within a SINGLE fs.changed payload, so a directory + * scattered across chunks costs one forced readDir RPC per chunk. Grouping is stable, so events for a + * given path keep their relative order and a create-then-delete can never invert. + */ +function groupWatcherEventsByDirectory( + mapped: readonly MappedWatcherEvent[] +): MappedWatcherEvent[] { + const groups = new Map() + for (const event of mapped) { + // Runs on the remote host: derive the parent with the runtime-flavored resolver, not a '/' split. + const parentPath = resolveRuntimePath(event.absolutePath, '..') + const group = groups.get(parentPath) + if (group) { + group.push(event) + } else { + groups.set(parentPath, [event]) + } + } + return Array.from(groups.values()).flat() +} + +function encodedWatcherEventBytes(event: MappedWatcherEvent): number { + return Buffer.byteLength(JSON.stringify(event)) +} + +function measureWatcherBatch(mapped: readonly MappedWatcherEvent[]): WatcherBatchSizing { + const eventBytes = new Map() + let batchBytes = Math.max(0, mapped.length - 1) + for (const event of mapped) { + const bytes = encodedWatcherEventBytes(event) + eventBytes.set(event, bytes) + batchBytes += bytes + } + return { eventBytes, batchBytes } +} + +// Batches are sized to each sink's frame capacity; a batch that cannot be sized degrades to an overflow resync. +function publishWatcherBatchToClient( + dispatcher: RelayDispatcher, + clientId: number, + rootPath: string, + mapped: readonly MappedWatcherEvent[], + groupedByDirectory: () => readonly MappedWatcherEvent[], + batchSizing: () => WatcherBatchSizing +): void { + const publish = (events: readonly MappedWatcherEvent[]): boolean => + dispatcher.publishProducerNotification(clientId, 'fs.changed', { events }) + + // Fast path: publish the whole batch first — two encodes, the same cost as an unchunked emit. + // logDrop:false because rejection here is a measurement, not an outcome: the batch is re-sent in + // chunks below, so logging it would report a drop for events that all arrive. + if ( + dispatcher.publishProducerNotification( + clientId, + 'fs.changed', + { events: mapped }, + { + logDrop: false + } + ) + ) { 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) { + + // Rejection is ambiguous: an over-capacity frame is chunkable, a full producer queue is real data loss. + // The empty envelope is encoded once; event JSON sizes are exact deltas apart from array commas. + const eventsCapacity = dispatcher.producerEnvelopeBudget('fs.changed', { events: [] }, clientId) + if (eventsCapacity < 0) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return + } + const { eventBytes, batchBytes } = batchSizing() + if (batchBytes <= eventsCapacity) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return + } + + const grouped = groupedByDirectory() + const groupedEventBytes = grouped.map((event) => eventBytes.get(event)!) + let index = 0 + while (index < grouped.length) { + // Why: the retention ledger covers every producer publication despite its legacy name, and admission + // is lane-agnostic: chunks queued past its low-water reserve (half the 2 MB queue) starve interactive + // PTY frames until pty-handler pauses every remote pane. A resync costs the user far less. + // Per client, never dispatcher-wide: one stalled peer must not cost a healthy client a resync, + // which forces a readDir per directory in its file tree. + if (!dispatcher.producerRetentionBelowLowWater(clientId)) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) 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 }) + let end = index + let chunkBytes = 0 + while (end < grouped.length) { + const nextBytes = groupedEventBytes[end] + (end === index ? 0 : 1) + if (chunkBytes + nextBytes > eventsCapacity) { + break + } + chunkBytes += nextBytes + end += 1 } - const payloadBytes = Buffer.byteLength(JSON.stringify(payload)) - if (chunk.length > 0 && chunkBytes + 1 + payloadBytes > frameCapacity) { - flush() + if (end === index || !publish(grouped.slice(index, end))) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return } - chunkBytes += payloadBytes + (chunk.length === 0 ? 0 : 1) - chunk.push(payload) + index = end + } +} + +// Why: the control lane — on the producer lane the marker would hit the same full queue that just +// rejected the batch and be dropped, silently desyncing the remote file tree. +function emitWatcherOverflowToClient( + dispatcher: RelayDispatcher, + clientId: number, + rootPath: string +): void { + // Per root, never per client alone: an outstanding marker for one tree must not suppress another's resync. + const key = `${clientId} ${rootPath}` + const outstanding = outstandingOverflowMarkers.get(dispatcher) ?? new Set() + if (outstanding.has(key)) { + return + } + outstanding.add(key) + outstandingOverflowMarkers.set(dispatcher, outstanding) + const release = (): void => { + outstanding.delete(key) + if (outstanding.size === 0) { + outstandingOverflowMarkers.delete(dispatcher) + } + } + const accepted = dispatcher.tryNotifyClient( + clientId, + 'fs.changed', + { events: [{ kind: 'overflow', absolutePath: rootPath }] }, + () => { + // Settles on write, drop, or client close, so the slot can never leak. + release() + }, + { controlOverflow: 'reject' } + ) + // Admission rejection has no settlement callback, so release the slot directly. + if (!accepted) { + release() } - flush() } export function emitRelayWatcherOverflow( @@ -54,9 +199,10 @@ export function emitRelayWatcherOverflow( rootPath: string, closed: boolean ): void { - if (!closed) { - dispatcher.notify(FS_CHANGED, { - events: [{ kind: 'overflow', absolutePath: rootPath }] - }) + if (closed) { + return + } + for (const clientId of dispatcher.activeClientIds()) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) } } diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts index 5d505fb8aaa..8b9782972c8 100644 --- a/src/relay/relay-watcher-frame-chunking.test.ts +++ b/src/relay/relay-watcher-frame-chunking.test.ts @@ -134,7 +134,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = watcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -152,7 +152,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = watcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -170,7 +170,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = multiByteWatcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -190,7 +190,7 @@ describe('relay watcher fs.changed frame chunking', () => { const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) expect(capture.frames).toHaveLength(2) @@ -208,7 +208,7 @@ describe('relay watcher fs.changed frame chunking', () => { const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) try { - emitRelayWatcherEvents(dispatcher, false, overshoot) + emitRelayWatcherEvents(dispatcher, '/workspace', false, overshoot) expect(detached).toEqual([]) expect(capture.frames).toHaveLength(2) @@ -221,7 +221,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('sizes chunks against the smallest capacity across attached clients', () => { + it('sizes chunks independently for clients with different capacities', () => { const { capture: attachedCapture, write: attachedWrite } = captureFrames() const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) const events = watcherBatch(2000) @@ -232,10 +232,13 @@ describe('relay watcher fs.changed frame chunking', () => { writableLength: () => 0 }) - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) - for (const frame of [...capture.frames, ...attachedCapture.frames]) { + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) + } + for (const frame of attachedCapture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) @@ -245,7 +248,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('still closes the client when a single event cannot fit any frame', () => { + it('resyncs without closing 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[] = [ @@ -253,16 +256,16 @@ describe('relay watcher fs.changed frame chunking', () => { ] try { - emitRelayWatcherEvents(dispatcher, false, oversized) + emitRelayWatcherEvents(dispatcher, '/workspace', false, oversized) - expect(detached).toEqual([1]) - expect(capture.frames).toEqual([]) + expect(detached).toEqual([]) + expect(capture.fsChangedEvents()).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) } finally { dispatcher.dispose() } }) - it('delivers the prefix then closes when an oversized event appears mid-batch', () => { + it('delivers the prefix then resyncs 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 = { @@ -273,13 +276,15 @@ describe('relay watcher fs.changed frame chunking', () => { const events = [...watcherBatch(300), oversized, ...watcherBatch(50)] try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - expect(detached).toEqual([1]) + expect(detached).toEqual([]) 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) + expect(delivered.at(-1)).toEqual({ kind: 'overflow', absolutePath: '/workspace' }) + const prefix = delivered.slice(0, -1) + expect(prefix).toEqual(expectedPayloads(events.filter((event) => event !== oversized))) + expect(prefix.some((event) => event.absolutePath === oversized.path)).toBe(false) for (const frame of capture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } @@ -288,7 +293,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('keeps the fail-closed kill when retained chunks exhaust the producer queue budget', () => { + it('keeps the client alive 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, { @@ -300,15 +305,18 @@ describe('relay watcher fs.changed frame chunking', () => { for (let round = 0; round < 10 && detached.length === 0; round++) { const events = watcherBatch(5000) emitted.push(...events) - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) } - expect(detached).toEqual([1]) + expect(detached).toEqual([]) 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)) + const producerEvents = delivered.filter((event) => event.kind !== 'overflow') + expect(delivered.filter((event) => event.kind === 'overflow')).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(producerEvents).toEqual(expectedPayloads(emitted).slice(0, producerEvents.length)) for (const frame of capture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } @@ -326,7 +334,7 @@ describe('relay watcher fs.changed frame chunking', () => { const notify = vi.spyOn(dispatcher, 'notify') try { dispatcher.invalidateClient() - emitRelayWatcherEvents(dispatcher, false, watcherBatch(10)) + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(10)) expect(notify).not.toHaveBeenCalled() expect(capture.frames).toEqual([]) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index f807fc6ac2f..516bbadc6dc 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -36,9 +36,9 @@ import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-se import { PluginOverlayManager } from './plugin-overlay' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD @@ -714,13 +714,8 @@ async function main(): Promise { const hookServer = new RelayAgentHookServer({ // Why: scope endpoint.env/cmd by socket path so multiple relay daemons on one account can't overwrite each other's hook tokens. endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), - forward: (envelope) => { - // Why: notify is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) - } + // Why: publication is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) }) // Why: await the bind before announcing readiness so the first PTY spawn already sees ORCA_AGENT_HOOK_* env; bind failure is soft (log and continue). try { diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts index 384fbc2c303..7f3ee8e4062 100644 --- a/src/relay/wsl-agent-hook-relay.ts +++ b/src/relay/wsl-agent-hook-relay.ts @@ -21,9 +21,9 @@ import { createInstallPluginsHandler } from './wsl-install-plugins-handler' import { PreflightHandler } from './preflight-handler' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { sanitizeWslHookInstanceKey, WSL_HOOK_RELAY_INSTANCE_ENV, @@ -68,11 +68,7 @@ async function main(): Promise { endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey), token, preferredPort: windowsPort, - forward: (envelope) => - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) }) new PreflightHandler(dispatcher) diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts index 27aec405236..338581c2859 100644 --- a/src/shared/agent-hook-relay.test.ts +++ b/src/shared/agent-hook-relay.test.ts @@ -3,10 +3,14 @@ import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, + AGENT_HOOK_SHED_FIELDS_KEY, ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, + createShedSubagentsField, isRemoteAgentHooksEnabled, + restoreShedStatusFields, type AgentHookRelayEnvelope } from './agent-hook-relay' +import type { ParsedAgentStatusPayload } from './agent-status-types' describe('agent-hook-relay wire shape', () => { it('encodes/decodes through JSON without losing fields', () => { @@ -55,3 +59,77 @@ describe('isRemoteAgentHooksEnabled', () => { expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true) }) }) + +describe('restoreShedStatusFields', () => { + const roster = [{ id: 'child-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 }] + const cached: ParsedAgentStatusPayload = { + state: 'working', + prompt: 'p', + agentType: 'claude', + subagents: roster, + lastAssistantMessage: 'cached message', + interactivePrompt: '{"questions":["old"]}' + } + const shed: ParsedAgentStatusPayload = { state: 'done', prompt: 'p', agentType: 'claude' } + + it('restores a matching shed roster, so a done pane is not falsely hibernation-eligible', () => { + const restored = restoreShedStatusFields(shed, [createShedSubagentsField(roster)], cached) + expect(restored.subagents).toEqual(roster) + expect(restored.state).toBe('done') + // Only the named field comes back. + expect(restored.lastAssistantMessage).toBeUndefined() + }) + + it('does not restore a shed roster across a prompt identity change', () => { + expect(AGENT_HOOK_SHED_FIELDS_KEY).toBe('shedFields') + const nextTurn = { ...shed, prompt: 'next prompt' } + const restored = restoreShedStatusFields( + nextTurn, + ['lastAssistantMessage', createShedSubagentsField(roster)], + cached + ) + expect(restored.lastAssistantMessage).toBeUndefined() + expect(restored.subagents).toBeUndefined() + expect(restored).toBe(nextTurn) + }) + + it('does not restore the cached roster when the shed roster digest changed', () => { + const changedRoster = [ + { id: 'child-2', agentType: 'reviewer', state: 'working' as const, startedAt: 2 } + ] + const restored = restoreShedStatusFields( + shed, + [createShedSubagentsField(changedRoster)], + cached + ) + expect(restored.subagents).toBeUndefined() + expect(restored).toBe(shed) + }) + + it('never restores interactivePrompt — a stale answerable card is worse than none', () => { + const restored = restoreShedStatusFields(shed, ['interactivePrompt'], cached) + expect(restored.interactivePrompt).toBeUndefined() + expect(restored).toBe(shed) + }) + + it('leaves an intentional lastAssistantMessage clear intact when the relay shed nothing', () => { + expect(restoreShedStatusFields(shed, undefined, cached)).toBe(shed) + expect(restoreShedStatusFields(shed, [], cached)).toBe(shed) + expect(restoreShedStatusFields(shed, [], cached).lastAssistantMessage).toBeUndefined() + // A hostile/garbled marker must not throw or restore. + expect(restoreShedStatusFields(shed, 'subagents', cached)).toBe(shed) + expect(restoreShedStatusFields(shed, ['subagents'], cached)).toBe(shed) + expect(restoreShedStatusFields(shed, [{ toString: () => 'subagents' }], cached)).toBe(shed) + }) + + it('does not overwrite a value the envelope still carries', () => { + const fresh: ParsedAgentStatusPayload = { ...shed, lastAssistantMessage: 'fresh' } + expect( + restoreShedStatusFields(fresh, ['lastAssistantMessage'], cached).lastAssistantMessage + ).toBe('fresh') + }) + + it('is a no-op with no cached payload for the pane', () => { + expect(restoreShedStatusFields(shed, ['subagents'], undefined)).toBe(shed) + }) +}) diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 71e7953170a..ebb3648a2ff 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -22,7 +22,9 @@ // POST body so Orca's warn-once protocol diagnostics still fire. The relay // default env is `remote`, a location marker ignored by dev-vs-prod checks. -import type { ParsedAgentStatusPayload } from './agent-status-types' +import { createHash } from 'node:crypto' + +import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentHookTarget } from './agent-hook-types' @@ -99,6 +101,82 @@ export type AgentHookRelayEnvelope = { /** JSON-RPC notification method name carried over the relay control channel. */ export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const +/** Identifies optional payload fields the relay dropped to fit an oversized frame + * (see `src/relay/agent-hook-envelope-publication.ts`), so `ingestRemote` can tell + * "shed in transit" from "the agent cleared it"; rosters include their digest. */ +export const AGENT_HOOK_SHED_FIELDS_KEY = 'shedFields' as const +const AGENT_HOOK_SHED_SUBAGENTS_DIGEST_PREFIX = 'subagents:sha256:' + +function subagentRosterDigest(subagents: readonly AgentSubagentSnapshot[]): string { + const stableRoster = subagents.map(({ id, state, startedAt, agentType, model, description }) => [ + id, + state, + startedAt, + agentType ?? null, + model ?? null, + description ?? null + ]) + return createHash('sha256').update(JSON.stringify(stableRoster)).digest('base64url') +} + +/** Carries the dropped roster identity without carrying its full rows. */ +export function createShedSubagentsField(subagents: readonly AgentSubagentSnapshot[]): string { + return `${AGENT_HOOK_SHED_SUBAGENTS_DIGEST_PREFIX}${subagentRosterDigest(subagents)}` +} + +function hasMatchingShedSubagentsField( + shedFields: readonly unknown[], + previous: ParsedAgentStatusPayload +): boolean { + if (!previous.subagents) { + return false + } + const expected = createShedSubagentsField(previous.subagents) + return shedFields.some((field) => field === expected) +} + +function hasMatchingTurnIdentity( + payload: ParsedAgentStatusPayload, + previous: ParsedAgentStatusPayload +): boolean { + return ( + payload.prompt === previous.prompt && + payload.agentType === previous.agentType && + payload.model === previous.model + ) +} + +/** + * Re-attaches shed fields from Orca's cached payload for this pane, so a transport-level + * truncation cannot read as a cleared field — an absent `subagents` blanks live child rows and + * unblocks hibernation for a pane whose teammates are still running. + * + * `interactivePrompt` and `lastAssistantMessage` are deliberately not restored: cached prose can + * belong to an earlier turn. A roster returns only when its wire digest and turn identity match. + */ +export function restoreShedStatusFields( + payload: ParsedAgentStatusPayload, + shedFields: unknown, + previous: ParsedAgentStatusPayload | undefined +): ParsedAgentStatusPayload { + if (!previous || !Array.isArray(shedFields) || shedFields.length === 0) { + return payload + } + const subagents = + payload.subagents === undefined && + hasMatchingTurnIdentity(payload, previous) && + hasMatchingShedSubagentsField(shedFields, previous) + ? previous.subagents + : undefined + if (subagents === undefined) { + return payload + } + return { + ...payload, + subagents + } +} + /** JSON-RPC request method Orca issues after `--connect` reattach to ask the * relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the * design doc for the race that ruled out push-on-`setWrite`. */ diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index 23559a63f42..a73ffae6bc9 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -591,7 +591,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `terminal-perf-${args.label}-${stamp}.json` From c5c10e1203a1ea72e3c59bc9aea4c8f2553855ed Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:49:33 -0700 Subject: [PATCH 08/64] Fix POSIX path coalescing and file explorer watch regressions (#12004) * 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(watcher/explorer/relay): coalesce POSIX paths by byte identity; pres - Remote watcher event coalescing now keeps NFC/NFD-distinct POSIX paths separate while still folding Windows path spellings, fixing cache invalidation when recreating directories with different Unicode compositions. - Relay grace reconfiguration now preserves shutdown-deferred state when grace is set to zero, preventing premature shutdown when the grace timer is reconfigured mid-flight. - File explorer refresh refactored from fixed-wave batching to concurrent task execution with `forEachWithConcurrency`, batching results every N settled reads instead of every wave, and reporting whether cancellation discarded pending work. - Watch handler now resyncs when events arrive after disposal, ensuring refresh requests aren't lost when events race cleanup during worktree switches. * fix(watcher/explorer): resilient commit batches on callback error Cache writes precede callbacks, so a throwing callback cannot strand the batch with stale marks. All callbacks complete despite errors, with the first error thrown after. - Fix commitBatchSize calculation for empty dirs - Add scheduler discard-report test - Move disposed variable before handleFsChanged - Expand batching-strategy comments --------- Co-authored-by: Orca --- .../ipc/remote-watcher-event-batch.test.ts | 46 ++++- src/main/ipc/remote-watcher-event-batch.ts | 33 ++-- src/relay/relay-grace-branch.test.ts | 19 +- src/relay/relay-grace-branch.ts | 6 + src/relay/relay.ts | 10 +- ...ile-explorer-expanded-dirs-refresh.test.ts | 138 ++++++++++++-- .../file-explorer-expanded-dirs-refresh.ts | 145 ++++++++------- ...e-explorer-watch-refresh-scheduler.test.ts | 23 ++- .../file-explorer-watch-refresh-scheduler.ts | 6 +- ...FileExplorerWatch.pending-refresh.test.tsx | 173 ++++++++++++++++++ .../right-sidebar/useFileExplorerWatch.ts | 61 ++++-- 11 files changed, 545 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx diff --git a/src/main/ipc/remote-watcher-event-batch.test.ts b/src/main/ipc/remote-watcher-event-batch.test.ts index b659c446aed..19e946d4007 100644 --- a/src/main/ipc/remote-watcher-event-batch.test.ts +++ b/src/main/ipc/remote-watcher-event-batch.test.ts @@ -80,19 +80,20 @@ describe('createRemoteWatcherEventBatch', () => { expect(deliver).not.toHaveBeenCalled() }) - it('clears a stale delete when a later update is known to be a file', () => { + it('keeps a replacement delete when a directory is recreated as 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 } + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'create', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false } ]) vi.advanceTimersByTime(150) expect(deliver.mock.calls[0][0]).toEqual([ - { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false } ]) }) @@ -267,4 +268,39 @@ describe('createRemoteWatcherEventBatch', () => { expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/) } }) + + it('keeps NFC- and NFD-distinct remote POSIX names separate', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + const nfcPath = `${ROOT}/caf\u00e9.txt` + const nfdPath = `${ROOT}/cafe\u0301.txt` + + batch.push([{ kind: 'update', absolutePath: nfcPath }]) + batch.push([{ kind: 'update', absolutePath: nfdPath }]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: nfcPath }, + { kind: 'update', absolutePath: nfdPath } + ]) + }) + + it('still coalesces equivalent Windows path spellings', () => { + const deliver = vi.fn() + const batch = createRemoteWatcherEventBatch({ + rootPath: 'C:\\Repo', + deliver, + trailingMs: 150, + maxWaitMs: 500, + maxEvents: 5_000 + }) + + batch.push([{ kind: 'update', absolutePath: 'C:\\Repo\\CAF\u00c9.txt' }]) + batch.push([{ kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' }]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' } + ]) + }) }) diff --git a/src/main/ipc/remote-watcher-event-batch.ts b/src/main/ipc/remote-watcher-event-batch.ts index 86f10e81d96..713c6068032 100644 --- a/src/main/ipc/remote-watcher-event-batch.ts +++ b/src/main/ipc/remote-watcher-event-batch.ts @@ -1,5 +1,8 @@ import type { FsChangeEvent } from '../../shared/types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' export type RemoteWatcherEventBatchOptions = { rootPath: string @@ -14,13 +17,18 @@ export type RemoteWatcherEventBatch = { 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[] { +// Unlike local coalescing, a recreate keeps its delete through live updates so cached dirs are purged; +// delete→create→delete also remains a net delete because the path predated the batch. +function posixEventIdentity(absolutePath: string): string { + const normalized = absolutePath.replace(/\/+/g, '/') + return normalized === '/' ? normalized : normalized.replace(/\/+$/, '') +} + +function coalesceRemoteEvents(raw: FsChangeEvent[], rootPath: string): FsChangeEvent[] { const lastByKey = new Map() const deleteBeforeCreate = new Map() const passthrough: FsChangeEvent[] = [] + const windowsPaths = isWindowsAbsolutePathLike(rootPath) for (const event of raw) { if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') { @@ -28,10 +36,10 @@ function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { 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) + // POSIX event identity keeps byte-distinct names; Windows still folds separators and casing. + const key = windowsPaths + ? normalizeRuntimePathForComparison(event.absolutePath) + : posixEventIdentity(event.absolutePath) const prev = lastByKey.get(key) if (prev) { @@ -51,9 +59,8 @@ function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { 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)) { + // A final delete subsumes the transition; a live update cannot prove the old entry was not a directory. + if (event.kind === 'delete') { deleteBeforeCreate.delete(key) } } @@ -89,7 +96,7 @@ export function createRemoteWatcherEventBatch({ deliver([{ kind: 'overflow', absolutePath: rootPath }]) return } - const coalesced = coalesceRemoteEvents(pending) + const coalesced = coalesceRemoteEvents(pending, rootPath) if (coalesced.length > 0) { deliver(coalesced) } diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts index 1361715a4ea..580ab7e179c 100644 --- a/src/relay/relay-grace-branch.test.ts +++ b/src/relay/relay-grace-branch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { decideRelayGrace, type RelayGraceDecisionInput } from './relay-grace-branch' +import { + decideRelayGrace, + retryDeferredShutdownAfterGraceReconfigure, + type RelayGraceDecisionInput +} from './relay-grace-branch' const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 const IDLE_RELAY_GRACE_MS = 15 * 60_000 @@ -64,6 +68,19 @@ describe('decideRelayGrace', () => { ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) }) + it('preserves shutdown-deferred when grace is reconfigured to zero', () => { + // Why a non-zero starting grace: configureRelayGraceTime only re-arms on an actual change, + // so the reconfiguration this models must cross a real boundary, not 0 → 0. + const current = decide({ configuredGraceMs: 10_000, retryDeferredShutdown: true }) + + expect( + decide({ + configuredGraceMs: 0, + retryDeferredShutdown: retryDeferredShutdownAfterGraceReconfigure(current.branch) + }) + ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + it('prefers startup-empty-detached over the idle cap', () => { expect( decide({ diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts index aa8aabcdd63..52c6935df56 100644 --- a/src/relay/relay-grace-branch.ts +++ b/src/relay/relay-grace-branch.ts @@ -23,6 +23,12 @@ export type RelayGraceDecision = { timeoutMs: number } +export function retryDeferredShutdownAfterGraceReconfigure( + currentBranch: RelayGraceBranch | null +): boolean { + return currentBranch === 'shutdown-deferred' +} + /** * Picks the relay's shutdown grace window. * diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 516bbadc6dc..a0228e0ccd6 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -51,7 +51,11 @@ 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 { + decideRelayGrace, + retryDeferredShutdownAfterGraceReconfigure, + type RelayGraceBranch +} from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -696,7 +700,9 @@ async function main(): Promise { graceReason !== null && !shutdownInFlight ) { - startGrace('grace reconfigured') + startGrace('grace reconfigured', { + retryDeferredShutdown: retryDeferredShutdownAfterGraceReconfigure(graceBranch) + }) } } return { graceTimeMs: ptyHandler.configuredGraceTimeMs } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts index 97e2dbd25d3..f1faa368e44 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts @@ -45,8 +45,7 @@ describe('refreshFileExplorerExpandedDirs', () => { dirLoadTracker: createFileExplorerDirLoadTracker(), setDirCache, readDirectory, - // A limit at or above the dir count must stay a single wave, i.e. exactly - // today's two batched writes. + // A limit at or above the dir count keeps one result batch. maxConcurrentReads: 16 }) @@ -247,7 +246,7 @@ describe('refreshFileExplorerExpandedDirs', () => { 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). + // One up-front loading write plus one result write per completed group of four. expect(setDirCache).toHaveBeenCalledTimes(6) for (const { dirPath } of dirs) { expect(cache[dirPath]).toMatchObject({ @@ -257,7 +256,7 @@ describe('refreshFileExplorerExpandedDirs', () => { } }) - it('marks queued dirs loading up front so only the first wave reads', async () => { + it('marks queued dirs loading up front while the initial reads are blocked', async () => { const dirs = Array.from({ length: 9 }, (_, index) => ({ dirPath: `/repo/d${index}`, depth: 0 @@ -266,13 +265,13 @@ describe('refreshFileExplorerExpandedDirs', () => { const setDirCache = vi.fn((update: CacheUpdate) => { cache = typeof update === 'function' ? update(cache) : update }) - let releaseFirstWave!: () => void - const firstWaveGate = new Promise((resolve) => { - releaseFirstWave = resolve + let releaseInitialReads!: () => void + const initialReadsGate = new Promise((resolve) => { + releaseInitialReads = resolve }) const readDirectory = vi.fn(async (dirPath: string) => { if (['/repo/d0', '/repo/d1', '/repo/d2'].includes(dirPath)) { - await firstWaveGate + await initialReadsGate } return { entries: [], operationOwner: { kind: 'local' as const } } }) @@ -294,13 +293,127 @@ describe('refreshFileExplorerExpandedDirs', () => { expect(cache['/repo/d6']).toMatchObject({ loading: true }) expect(readDirectory).toHaveBeenCalledTimes(3) - releaseFirstWave() + releaseInitialReads() await refreshPromise expect(cache['/repo/d6']).toMatchObject({ loading: false }) }) - it('drops a later-wave directory superseded while an earlier wave was reading', async () => { + it('starts later reads as slots free without waiting for the slowest initial read', async () => { + const dirs = Array.from({ length: 5 }, (_, index) => ({ + dirPath: `/repo/d${index}`, + depth: 0 + })) + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseSlowRead!: () => void + const slowRead = new Promise((resolve) => { + releaseSlowRead = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/d0') { + await slowRead + } + return { entries: [entry('x.ts')], operationOwner: { kind: 'local' as const } } + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs, + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory, + maxConcurrentReads: 2 + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(readDirectory.mock.calls.map(([dirPath]) => dirPath)).toEqual([ + '/repo/d0', + '/repo/d1', + '/repo/d2', + '/repo/d3', + '/repo/d4' + ]) + expect(cache['/repo/d1']).toMatchObject({ loading: false }) + expect(cache['/repo/d0']).toMatchObject({ loading: true }) + + releaseSlowRead() + await expect(refreshPromise).resolves.toBe(true) + expect(cache['/repo/d0']).toMatchObject({ loading: false }) + }) + + it('does not turn a commit callback failure into an empty directory result', async () => { + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + const commitError = new Error('commit failed') + + await expect( + refreshFileExplorerExpandedDirs({ + dirs: [{ dirPath: '/repo/src', depth: 0 }], + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory: async () => ({ + entries: [entry('index.ts')], + operationOwner: { kind: 'local' as const } + }), + maxConcurrentReads: 1, + onDirCommitted: () => { + throw commitError + } + }) + ).rejects.toBe(commitError) + + expect(setDirCache).toHaveBeenCalledTimes(2) + expect(cache['/repo/src']).toMatchObject({ + loading: false, + children: [{ name: 'index.ts' }] + }) + }) + + it('still notifies the rest of a commit batch after one commit callback throws', async () => { + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + const commitError = new Error('commit failed') + const onDirCommitted = vi.fn((dirPath: string) => { + if (dirPath === '/repo/a') { + throw commitError + } + }) + + await expect( + refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/a', depth: 0 }, + { dirPath: '/repo/b', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory: async () => ({ + entries: [entry('index.ts')], + operationOwner: { kind: 'local' as const } + }), + // Both dirs land in one commit batch, so the throw must not strand the other's mark. + maxConcurrentReads: 2, + onDirCommitted + }) + ).rejects.toBe(commitError) + + expect(onDirCommitted.mock.calls.map(([dirPath]) => dirPath).sort()).toEqual([ + '/repo/a', + '/repo/b' + ]) + expect(cache['/repo/b']).toMatchObject({ loading: false, children: [{ name: 'index.ts' }] }) + }) + + it('drops a queued directory superseded while an earlier read is blocked', async () => { const tracker = createFileExplorerDirLoadTracker() let cache: Record = {} const setDirCache = vi.fn((update: CacheUpdate) => { @@ -330,8 +443,7 @@ describe('refreshFileExplorerExpandedDirs', () => { }) 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. + // A watcher-driven refreshDir supersedes the queued dir before it starts reading. tracker.begin('/repo/b') const newerBCache: DirCache = { loading: false, children: [] } setDirCache((prev) => ({ ...prev, '/repo/b': newerBCache })) @@ -341,7 +453,7 @@ describe('refreshFileExplorerExpandedDirs', () => { 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. + // The queued task 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 index 280520c6425..06c5347b4f7 100644 --- 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 @@ -5,6 +5,7 @@ import { fileExplorerEntriesToTreeNodes, type FileExplorerDirectoryListing } from './file-explorer-directory-listing' +import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' export type RefreshFileExplorerTreeDir = { dirPath: string @@ -37,17 +38,23 @@ export async function refreshFileExplorerExpandedDirs({ 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. + // worktree reset supersedes dirs still waiting for a concurrency slot. const loadTokens = new Map( uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) ) - const waveSize = Math.max(1, Math.floor(maxConcurrentReads)) + const commitBatchSize = + maxConcurrentReads === Number.POSITIVE_INFINITY + ? Math.max(1, uniqueDirs.length) + : Number.isFinite(maxConcurrentReads) + ? Math.max(1, Math.floor(maxConcurrentReads)) + : 1 + const pendingResults: { dirPath: string; cache: DirCache }[] = [] + let settledSinceCommit = 0 let committedDirs = 0 - // Why: mark every dir loading up front, not per wave — FileExplorer's auto-load + // Why: mark every dir loading up front — 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. + // dir that is neither cached nor loading, which would defeat the concurrency cap. setDirCache((prev) => { const next = { ...prev } for (const { dirPath } of uniqueDirs) { @@ -59,65 +66,13 @@ export async function refreshFileExplorerExpandedDirs({ 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)!) - ) + const commitPendingResults = (): void => { + settledSinceCommit = 0 + const currentResults = pendingResults + .splice(0) + .filter((result) => dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!)) if (currentResults.length === 0) { - continue + return } setDirCache((prev) => { @@ -127,10 +82,68 @@ export async function refreshFileExplorerExpandedDirs({ } return next }) - for (const result of currentResults) { - onDirCommitted?.(result.dirPath) - } committedDirs += currentResults.length + // Why: the cache write above already landed for every result, so a throwing callback must not + // strand the rest of the batch with a staleness mark no later commit will clear. + let firstCommitError: unknown + let commitFailed = false + for (const result of currentResults) { + try { + onDirCommitted?.(result.dirPath) + } catch (error) { + if (!commitFailed) { + commitFailed = true + firstCommitError = error + } + } + } + if (commitFailed) { + throw firstCommitError + } + } + + const settleRead = (result?: { dirPath: string; cache: DirCache }): void => { + if (result) { + pendingResults.push(result) + } + settledSinceCommit++ + if (settledSinceCommit >= commitBatchSize) { + commitPendingResults() + } + } + + await forEachWithConcurrency(uniqueDirs, maxConcurrentReads, async ({ dirPath, depth }) => { + const loadToken = loadTokens.get(dirPath)! + // A superseding load owns this dir now; do not spend a round trip on a result we must drop. + if (!dirLoadTracker.isCurrent(loadToken)) { + settleRead() + return + } + let cache: DirCache | undefined + try { + const listing = await readDirectory(dirPath) + if (dirLoadTracker.isCurrent(loadToken)) { + cache = { + children: fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ), + loading: false, + operationOwner: listing.operationOwner + } + } + } catch { + if (dirLoadTracker.isCurrent(loadToken)) { + cache = { children: [], loading: false } + } + } + settleRead(cache ? { dirPath, cache } : undefined) + }) + if (settledSinceCommit > 0) { + commitPendingResults() } return committedDirs === uniqueDirs.length 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 index 376479eb388..fc515608394 100644 --- 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 @@ -226,13 +226,34 @@ describe('createFileExplorerWatchRefreshScheduler', () => { scheduler.requestFullRefresh() scheduler.requestDirRefresh('/repo/src') - scheduler.cancel() + expect(scheduler.cancel()).toBe(true) await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) expect(refreshTree).not.toHaveBeenCalled() expect(refreshDir).not.toHaveBeenCalled() }) + it('reports no discarded work when an idle scheduler is cancelled', () => { + const { scheduler } = setup() + + expect(scheduler.cancel()).toBe(false) + }) + + it('reports discarded work when cancelled with a refresh in flight and nothing queued', async () => { + const gate = createDeferred() + const { refreshTree, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + expect(refreshTree).toHaveBeenCalledTimes(1) + + // Nothing pending and no timer armed: the in-flight run is the only discarded work. + expect(scheduler.cancel()).toBe(true) + + gate.resolve() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + }) + 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 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 index 246ead05519..f2790ceca3b 100644 --- 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 @@ -11,7 +11,8 @@ type SchedulerTimer = ReturnType export type FileExplorerWatchRefreshScheduler = { requestFullRefresh: () => void requestDirRefresh: (dirPath: string) => void - cancel: () => void + /** True when cancellation discarded a received refresh or interrupted one in flight. */ + cancel: () => boolean } export type CreateFileExplorerWatchRefreshSchedulerParams = { @@ -142,11 +143,14 @@ export function createFileExplorerWatchRefreshScheduler({ armForRequest() }, cancel: () => { + const discardedWork = + pendingFull || pendingDirs.size > 0 || timer !== null || inFlight !== null disposed = true clearTimer() pendingFull = false pendingDirs.clear() firstRequestAt = null + return discardedWork } } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx new file mode 100644 index 00000000000..13b7b1c1c5a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangedPayload } from '../../../../shared/types' +import type { + FileExplorerOperationOwner, + FileExplorerTreeRefreshOutcome +} from './file-explorer-types' + +const ownerRef = vi.hoisted(() => ({ current: { kind: 'local' } as FileExplorerOperationOwner })) +const runtimeWatch = vi.hoisted(() => ({ + handler: null as ((payload: FsChangedPayload) => void) | null, + subscribe: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({}) + }) +})) +vi.mock('./file-explorer-operation-owner', () => ({ + getFileExplorerOperationOwner: () => ownerRef.current, + getFileExplorerOperationOwnerFromState: () => ownerRef.current +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + subscribeRuntimeFileChanges: runtimeWatch.subscribe +})) + +import { useFileExplorerWatch } from './useFileExplorerWatch' + +type WatchHandler = (payload: FsChangedPayload) => void + +describe('useFileExplorerWatch pending refreshes', () => { + let mainWatchHandler: WatchHandler | null + let refreshDir: ReturnType Promise>> + let refreshTree: ReturnType Promise>> + + beforeEach(() => { + vi.useFakeTimers() + ownerRef.current = { kind: 'local' } + mainWatchHandler = null + runtimeWatch.handler = null + runtimeWatch.subscribe.mockReset() + runtimeWatch.subscribe.mockImplementation( + async (_context: unknown, handler: WatchHandler): Promise<() => void> => { + runtimeWatch.handler = handler + return () => undefined + } + ) + refreshDir = vi.fn(async () => {}) + refreshTree = vi.fn(async () => 'refreshed' as const) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + fs: { + onFsChanged: vi.fn((handler: WatchHandler) => { + mainWatchHandler = handler + return vi.fn() + }) + } + } + }) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + }) + + function renderWatch(worktreePath: string | null = '/repo') { + return renderHook( + ({ visiblePath }: { visiblePath: string | null }) => + useFileExplorerWatch({ + worktreePath: visiblePath, + activeWorktreeId: 'wt-1', + dirCache: { '/repo': { children: [], loading: false } }, + setDirCache: vi.fn(), + expanded: new Set(), + setSelectedPath: vi.fn(), + refreshDir, + refreshTree, + inlineInput: null, + dragSourcePath: null, + isNativeDragOver: false, + operationOwner: ownerRef.current + }), + { initialProps: { visiblePath: worktreePath } } + ) + } + + function emit(handler: WatchHandler): void { + handler({ + worktreePath: '/repo', + events: [{ kind: 'create', absolutePath: '/repo/new.ts', isDirectory: false }] + }) + } + + it('resyncs on reopen when hiding Files cancelled a received remote event', async () => { + ownerRef.current = { kind: 'ssh', connectionId: 'ssh-1' } + const hook = renderWatch() + expect(mainWatchHandler).not.toBeNull() + + act(() => emit(mainWatchHandler!)) + hook.rerender({ visiblePath: null }) + hook.rerender({ visiblePath: '/repo' }) + await act(async () => vi.advanceTimersByTimeAsync(0)) + + expect(refreshTree).toHaveBeenCalledOnce() + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('flushes main SSH batches on the next turn without another debounce window', async () => { + ownerRef.current = { kind: 'ssh', connectionId: 'ssh-1' } + renderWatch() + + act(() => emit(mainWatchHandler!)) + expect(refreshDir).not.toHaveBeenCalled() + await act(async () => vi.advanceTimersByTimeAsync(0)) + + expect(refreshDir).toHaveBeenCalledOnce() + }) + + it('flushes runtime RPC batches on the next turn without another debounce window', async () => { + ownerRef.current = { + kind: 'runtime', + environmentId: 'runtime-1', + executionHostId: 'runtime:runtime-1' + } + renderWatch() + expect(runtimeWatch.handler).not.toBeNull() + + act(() => emit(runtimeWatch.handler!)) + expect(refreshDir).not.toHaveBeenCalled() + await act(async () => vi.advanceTimersByTimeAsync(0)) + + expect(refreshDir).toHaveBeenCalledOnce() + }) + + it('resyncs after an event races cleanup of a pending runtime subscription', async () => { + ownerRef.current = { + kind: 'runtime', + environmentId: 'runtime-1', + executionHostId: 'runtime:runtime-1' + } + let releaseSubscription!: () => void + const subscriptionReady = new Promise((resolve) => { + releaseSubscription = resolve + }) + runtimeWatch.subscribe.mockImplementation( + async (_context: unknown, handler: WatchHandler): Promise<() => void> => { + runtimeWatch.handler = handler + await subscriptionReady + return () => undefined + } + ) + const hook = renderWatch() + const disposedHandler = runtimeWatch.handler! + + hook.rerender({ visiblePath: null }) + hook.rerender({ visiblePath: '/repo' }) + act(() => emit(disposedHandler)) + await act(async () => vi.advanceTimersByTimeAsync(0)) + + expect(refreshTree).toHaveBeenCalledOnce() + expect(refreshDir).not.toHaveBeenCalled() + + await act(async () => { + releaseSubscription() + await subscriptionReady + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index c75dfd4fac8..0d9c42af68e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -102,9 +102,6 @@ export function useFileExplorerWatch({ const expandedRef = useRef(expanded) expandedRef.current = expanded - const worktreeIdRef = useRef(activeWorktreeId) - worktreeIdRef.current = activeWorktreeId - const inlineInputRef = useRef(inlineInput) inlineInputRef.current = inlineInput @@ -123,6 +120,8 @@ export function useFileExplorerWatch({ // Deferred events queue: events that arrive during inline input or drag const deferredRef = useRef([]) + const resyncWatchKeysRef = useRef(new Set()) + const activeResyncByWatchKeyRef = useRef(new Map void>()) // Why: a ref bridges processPayload to the flush effect so it can replay deferred payloads without re-subscribing (design §6.2). const processPayloadRef = useRef<((payload: FsChangedPayload) => void) | null>(null) @@ -134,12 +133,19 @@ export function useFileExplorerWatch({ } const currentWorktreePath = worktreePath + const currentWorktreeId = activeWorktreeId + const resyncWatchKeys = resyncWatchKeysRef.current + const activeResyncByWatchKey = activeResyncByWatchKeyRef.current + const currentWatchKey = JSON.stringify([ + currentWorktreeId, + normalizeRuntimePathForComparison(currentWorktreePath) + ]) // 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 owner = getFileExplorerOperationOwner(currentWorktreeId) const scheduler = createFileExplorerWatchRefreshScheduler({ refreshTree: () => refreshTreeRef.current(), refreshDir: (dirPath) => refreshDirRef.current(dirPath), @@ -152,21 +158,27 @@ export function useFileExplorerWatch({ 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 } : {}) + // Why 0ms for every transport: each producer already flushes on the shared + // WATCH_BATCH_* window — local/SSH via filesystem-watcher, runtime files.watch via + // createFileWatchEventBatcher — so a second 150ms here only adds latency, not coalescing. + // A new transport must batch at the producer before joining this path. + trailingMs: 0, + maxWaitMs: 0 }) + activeResyncByWatchKey.set(currentWatchKey, scheduler.requestFullRefresh) + + if (resyncWatchKeys.delete(currentWatchKey)) { + scheduler.requestFullRefresh() + } function processPayload(payload: FsChangedPayload): void { - const wtId = worktreeIdRef.current - if (!wtId) { + if (!currentWorktreeId) { return } processFileExplorerFsPayload({ payload, currentWorktreePath, - worktreeId: wtId, + worktreeId: currentWorktreeId, cache: dirCacheRef.current, expanded: expandedRef.current, setDirCache, @@ -179,7 +191,24 @@ export function useFileExplorerWatch({ // Why: expose processPayload to the flush effect so it can replay deferred payloads without re-subscribing. processPayloadRef.current = processPayload + // Declared before handleFsChanged so its `disposed` read can never hit the temporal dead zone. + let disposed = false + const handleFsChanged = (payload: FsChangedPayload): void => { + if (disposed) { + if ( + normalizeRuntimePathForComparison(payload.worktreePath) === + normalizeRuntimePathForComparison(currentWorktreePath) + ) { + const requestActiveResync = activeResyncByWatchKey.get(currentWatchKey) + if (requestActiveResync) { + requestActiveResync() + } else { + resyncWatchKeys.add(currentWatchKey) + } + } + return + } // Why: defer refreshes during inline input/drag so rows don't shift; native drags only set isNativeDragOver (design §6.2). if ( inlineInputRef.current !== null || @@ -193,7 +222,6 @@ export function useFileExplorerWatch({ processPayload(payload) } - let disposed = false let unsubscribeListener: (() => void) | null = null if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) { // Why: remote runtime watch events don't enter the local Electron fs:changed bus, so subscribe directly. @@ -234,7 +262,14 @@ export function useFileExplorerWatch({ return () => { disposed = true unsubscribeListener?.() - scheduler.cancel() + if (activeResyncByWatchKey.get(currentWatchKey) === scheduler.requestFullRefresh) { + activeResyncByWatchKey.delete(currentWatchKey) + } + const hadDeferredEvents = deferredRef.current.length > 0 + if (scheduler.cancel() || hadDeferredEvents) { + // The tree cache survives Files being hidden, so remember work canceled after receipt. + resyncWatchKeys.add(currentWatchKey) + } deferredRef.current = [] processPayloadRef.current = null } From a7d769e13de6a9eb74e6ada4e9218eadfce74307 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:45:45 -0700 Subject: [PATCH 09/64] Watcher explorer relay regressions (#12012) * 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(watcher/explorer/relay): coalesce POSIX paths by byte identity; pres - Remote watcher event coalescing now keeps NFC/NFD-distinct POSIX paths separate while still folding Windows path spellings, fixing cache invalidation when recreating directories with different Unicode compositions. - Relay grace reconfiguration now preserves shutdown-deferred state when grace is set to zero, preventing premature shutdown when the grace timer is reconfigured mid-flight. - File explorer refresh refactored from fixed-wave batching to concurrent task execution with `forEachWithConcurrency`, batching results every N settled reads instead of every wave, and reporting whether cancellation discarded pending work. - Watch handler now resyncs when events arrive after disposal, ensuring refresh requests aren't lost when events race cleanup during worktree switches. * fix(watcher/explorer): resilient commit batches on callback error Cache writes precede callbacks, so a throwing callback cannot strand the batch with stale marks. All callbacks complete despite errors, with the first error thrown after. - Fix commitBatchSize calculation for empty dirs - Add scheduler discard-report test - Move disposed variable before handleFsChanged - Expand batching-strategy comments * test(activity): stabilize portal readiness latch release under CI load The fixed 9-flip budget could miss enough MutationObserver deliveries under shard load for sibling DOM to still report loading. Drain readiness rAF after each flip and wait until latched unavailable is actually observed. * fix(watcher/explorer): stop batches on commit error; verify drain settle Enhance resilience under CI load and error conditions: - Portal readiness drain now returns a boolean confirming settlement; test expects verify the drain completed before proceeding. - File explorer commit batches stop executing after a callback throws, preventing stale writes after caller-observed rejection. - Watch hook requires worktree ID upfront in effect guard, eliminating redundant checks inside loops. * fix(relay): re-arm grace on configured change Refactor grace reconfiguration into a dedicated decision function. startGrace samples the configured grace at arm time, so a raised value landing mid-window would still fire at the old deadline without re-arming. Extract the logic with proper tests to ensure shutdown-deferred state is preserved across re-arms. * refactor(relay): extract grace reconfiguration to testable function Extract `applyRelayGraceTimeConfiguration` so the grace-time re-arming logic can be tested independently. relay.ts runs `main()` on import and exports nothing, making the call site (including retryDeferredShutdown hand-off into startGrace) otherwise untestable. --------- Co-authored-by: Orca --- src/relay/relay-grace-branch.test.ts | 169 ++++++++++++++++-- src/relay/relay-grace-branch.ts | 78 +++++++- src/relay/relay.ts | 31 +--- ...ty-portal-readiness-loop.react185.test.tsx | 59 +++++- ...ile-explorer-expanded-dirs-refresh.test.ts | 40 +++++ .../file-explorer-expanded-dirs-refresh.ts | 10 ++ .../right-sidebar/useFileExplorerWatch.ts | 7 +- 7 files changed, 344 insertions(+), 50 deletions(-) diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts index 580ab7e179c..e7fee583239 100644 --- a/src/relay/relay-grace-branch.test.ts +++ b/src/relay/relay-grace-branch.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { + applyRelayGraceTimeConfiguration, decideRelayGrace, - retryDeferredShutdownAfterGraceReconfigure, - type RelayGraceDecisionInput + decideRelayGraceReconfigure, + type RelayGraceBranch, + type RelayGraceDecisionInput, + type RelayGraceReconfigureInput } from './relay-grace-branch' const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 @@ -68,19 +71,6 @@ describe('decideRelayGrace', () => { ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) }) - it('preserves shutdown-deferred when grace is reconfigured to zero', () => { - // Why a non-zero starting grace: configureRelayGraceTime only re-arms on an actual change, - // so the reconfiguration this models must cross a real boundary, not 0 → 0. - const current = decide({ configuredGraceMs: 10_000, retryDeferredShutdown: true }) - - expect( - decide({ - configuredGraceMs: 0, - retryDeferredShutdown: retryDeferredShutdownAfterGraceReconfigure(current.branch) - }) - ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) - }) - it('prefers startup-empty-detached over the idle cap', () => { expect( decide({ @@ -115,3 +105,150 @@ describe('decideRelayGrace', () => { }) }) }) + +function reconfigure(overrides: Partial = {}) { + return decideRelayGraceReconfigure({ + previousConfiguredGraceMs: 10_000, + nextConfiguredGraceMs: 86_400_000, + graceTimerArmed: true, + shutdownInFlight: false, + currentBranch: 'configured', + ...overrides + }) +} + +describe('decideRelayGraceReconfigure', () => { + it('re-arms a running window so a raised grace takes effect at the new deadline', () => { + // Why: the reported bug's call site. startGrace samples the grace at arm time, so without this + // re-arm a raise landing mid-window still fires at the old deadline. + expect(reconfigure()).toEqual({ rearm: true, retryDeferredShutdown: false }) + }) + + it('preserves shutdown-deferred across the re-arm', () => { + const rearmed = reconfigure({ nextConfiguredGraceMs: 0, currentBranch: 'shutdown-deferred' }) + + expect(rearmed).toEqual({ rearm: true, retryDeferredShutdown: true }) + expect( + decide({ + configuredGraceMs: 0, + retryDeferredShutdown: rearmed.rearm && rearmed.retryDeferredShutdown + }) + ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + + it('ignores a re-assertion of the same grace', () => { + // Why: the host re-asserts its grace on every establish; re-arming on those would keep the + // window alive indefinitely. + expect(reconfigure({ previousConfiguredGraceMs: 86_400_000 })).toEqual({ rearm: false }) + }) + + it('does not arm a window that was never running', () => { + expect(reconfigure({ graceTimerArmed: false })).toEqual({ rearm: false }) + }) + + it('does not re-arm once shutdown is in flight', () => { + expect(reconfigure({ shutdownInFlight: true })).toEqual({ rearm: false }) + }) +}) + +/** + * Models relay.ts's grace state around the configureGraceTime call site: `startGrace` re-decides the + * branch exactly as the relay does, so a dropped `retryDeferredShutdown` shows up as a branch downgrade. + */ +function relayGraceHost( + overrides: { + configuredGraceMs?: number + graceTimerArmed?: boolean + shutdownInFlight?: boolean + graceBranch?: RelayGraceBranch | null + relayIdle?: boolean + activePtyCount?: number + } = {} +) { + let configuredGraceMs = overrides.configuredGraceMs ?? 10_000 + let graceBranch: RelayGraceBranch | null = overrides.graceBranch ?? 'configured' + const startGrace = vi.fn( + (_reason: string, options?: { retryDeferredShutdown?: boolean }): void => { + graceBranch = decide({ + configuredGraceMs, + relayIdle: overrides.relayIdle ?? true, + activePtyCount: overrides.activePtyCount ?? 0, + retryDeferredShutdown: options?.retryDeferredShutdown === true + }).branch + } + ) + + return { + startGrace, + branch: () => graceBranch, + configuredGraceMs: () => configuredGraceMs, + apply: (graceTimeSeconds: unknown) => + applyRelayGraceTimeConfiguration(graceTimeSeconds, { + readConfiguredGraceMs: () => configuredGraceMs, + // Mirrors PtyHandler.setGraceTimeMs's clamp so the decision sees the stored value. + writeConfiguredGraceMs: (graceMs) => { + configuredGraceMs = Math.max(0, Math.floor(graceMs)) + }, + isGraceTimerArmed: () => overrides.graceTimerArmed ?? true, + isShutdownInFlight: () => overrides.shutdownInFlight ?? false, + readGraceBranch: () => graceBranch, + startGrace + }) + } +} + +describe('applyRelayGraceTimeConfiguration', () => { + it('re-arms a deferred shutdown without downgrading it to an ordinary configured window', () => { + // Why: the call site the branch selector alone cannot cover — dropping the startGrace option here + // would leave a refused kill with nothing left to retry it. + const host = relayGraceHost({ graceBranch: 'shutdown-deferred', configuredGraceMs: 0 }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { + retryDeferredShutdown: true + }) + expect(host.branch()).toBe('shutdown-deferred') + }) + + it('re-arms an ordinary window at the raised grace', () => { + const host = relayGraceHost() + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { + retryDeferredShutdown: false + }) + expect(host.branch()).toBe('configured') + }) + + it('stores the host-sleep zero and lets the idle cap own the window', () => { + const host = relayGraceHost() + + expect(host.apply(0)).toEqual({ graceTimeMs: 0 }) + expect(host.startGrace).toHaveBeenCalledTimes(1) + expect(host.branch()).toBe('idle-no-ptys') + }) + + it('does not re-arm on a re-asserted grace', () => { + const host = relayGraceHost({ configuredGraceMs: 86_400_000 }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).not.toHaveBeenCalled() + }) + + it('does not arm a window that was never running', () => { + const host = relayGraceHost({ graceTimerArmed: false }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).not.toHaveBeenCalled() + }) + + it('ignores a malformed or negative payload without touching the stored grace', () => { + for (const payload of [undefined, 'later', Number.NaN, -1]) { + const host = relayGraceHost() + + expect(host.apply(payload)).toEqual({ graceTimeMs: 10_000 }) + expect(host.configuredGraceMs()).toBe(10_000) + expect(host.startGrace).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts index 52c6935df56..68ba3e2ec9d 100644 --- a/src/relay/relay-grace-branch.ts +++ b/src/relay/relay-grace-branch.ts @@ -23,10 +23,82 @@ export type RelayGraceDecision = { timeoutMs: number } -export function retryDeferredShutdownAfterGraceReconfigure( +export type RelayGraceReconfigureInput = { + previousConfiguredGraceMs: number + nextConfiguredGraceMs: number + /** A grace window with a real deadline is running; an unlimited (deadline-less) one is not re-armed. */ + graceTimerArmed: boolean + shutdownInFlight: boolean currentBranch: RelayGraceBranch | null -): boolean { - return currentBranch === 'shutdown-deferred' +} + +export type RelayGraceReconfigureDecision = + | { rearm: false } + | { rearm: true; retryDeferredShutdown: boolean } + +/** Live relay grace state, read through accessors so the caller's `let` bindings stay authoritative. */ +export type RelayGraceTimeConfigurationInput = { + readConfiguredGraceMs: () => number + /** Writes the new grace; the value is read back so a normalizing setter wins. */ + writeConfiguredGraceMs: (graceMs: number) => void + isGraceTimerArmed: () => boolean + isShutdownInFlight: () => boolean + readGraceBranch: () => RelayGraceBranch | null + startGrace: (reason: string, options?: { retryDeferredShutdown?: boolean }) => void +} + +/** + * Applies a `relay.configureGraceTime` payload, re-arming the running window when the value changed. + * + * Why extracted from relay.ts: that file runs `main()` on import and exports nothing, so the call site + * — including the `retryDeferredShutdown` hand-off into `startGrace` — is otherwise untestable. + */ +export function applyRelayGraceTimeConfiguration( + graceTimeSeconds: unknown, + state: RelayGraceTimeConfigurationInput +): { graceTimeMs: number } { + const seconds = Number(graceTimeSeconds) + if (Number.isFinite(seconds) && seconds >= 0) { + const previousConfiguredGraceMs = state.readConfiguredGraceMs() + // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. + state.writeConfiguredGraceMs(Math.floor(seconds) * 1000) + const reconfigure = decideRelayGraceReconfigure({ + previousConfiguredGraceMs, + nextConfiguredGraceMs: state.readConfiguredGraceMs(), + graceTimerArmed: state.isGraceTimerArmed(), + shutdownInFlight: state.isShutdownInFlight(), + currentBranch: state.readGraceBranch() + }) + if (reconfigure.rearm) { + state.startGrace('grace reconfigured', { + retryDeferredShutdown: reconfigure.retryDeferredShutdown + }) + } + } + return { graceTimeMs: state.readConfiguredGraceMs() } +} + +/** + * Decides whether a live grace change must re-arm the running grace timer. + * + * Why a decision rather than inline gating: `startGrace` samples the configured grace at arm time, so + * this gate is the only thing that makes a post-launch raise take effect on an already-running window. + */ +export function decideRelayGraceReconfigure( + input: RelayGraceReconfigureInput +): RelayGraceReconfigureDecision { + // Why 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 ( + input.nextConfiguredGraceMs === input.previousConfiguredGraceMs || + !input.graceTimerArmed || + input.shutdownInFlight + ) { + return { rearm: false } + } + // Why carry the branch forward: re-arming without it downgrades a deferred shutdown to an ordinary + // configured window, and the refused kill is never retried. + return { rearm: true, retryDeferredShutdown: input.currentBranch === 'shutdown-deferred' } } /** diff --git a/src/relay/relay.ts b/src/relay/relay.ts index a0228e0ccd6..4bfc8bf3e92 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -52,8 +52,8 @@ import { import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' import { + applyRelayGraceTimeConfiguration, decideRelayGrace, - retryDeferredShutdownAfterGraceReconfigure, type RelayGraceBranch } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' @@ -685,27 +685,14 @@ 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', { - retryDeferredShutdown: retryDeferredShutdownAfterGraceReconfigure(graceBranch) - }) - } - } - return { graceTimeMs: ptyHandler.configuredGraceTimeMs } + return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { + readConfiguredGraceMs: () => ptyHandler.configuredGraceTimeMs, + writeConfiguredGraceMs: (graceMs) => ptyHandler.setGraceTimeMs(graceMs), + isGraceTimerArmed: () => graceDeadlineAt !== null && graceReason !== null, + isShutdownInFlight: () => shutdownInFlight, + readGraceBranch: () => graceBranch, + startGrace + }) } dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { 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 50190295daa..10d9fc2afa6 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 @@ -15,7 +15,10 @@ import { resolveActivityPortalSwap, type ActivityPortalThreadRef } from './activity-portal-thread-reconciliation' -import type { ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation' +import { + ACTIVITY_PORTAL_READINESS_MAX_FLIPS, + type ActivityPortalReadinessStatus +} from './activity-portal-readiness-oscillation' const WORKTREE_ID = 'wt-1' const TAB_ID = 'tab-react185' @@ -85,6 +88,28 @@ async function flushPortalFramesUntil( } } +// Drain MutationObserver microtasks and the readiness rAF they schedule. Reports whether the +// drain settled so a caller never reads a transition whose readiness callbacks are still queued. +async function flushPortalReadiness( + frames: ReturnType +): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + await act(async () => { + await Promise.resolve() + }) + if (frames.pending() === 0) { + await act(async () => { + await Promise.resolve() + }) + if (frames.pending() === 0) { + return true + } + } + await frames.flush() + } + return frames.pending() === 0 +} + // 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] @@ -354,23 +379,47 @@ describe('Activity portal pane switching', () => { await act(async () => { root.render() }) + expect(await flushPortalReadiness(frames)).toBe(true) 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. - for (let flip = 0; flip < 9; flip += 1) { + // Feed each DOM state separately and keep going until sibling DOM reports latched + // unavailable. A fixed 9-flip budget flakes when CI load drops MutationObserver + // deliveries below ACTIVITY_PORTAL_READINESS_MAX_FLIPS transitions. + let sawSiblingLoading = false + let sawLatchedSibling = false + for ( + let flip = 0; + flip < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 4 && !sawLatchedSibling; + flip += 1 + ) { + const mode = flip % 2 === 0 ? 'sibling' : 'hidden' + const statusesBefore = statuses.length await act(async () => { - buildRoot(flip % 2 === 0 ? 'sibling' : 'hidden') + buildRoot(mode) await Promise.resolve() }) - await frames.flush() + expect(await flushPortalReadiness(frames)).toBe(true) + if (mode !== 'sibling') { + continue + } + // Transition-local evidence: an unlatched subscription answers sibling DOM with 'loading', + // so the latch is only proven once a sibling transition that previously emitted 'loading' + // stops doing so and leaves 'unavailable' standing. + if (statuses.slice(statusesBefore).includes('loading')) { + sawSiblingLoading = true + } else if (sawSiblingLoading && statuses.at(-1) === 'unavailable') { + sawLatchedSibling = true + } } + expect(sawLatchedSibling).toBe(true) expect(statuses.at(-1)).toBe('unavailable') await act(async () => { buildRoot('ready') await Promise.resolve() }) + expect(await flushPortalReadiness(frames)).toBe(true) await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') expect(statuses.at(-1)).toBe('ready') }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts index f1faa368e44..b3fdc583522 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts @@ -413,6 +413,46 @@ describe('refreshFileExplorerExpandedDirs', () => { expect(cache['/repo/b']).toMatchObject({ loading: false, children: [{ name: 'index.ts' }] }) }) + it('stops later batches after a commit callback throws', async () => { + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + const commitError = new Error('commit failed') + const onDirCommitted = vi.fn((dirPath: string) => { + if (dirPath === '/repo/a') { + throw commitError + } + }) + + await expect( + refreshFileExplorerExpandedDirs({ + dirs: ['a', 'b', 'c', 'd'].map((name) => ({ dirPath: `/repo/${name}`, depth: 0 })), + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory: async () => ({ + entries: [entry('index.ts')], + operationOwner: { kind: 'local' as const } + }), + // Two dirs per commit batch, so /repo/c and /repo/d belong to a later batch. + maxConcurrentReads: 2, + onDirCommitted + }) + ).rejects.toBe(commitError) + + // A surviving worker must not commit past the failure: callers observing the rejection + // would otherwise still get setDirCache writes and staleness clears afterwards. + expect(onDirCommitted.mock.calls.map(([dirPath]) => dirPath).sort()).toEqual([ + '/repo/a', + '/repo/b' + ]) + // One up-front loading write plus the single failed batch's result write. + expect(setDirCache).toHaveBeenCalledTimes(2) + expect(cache['/repo/c']).toMatchObject({ loading: true }) + expect(cache['/repo/d']).toMatchObject({ loading: true }) + }) + it('drops a queued directory superseded while an earlier read is blocked', async () => { const tracker = createFileExplorerDirLoadTracker() let cache: Record = {} 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 index 06c5347b4f7..64dc0d587bb 100644 --- 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 @@ -51,6 +51,9 @@ export async function refreshFileExplorerExpandedDirs({ const pendingResults: { dirPath: string; cache: DirCache }[] = [] let settledSinceCommit = 0 let committedDirs = 0 + // Why: forEachWithConcurrency has no cancel hook, so a failed commit must stop the surviving + // workers itself — otherwise a later batch commits after the caller already saw this reject. + let stopped = false // Why: mark every dir loading up front — FileExplorer's auto-load // effect re-runs on any `expanded` change and fans out an unbounded loadDir per @@ -67,6 +70,9 @@ export async function refreshFileExplorerExpandedDirs({ }) const commitPendingResults = (): void => { + if (stopped) { + return + } settledSinceCommit = 0 const currentResults = pendingResults .splice(0) @@ -98,6 +104,7 @@ export async function refreshFileExplorerExpandedDirs({ } } if (commitFailed) { + stopped = true throw firstCommitError } } @@ -113,6 +120,9 @@ export async function refreshFileExplorerExpandedDirs({ } await forEachWithConcurrency(uniqueDirs, maxConcurrentReads, async ({ dirPath, depth }) => { + if (stopped) { + return + } const loadToken = loadTokens.get(dirPath)! // A superseding load owns this dir now; do not spend a round trip on a result we must drop. if (!dirLoadTracker.isCurrent(loadToken)) { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 0d9c42af68e..d8b218b6409 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -128,7 +128,9 @@ export function useFileExplorerWatch({ // Why: one atomic effect avoids a cleanup-ordering race that drops events on rapid worktree switches (review issue §3). useEffect(() => { - if (!worktreePath || activeRuntimeEnvironmentId === undefined) { + // Why require a worktree id: it is half of every watch key, and reconciliation already needs it — + // a null-keyed subscription would only park resync state no correctly-keyed one can consume. + if (!worktreePath || !activeWorktreeId || activeRuntimeEnvironmentId === undefined) { return } @@ -172,9 +174,6 @@ export function useFileExplorerWatch({ } function processPayload(payload: FsChangedPayload): void { - if (!currentWorktreeId) { - return - } processFileExplorerFsPayload({ payload, currentWorktreePath, From 2f104d8713dfaa40b3f53cd321820397b0dec2b4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:50:54 -0700 Subject: [PATCH 10/64] Tier GitHub PR lookup polling to prevent quota exhaustion (#12013) * Tier GitHub PR lookup polling to prevent quota exhaustion The selected worktree (O(1)) checks per-minute; card list (O(N)) per-15-minutes. Introduce process-wide cache to collapse concurrent polling and gate lookups on available rate-limit budget with exponential backoff on failure. - Preserve last-known review during backoff - Invalidate cache when Orca opens a PR - Stop coordinator from double-charging * Tier GitHub PR lookup polling to prevent quota exhaustion - Return the latest reset time when both GitHub API buckets are rate-limited, preventing premature retries against still-blocked buckets. - Serve the last known review on transient lookup failures, preventing reviews from blinking out on temporary errors. - Discard in-flight lookups that predate an invalidation so stale answers cannot overwrite newly opened reviews. * fix: give rate-limit reset tests unique titles oxlint vitest/no-identical-title was failing static analysis because two cases shared the same describe title. --- mobile/src/session/github-pr-rpc.ts | 5 +- src/main/github/client.test.ts | 68 ++++ src/main/github/client.ts | 53 +++ .../github/default-branch-stale-pr.test.ts | 5 +- .../github/pr-refresh-coordinator.test.ts | 15 +- src/main/github/pr-refresh-coordinator.ts | 21 +- src/main/ipc/hosted-review.test.ts | 20 + src/main/ipc/hosted-review.ts | 1 + src/main/runtime/orca-runtime.ts | 2 + .../runtime/rpc/methods/hosted-review.test.ts | 22 ++ src/main/runtime/rpc/methods/hosted-review.ts | 3 + .../source-control/forge-provider.test.ts | 54 ++- src/main/source-control/forge-provider.ts | 32 +- .../hosted-review-branch-cache.test.ts | 374 ++++++++++++++++++ .../hosted-review-branch-cache.ts | 306 ++++++++++++++ .../hosted-review-creation.test.ts | 5 +- .../source-control/hosted-review-creation.ts | 18 +- .../hosted-review-refresh-pacing.ts | 42 ++ src/main/source-control/hosted-review.test.ts | 8 +- src/main/source-control/hosted-review.ts | 47 ++- .../components/right-sidebar/ChecksPanel.tsx | 5 +- .../right-sidebar/SourceControl.tsx | 5 +- .../src/store/slices/hosted-review.ts | 6 + src/shared/hosted-review.ts | 6 + 24 files changed, 1070 insertions(+), 53 deletions(-) create mode 100644 src/main/source-control/hosted-review-branch-cache.test.ts create mode 100644 src/main/source-control/hosted-review-branch-cache.ts create mode 100644 src/main/source-control/hosted-review-refresh-pacing.ts diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 0ae009d926a..24baef0a2d1 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -141,7 +141,10 @@ export async function fetchHostedReviewForBranch( { repo: mobileRepoSelectorFromWorktreeId(worktreeId), branch: args.branch, - linkedGitHubPR: args.linkedGitHubPR ?? null + linkedGitHubPR: args.linkedGitHubPR ?? null, + // Why: the mobile PR sidebar is only ever open on the selected worktree, + // so it belongs in the host's fast re-check tier (#11532). + active: true }, readForBranch ) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b4ede36e45..4b5f1375aa8 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -170,6 +170,7 @@ import { getPRComments, getPRForBranch, getPRForBranchOutcome, + getGitHubPRLookupRateLimitBlock, getRepoSlug, getRepoUpstream, getWorkItem, @@ -4609,3 +4610,70 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).not.toHaveBeenCalled() }) }) + +describe('getGitHubPRLookupRateLimitBlock', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getRemoteUrlForRepoMock.mockReset() + gitExecFileAsyncMock.mockReset() + getRateLimitMock.mockReset() + getRateLimitMock.mockResolvedValue(undefined) + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetOwnerRepoCache() + }) + + it('reports no block while every lookup bucket has budget', async () => { + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() + expect(getRateLimitMock).toHaveBeenCalled() + }) + + it('reports a block when either lookup bucket is exhausted', async () => { + rateLimitGuardMock.mockImplementation(((bucket: string) => + bucket === 'graphql' + ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } + : { blocked: false }) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_000_000 + }) + }) + + it('reports the latest reset when both lookup buckets are exhausted', async () => { + rateLimitGuardMock.mockImplementation(((bucket: string) => ({ + blocked: true, + remaining: 4, + limit: 5000, + // Why: core resets first, so returning it would retry into graphql's block. + resetAt: bucket === 'core' ? 1_800_000_000 : 1_800_003_600 + })) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_003_600 + }) + }) + + it('reports the later reset when graphql outlasts core', async () => { + // Retrying at the earlier reset would fail again on the bucket still blocked. + rateLimitGuardMock.mockImplementation(((bucket: string) => ({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: bucket === 'graphql' ? 1_800_000_600 : 1_800_000_000 + })) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_000_600 + }) + }) + + it('fails open when the exempt rate-limit probe itself fails', async () => { + getRateLimitMock.mockRejectedValue(new Error('probe offline')) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() + }) +}) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ff602bc4dbe..3684d7729b1 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -195,6 +195,52 @@ async function assertRateLimitBudget( } } +// Why: a branch lookup prefers REST but can fall back to `gh pr list` and +// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh +// coordinator's own estimate. +const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const + +/** + * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh + * coordinator's queue (#11532). + * + * The coordinator guards and paces its own background refreshes, but + * `hostedReview:forBranch` polls the same lookup straight from the renderer. + * Ungated, the two paths together could spend the user's entire hourly quota — + * which is per user and shared with their own `gh` and CLI agents. + * Returns the reset time when the caller must not spend, else `null`. + */ +export async function getGitHubPRLookupRateLimitBlock( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ resetAt: number } | null> { + const executionOptions = ghRepoExecOptions( + githubRepoContext(repoPath, connectionId, localGitOptions) + ) + // Why: identity resolution runs local git, which can fail for reasons that + // have nothing to do with the budget; let the lookup itself classify those. + const repository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + executionOptions + ).catch(() => null) + if (repository === null) { + return null + } + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the snapshot and is exempt from limits, so a + // failure must fail open rather than block the lookup (#7553). + await getRateLimit().catch(() => undefined) + } + // Why: retrying at the earlier reset would fail again on the bucket that has + // not reset yet, so the latest blocked reset is the only honest retry time. + const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : [])) + return resets.length > 0 ? { resetAt: Math.max(...resets) } : null +} + function prRefreshUpstreamError( err: unknown ): Extract { @@ -2956,6 +3002,13 @@ export async function getPRForBranchOutcome( if (connectionId && candidates.length === 0) { return { kind: 'no-pr', fetchedAt: Date.now() } } + // Why (#11532): account every lookup, not just the coordinator's queue — + // `hostedReview:forBranch` reaches this directly from renderer polling and + // was spending the shared quota invisibly. headRepo is `origin`, the same + // identity the coordinator guards on. + for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) { + noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions) + } let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index 23f3eb8b96e..fde4b6b8b84 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -32,6 +32,7 @@ const { getRateLimitMock, rateLimitGuardMock, noteRateLimitSpendMock, + noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, getSshGitProviderMock, @@ -52,6 +53,7 @@ const { blocked: false })), noteRateLimitSpendMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} @@ -116,7 +118,8 @@ vi.mock('./local-git-config-signature', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 7298c36af6d..30fc44b3c66 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -649,18 +649,9 @@ describe('pr-refresh-coordinator', () => { 'graphql', executionOptions ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - testCase.repository, - 'core', - 1, - executionOptions - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - testCase.repository, - 'graphql', - 1, - executionOptions - ) + // Why (#11532): the lookup itself debits the snapshot now, so every caller + // is accounted for; the coordinator must not double-charge on top. + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 70b13ec2e69..190cc82c6f9 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -11,12 +11,11 @@ import type { import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getOriginGitHubApiRepository } from './github-api-repository' import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' import { - getRateLimit, - noteRepositoryRateLimitSpend, - repositoryRateLimitGuard, - spendsSharedGitHubComQuota -} from './rate-limit' + lookupBackoffDelayMs, + NO_REVIEW_REFRESH_INTERVAL_MS +} from '../source-control/hosted-review-refresh-pacing' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { sendToTrustedUIRenderer } from '../ipc/ui' @@ -74,8 +73,6 @@ const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 const MIN_BACKGROUND_SPACING_MS = 10_000 const BACKGROUND_BUDGET_MAX = 20 const POST_PUSH_DELAY_MS = 2_500 -const BACKOFF_BASE_MS = 60_000 -const BACKOFF_MAX_MS = 15 * 60_000 const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const ACTIVE_BURST_WINDOW_MS = 30_000 const ACTIVE_BURST_MAX = 3 @@ -424,8 +421,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres */ function nextVisibleErrorRetryAt(key: string): number { const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = - Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) + const retryAt = Date.now() + lookupBackoffDelayMs(failures) errorBackoff.set(key, { failures, retryAt }) return retryAt } @@ -509,7 +505,7 @@ function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): numbe return 30 * 60_000 } if (candidate.cachedHasPR === false) { - return 15 * 60_000 + return NO_REVIEW_REFRESH_INTERVAL_MS } if ( candidate.cachedHasPR === true && @@ -776,9 +772,8 @@ async function drainQueue(): Promise { // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. noteActiveStart(next) } - for (const bucket of buckets) { - noteRepositoryRateLimitSpend(repository, bucket, 1, executionOptions) - } + // Why (#11532): the lookup itself now debits the snapshot, so every + // caller is accounted for; debiting here too would double-count. } const outcome = await getPRForBranchOutcome( diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 2f0e3e155b2..cef5d7f1d66 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -285,6 +285,26 @@ describe('registerHostedReviewHandlers', () => { localGitExecOptions: { wslDistro: 'Ubuntu' } }) ) + // Card-list polling is the O(N) tier and must not claim the fast one. + expect(getHostedReviewForBranchMock.mock.calls[0][0]).not.toHaveProperty('active') + }) + + it('carries a selected-worktree claim through to the branch lookup', async () => { + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + branch: 'feature/selected', + active: true + }) + + // Why: the right sidebar renders only the selected worktree, so its lookup + // earns the per-minute tier instead of the card-list interval (#11532). + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ branch: 'feature/selected', active: true }) + ) }) it('passes SSH connectionId through create eligibility instead of blocking the worktree', async () => { diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 07e36469ef6..670064d3e54 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -91,6 +91,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, currentHeadOid: args.currentHeadOid ?? null, + ...(args.active === true ? { active: true } : {}), ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 963c91811b9..18273e3a6ca 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18623,6 +18623,7 @@ export class OrcaRuntimeService { repoSelector: string branch: string currentHeadOid?: string | null + active?: boolean linkedGitHubPR?: number | null fallbackGitHubPR?: number | null linkedGitLabMR?: number | null @@ -18637,6 +18638,7 @@ export class OrcaRuntimeService { connectionId: repo.connectionId ?? null, branch: args.branch, currentHeadOid: args.currentHeadOid ?? null, + ...(args.active === true ? { active: true } : {}), linkedGitHubPR: args.linkedGitHubPR ?? null, fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, linkedGitLabMR: args.linkedGitLabMR ?? null, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 7c2e3cb7bb9..3d132e82e41 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -49,6 +49,28 @@ describe('hosted review RPC methods', () => { }) }) + it('carries a selected-worktree claim through to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getHostedReviewForBranch: vi.fn().mockResolvedValue(null) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + await dispatcher.dispatch( + makeRequest('hostedReview.forBranch', { + repo: '/repo', + branch: 'feature/selected', + active: true + }) + ) + + // Why: without this the mobile PR sidebar would sit on the card-list pacing + // and take a no-review interval to notice a PR opened elsewhere (#11532). + expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith( + expect.objectContaining({ active: true }) + ) + }) + it('dispatches creation eligibility requests to the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 538c6267cb2..b658c4c74cb 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -6,6 +6,8 @@ const HostedReviewForBranch = z.object({ repo: requiredString('Missing repo selector'), branch: requiredString('Missing branch'), currentHeadOid: z.string().nullable().optional(), + // Only the caller's selected worktree; the host caps how many earn the fast tier. + active: z.boolean().optional(), linkedGitHubPR: z.number().int().positive().nullable().optional(), fallbackGitHubPR: z.number().int().positive().nullable().optional(), linkedGitLabMR: z.number().int().positive().nullable().optional(), @@ -54,6 +56,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ repoSelector: params.repo, branch: params.branch, currentHeadOid: params.currentHeadOid ?? null, + ...(params.active === true ? { active: true } : {}), linkedGitHubPR: params.linkedGitHubPR ?? null, ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), linkedGitLabMR: params.linkedGitLabMR ?? null, diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 884b72f90de..ae5e5623a97 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -12,6 +12,7 @@ const { getProjectSlugMock, getPRForBranchOutcomeMock, getRepoSlugMock, + getGitHubPRLookupRateLimitBlockMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -25,12 +26,16 @@ const { getProjectSlugMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), getRepoSlugMock: vi.fn(), + getGitHubPRLookupRateLimitBlockMock: vi.fn(async () => null), getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: getMergeRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no MR found". + getMergeRequestForBranchOrThrow: getMergeRequestForBranchMock, getMergeRequest: vi.fn() })) @@ -41,7 +46,8 @@ vi.mock('../gitlab/merge-request-creation', () => ({ vi.mock('../github/client', () => ({ createGitHubPullRequest: createGitHubPullRequestMock, getRepoSlug: getRepoSlugMock, - getPRForBranchOutcome: getPRForBranchOutcomeMock + getPRForBranchOutcome: getPRForBranchOutcomeMock, + getGitHubPRLookupRateLimitBlock: getGitHubPRLookupRateLimitBlockMock })) vi.mock('../github/github-enterprise-repository', () => ({ @@ -103,6 +109,8 @@ describe('forge provider interface', () => { getPRForBranchOutcomeMock.mockReset() getRepoSlugMock.mockReset() getEnterpriseGitHubRepoSlugMock.mockReset() + getGitHubPRLookupRateLimitBlockMock.mockReset() + getGitHubPRLookupRateLimitBlockMock.mockResolvedValue(null) }) it('preserves the existing hosted provider detection order', async () => { @@ -377,4 +385,48 @@ describe('forge provider interface', () => { }) ).rejects.toThrow(/network/) }) + + it('refuses a GitHub branch lookup while the rate-limit budget is exhausted (#11532)', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValueOnce({ + resetAt: 1_800_000_000 + } as never) + + await expect( + getForgeProviderById('github').getReviewForBranch({ + repoPath: '/repo', + connectionId: null, + branch: 'feature/x' + }) + // Throwing (not null) keeps a low budget from reading as "no pull request". + ).rejects.toThrow(/rate_limited/) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + }) + + it('refuses a GitHub lookup by number while the rate-limit budget is exhausted (#11532)', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValueOnce({ + resetAt: 1_800_000_000 + } as never) + + await expect( + getForgeProviderById('github').getReviewByNumber({ + repoPath: '/repo', + connectionId: null, + number: 42 + }) + ).rejects.toThrow(/rate_limited/) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + }) + + it('does not gate non-GitHub providers on the GitHub rate limit', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValue({ resetAt: 1_800_000_000 } as never) + getMergeRequestForBranchMock.mockResolvedValue(null) + + await expect( + getForgeProviderById('gitlab').getReviewForBranch({ + repoPath: '/repo', + connectionId: null, + branch: 'feature/x' + }) + ).resolves.toBeNull() + }) }) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index d4967486ca0..19526ad71fa 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -21,7 +21,12 @@ import { getGiteaRepoSlug } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' -import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' +import { + createGitHubPullRequest, + getGitHubPRLookupRateLimitBlock, + getPRForBranchOutcome, + getRepoSlug +} from '../github/client' import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -122,6 +127,29 @@ function unwrapGitHubPRForBranchOutcome( return outcome.kind === 'found' ? mapGitHubReview(outcome.pr) : null } +/** + * Why (#11532): hosted-review lookups reach GitHub outside the PR refresh + * coordinator's paced queue, so they need the same rate-limit floor. Throwing + * (rather than returning null) keeps a low budget from reading as "no pull + * request" — callers preserve the last known review and back off. + */ +async function assertGitHubReviewRateLimitBudget( + input: ForgeProviderRepositoryContext +): Promise { + const block = await getGitHubPRLookupRateLimitBlock( + input.repoPath, + input.connectionId, + getHostedReviewLocalGitOptions(input) + ) + if (block) { + throw new Error( + `GitHub PR lookup failed (rate_limited): GitHub rate limit is low. Try again after ${new Date( + block.resetAt * 1000 + ).toLocaleTimeString()}.` + ) + } +} + const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, @@ -131,6 +159,7 @@ const gitHubForgeProvider = { resolveRepository: async (context) => getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { + await assertGitHubReviewRateLimitBudget(input) const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null const executionArgs = hostedReviewExecutionArgs(input) @@ -149,6 +178,7 @@ const gitHubForgeProvider = { return unwrapGitHubPRForBranchOutcome(outcome) }, async getReviewByNumber(input) { + await assertGitHubReviewRateLimitBudget(input) const executionArgs = hostedReviewExecutionArgs(input) const outcome = executionArgs.length > 0 diff --git a/src/main/source-control/hosted-review-branch-cache.test.ts b/src/main/source-control/hosted-review-branch-cache.test.ts new file mode 100644 index 00000000000..80d9220aa12 --- /dev/null +++ b/src/main/source-control/hosted-review-branch-cache.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + __resetHostedReviewBranchCacheForTests, + invalidateHostedReviewBranchCache, + withHostedReviewBranchCache +} from './hosted-review-branch-cache' + +const identity = { repoPath: '/repo', connectionId: null, branch: 'feature/x' } + +const openReview: HostedReviewInfo = { + provider: 'github', + number: 7, + title: 'Open PR', + state: 'open', + url: 'https://github.com/acme/orca/pull/7', + status: 'success', + updatedAt: '2026-07-31T00:00:00.000Z', + mergeable: 'MERGEABLE' +} + +const mergedReview: HostedReviewInfo = { ...openReview, state: 'merged' } + +describe('hosted review branch cache (#11532)', () => { + beforeEach(() => { + __resetHostedReviewBranchCacheForTests() + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('holds a no-review answer far longer than a poll interval', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 5 * 60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('keeps the no-review answer while the branch head moves', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + vi.setSystemTime(1_000_000 + 60_000) + await withHostedReviewBranchCache(identity, { headOid: 'bbb' }, lookup) + + // A commit is not evidence that a review was opened, so it must not defeat + // the long interval — that is what kept busy worktrees polling every minute. + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('refreshes a found review at the caller cadence', async () => { + const lookup = vi.fn(async () => openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 30_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('drops a merged review once the inspected head moves off it', async () => { + const lookup = vi.fn(async () => mergedReview) + + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + + await withHostedReviewBranchCache(identity, { headOid: 'bbb' }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('collapses concurrent callers onto one lookup', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const lookup = vi.fn( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + + const first = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + const second = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + resolveLookup(openReview) + + await expect(first).resolves.toEqual(openReview) + await expect(second).resolves.toEqual(openReview) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('separates lookups that differ only by linked review number', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache({ ...identity, linkedGitHubPR: 1 }, { headOid: null }, lookup) + await withHostedReviewBranchCache({ ...identity, linkedGitHubPR: 2 }, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('backs a failing branch off instead of re-asking every poll', async () => { + const lookup = vi.fn(async () => { + throw new Error('rate limited') + }) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'rate limited' + ) + vi.setSystemTime(1_000_000 + 30_000) + // Nothing cached, so the caller must still hear a failure — but no API call. + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + /backing off/ + ) + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'rate limited' + ) + expect(lookup).toHaveBeenCalledTimes(2) + + // The second failure doubles the window. + vi.setSystemTime(1_000_000 + 60_001 + 60_000) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + /backing off/ + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('serves the last known review from the failure itself, not only the backoff', async () => { + const lookup = vi + .fn<() => Promise>() + .mockResolvedValueOnce(openReview) + .mockRejectedValueOnce(new Error('transient')) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 60_001) + // The review must not blink out on the first failure and reappear on the next + // poll once the backoff window is what serves it. + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + + vi.setSystemTime(1_000_000 + 60_001 + 1_000) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('resets the escalation once a lookup succeeds', async () => { + const lookup = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('first')) + .mockResolvedValueOnce(openReview) + .mockRejectedValueOnce(new Error('second')) + .mockResolvedValue(mergedReview) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'first' + ) + vi.setSystemTime(1_000_000 + 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + + // The success clears the counter, so the next failure starts at the base + // window again rather than resuming a doubled one. That failure is served + // from the stale entry, but it still counts. + vi.setSystemTime(1_000_000 + 2 * 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + vi.setSystemTime(1_000_000 + 3 * 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + mergedReview + ) + expect(lookup).toHaveBeenCalledTimes(4) + }) + + it('retires a cached no-review answer when Orca opens a review', async () => { + const lookup = vi + .fn<() => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('discards a lookup that was already in flight when Orca opened a review', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const lookup = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + .mockResolvedValue(openReview) + + const inflight = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + // The poll started before the review existed, so its "no review" answer is + // older than the invalidation and must not be cached back over it. + resolveLookup(null) + await expect(inflight).resolves.toBeNull() + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('leaves another repo in-flight lookup cacheable across an invalidation', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const other = { ...identity, repoPath: '/other' } + const lookup = vi.fn<() => Promise>().mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + + const inflight = withHostedReviewBranchCache(other, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + resolveLookup(null) + await inflight + + await withHostedReviewBranchCache(other, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('scopes invalidation to one repo', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + await withHostedReviewBranchCache( + { ...identity, repoPath: '/other' }, + { headOid: null }, + lookup + ) + expect(lookup).toHaveBeenCalledTimes(2) + + invalidateHostedReviewBranchCache('/other', null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + await withHostedReviewBranchCache( + { ...identity, repoPath: '/other' }, + { headOid: null }, + lookup + ) + expect(lookup).toHaveBeenCalledTimes(3) + }) + + it('keeps SSH and local repos with the same path apart', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + await withHostedReviewBranchCache( + { ...identity, connectionId: 'ssh-1' }, + { headOid: null }, + lookup + ) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + describe('selected-worktree tier', () => { + it('re-checks the selected branch every minute while the card list waits', async () => { + const selected = vi.fn(async () => null) + const listed = vi.fn(async () => null) + const other = { ...identity, branch: 'feature/y' } + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, selected) + await withHostedReviewBranchCache(other, { headOid: null }, listed) + + vi.setSystemTime(1_000_000 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, selected) + await withHostedReviewBranchCache(other, { headOid: null }, listed) + + expect(selected).toHaveBeenCalledTimes(2) + expect(listed).toHaveBeenCalledTimes(1) + }) + + it('retires a cached no-review answer when a branch becomes the selection', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 1_000) + + // Selecting the worktree is the user asking whether a review exists yet. + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + + // Staying on it does not re-ask; the minute interval takes over. + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('keeps a found review when a branch becomes the selection', async () => { + const lookup = vi.fn(async () => openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 1_000) + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + + // Only the long no-review answer is worth spending a call to retire. + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('caps the fast tier so a caller cannot promote a whole list', async () => { + const lookup = vi.fn(async () => null) + const branchAt = (index: number) => ({ ...identity, branch: `feature/${index}` }) + + // One more claim than the cap allows, so the first claim is evicted. + for (let index = 0; index <= 8; index += 1) { + await withHostedReviewBranchCache(branchAt(index), { headOid: null, active: true }, lookup) + } + expect(lookup).toHaveBeenCalledTimes(9) + + vi.setSystemTime(1_000_000 + 60_001) + // The evicted branch is back on card pacing; the newest claim is not. + await withHostedReviewBranchCache(branchAt(0), { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(9) + await withHostedReviewBranchCache(branchAt(8), { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(10) + }) + + it('paces a card poll of the selected branch at the selection interval', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + vi.setSystemTime(1_000_000 + 60_001) + + // Freshness is a property of the branch, not of which surface asked. + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('returns a lapsed selection to card pacing', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + // Nothing re-asserted the selection for a full no-review interval. + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts new file mode 100644 index 00000000000..b1d54774ca8 --- /dev/null +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -0,0 +1,306 @@ +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + ACTIVE_CLAIM_TTL_MS, + ACTIVE_REFRESH_INTERVAL_MS, + lookupBackoffDelayMs, + LOOKUP_BACKOFF_MAX_MS, + MAX_ACTIVE_BRANCHES, + NO_REVIEW_REFRESH_INTERVAL_MS +} from './hosted-review-refresh-pacing' + +/** + * Process-wide cache for branch review lookups (#11532). + * + * `hostedReview:forBranch` is polled by every desktop window, the mobile client + * and `orca serve` alike, and each one used to reach the provider directly. The + * host's API quota is per user, so the only place that can pace them together is + * here — the single funnel they all pass through. + * + * Pacing is tiered by what the user is looking at rather than applied flat: the + * selected worktree is O(1) and can afford a per-minute re-check, while the + * worktree list is O(N) and is what exhausts the budget. + */ + +// Why: a found review still refreshes at the callers' poll cadence; the cache +// exists to collapse concurrent clients, not to make review state go stale. +const FOUND_REVIEW_TTL_MS = 60_000 +const MAX_ENTRIES = 500 + +type CacheEntry = { + review: HostedReviewInfo | null + fetchedAt: number + headOid: string | null +} + +const entries = new Map() +const inflight = new Map>() +const failureBackoff = new Map() +/** Branches a caller reported as its current selection, least recent first. */ +const activeClaims = new Map() +/** Bumped per repo on invalidation so a lookup that predates it cannot store. */ +const scopeGenerations = new Map() + +// Why: NUL is the one byte a repo path or branch name cannot contain, so a +// scope prefix cannot straddle a component boundary — invalidating `/a/b` must +// not also flush the unrelated repo at `/a/b c`. +const KEY_SEPARATOR = '\0' + +export type HostedReviewBranchCacheIdentity = { + repoPath: string + connectionId?: string | null + branch: string + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + localGitExecOptions?: unknown +} + +export type HostedReviewBranchCacheOptions = { + /** The worktree's checked-out HEAD oid, for merged-at-head visibility. */ + headOid: string | null + /** Set by surfaces that only ever render the selected worktree. */ + active?: boolean +} + +/** Repo-scoped prefix so a single repo's entries can be dropped without a full flush. */ +function repoScope(repoPath: string, connectionId?: string | null): string { + return `${connectionId ?? ''}${KEY_SEPARATOR}${repoPath}` +} + +export function hostedReviewBranchCacheKey(identity: HostedReviewBranchCacheIdentity): string { + return [ + repoScope(identity.repoPath, identity.connectionId), + identity.branch, + // Each linked id selects a different lookup, so it belongs in the identity. + identity.linkedGitHubPR ?? '', + identity.fallbackGitHubPR ?? '', + identity.linkedGitLabMR ?? '', + identity.linkedBitbucketPR ?? '', + identity.linkedAzureDevOpsPR ?? '', + identity.linkedGiteaPR ?? '', + identity.localGitExecOptions ? JSON.stringify(identity.localGitExecOptions) : '' + ].join(KEY_SEPARATOR) +} + +/** + * Records the caller's current selection, reporting whether the branch was not + * already active. Claims are least-recently-used so the fast tier stays bounded + * no matter how many a client asserts. + */ +function noteActiveClaim(key: string): boolean { + const now = Date.now() + for (const [candidate, claimedAt] of activeClaims) { + if (now - claimedAt > ACTIVE_CLAIM_TTL_MS) { + activeClaims.delete(candidate) + } + } + const wasActive = activeClaims.has(key) + activeClaims.delete(key) + activeClaims.set(key, now) + while (activeClaims.size > MAX_ACTIVE_BRANCHES) { + const oldest = activeClaims.keys().next().value + if (oldest === undefined) { + break + } + activeClaims.delete(oldest) + } + return !wasActive +} + +function isActiveBranch(key: string): boolean { + const claimedAt = activeClaims.get(key) + return claimedAt !== undefined && Date.now() - claimedAt <= ACTIVE_CLAIM_TTL_MS +} + +// Why: a merged review is the one answer that depends on the inspected head — +// the merged-at-head carve-out keeps it visible only while the head matches. +// Negative answers are deliberately head-insensitive, so a branch under active +// commits cannot defeat the long no-review interval. +function isHeadSensitive(entry: CacheEntry): boolean { + return entry.review?.state === 'merged' +} + +function refreshIntervalMs(entry: CacheEntry, active: boolean): number { + if (entry.review !== null) { + return FOUND_REVIEW_TTL_MS + } + return active ? ACTIVE_REFRESH_INTERVAL_MS : NO_REVIEW_REFRESH_INTERVAL_MS +} + +function isFresh(entry: CacheEntry, headOid: string | null, active: boolean): boolean { + if (isHeadSensitive(entry) && headOid !== null && entry.headOid !== null) { + if (headOid !== entry.headOid) { + return false + } + } + return Date.now() - entry.fetchedAt < refreshIntervalMs(entry, active) +} + +function storeEntry(key: string, entry: CacheEntry): void { + entries.delete(key) + entries.set(key, entry) + while (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next().value + if (oldest === undefined) { + break + } + entries.delete(oldest) + } +} + +function backoffUntil(key: string): number | null { + // Why: a lapsed window keeps its failure count, otherwise the very act of + // retrying resets the escalation and the backoff never grows past the base. + const entry = failureBackoff.get(key) + return entry !== undefined && entry.until > Date.now() ? entry.until : null +} + +function noteFailure(key: string): void { + const now = Date.now() + for (const [candidate, entry] of failureBackoff) { + // Why: only counts that lapsed a full max window ago are stale enough to + // forget; anything more eager would undo the escalation above. + if (now - entry.until > LOOKUP_BACKOFF_MAX_MS) { + failureBackoff.delete(candidate) + } + } + const failures = (failureBackoff.get(key)?.failures ?? 0) + 1 + failureBackoff.delete(key) + failureBackoff.set(key, { until: now + lookupBackoffDelayMs(failures), failures }) + while (failureBackoff.size > MAX_ENTRIES) { + const oldest = failureBackoff.keys().next().value + if (oldest === undefined) { + break + } + failureBackoff.delete(oldest) + } +} + +function scopeGeneration(scope: string): number { + return scopeGenerations.get(scope) ?? 0 +} + +function bumpScopeGeneration(scope: string): void { + const next = scopeGeneration(scope) + 1 + scopeGenerations.delete(scope) + scopeGenerations.set(scope, next) + // Why: an evicted scope reads as generation 0, which only makes a lookup in + // flight at eviction discard its result — a wasted call, never a stale one. + while (scopeGenerations.size > MAX_ENTRIES) { + const oldest = scopeGenerations.keys().next().value + if (oldest === undefined) { + break + } + scopeGenerations.delete(oldest) + } +} + +/** + * Drops every cached answer for a repo. Called when Orca itself opens a review, + * so the new one is visible immediately instead of after the no-review interval. + */ +export function invalidateHostedReviewBranchCache( + repoPath: string, + connectionId?: string | null +): void { + const scope = repoScope(repoPath, connectionId) + bumpScopeGeneration(scope) + const prefix = `${scope}${KEY_SEPARATOR}` + for (const key of entries.keys()) { + if (key.startsWith(prefix)) { + entries.delete(key) + } + } + for (const key of failureBackoff.keys()) { + if (key.startsWith(prefix)) { + failureBackoff.delete(key) + } + } +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewBranchCacheForTests(): void { + entries.clear() + inflight.clear() + failureBackoff.clear() + activeClaims.clear() + scopeGenerations.clear() +} + +/** + * Serves `lookup` through the shared cache: a fresh answer is reused, concurrent + * callers share one in-flight lookup, and a failing branch backs off instead of + * being re-asked at every caller's poll cadence. + */ +export async function withHostedReviewBranchCache( + identity: HostedReviewBranchCacheIdentity, + options: HostedReviewBranchCacheOptions, + lookup: () => Promise +): Promise { + const key = hostedReviewBranchCacheKey(identity) + const headOid = options.headOid + if (options.active === true && noteActiveClaim(key) && entries.get(key)?.review === null) { + // Why: switching to a worktree is the user asking whether a review exists + // yet, so the long no-review interval must not answer on their behalf. This + // is the cheap half of the fast tier — it costs one lookup per selection + // rather than one per minute. + entries.delete(key) + } + const active = isActiveBranch(key) + + const cached = entries.get(key) + if (cached && isFresh(cached, headOid, active)) { + return cached.review + } + + const pending = inflight.get(key) + if (pending) { + return pending + } + + const until = backoffUntil(key) + if (until !== null) { + // Why: a stale answer beats an error card, but with nothing cached the + // caller must hear the failure rather than read it as "no review". + if (cached) { + return cached.review + } + throw new Error( + `Hosted review lookup is backing off after repeated failures. Retrying after ${new Date( + until + ).toLocaleTimeString()}.` + ) + } + + const scope = repoScope(identity.repoPath, identity.connectionId) + const request = (async () => { + const generation = scopeGeneration(scope) + try { + const review = await lookup() + // Why: a review created while this lookup was out makes its answer older + // than the invalidation; storing it would re-pin the stale "no review". + if (generation === scopeGeneration(scope)) { + storeEntry(key, { review, fetchedAt: Date.now(), headOid }) + failureBackoff.delete(key) + } + return review + } catch (error) { + noteFailure(key) + // Why: the last good review beats an error card here just as it does on + // the backed-off path — otherwise it blinks out on the first failure. + // An invalidation drops the entry, so this cannot revive a retired answer. + const stale = entries.get(key) + if (stale) { + return stale.review + } + throw error + } finally { + inflight.delete(key) + } + })() + inflight.set(key, request) + return request +} diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 43de5f42518..83df9010d65 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -403,7 +403,10 @@ describe('createHostedReview', () => { expect.objectContaining({ repoPath: '/repo', branch: 'feature', - localGitExecOptions: { wslDistro: 'Ubuntu' } + localGitExecOptions: { wslDistro: 'Ubuntu' }, + // Why: a stale no-review answer here would leave Create enabled after a + // review was opened outside Orca, so eligibility takes the fast tier. + active: true }) ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 75e3189f447..f28c319f3ea 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -36,6 +36,7 @@ import { } from '../gitlab/gl-utils' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider' +import { invalidateHostedReviewBranchCache } from './hosted-review-branch-cache' import { getHostedReviewForBranch } from './hosted-review' import { getHostedReviewLocalGitOptions, @@ -503,6 +504,10 @@ export async function getHostedReviewCreationEligibility( linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, connectionId: args.connectionId ?? null, + // Why: eligibility is only ever asked for the worktree the user is acting + // on, so it earns the fast tier. Without it a review opened outside Orca + // in the last no-review interval would leave Create enabled (#11532). + active: true, ...hostedReviewExecutionContext(args) }) } catch (error) { @@ -624,7 +629,14 @@ export async function createHostedReview( return blocked } const localGitOptions = getHostedReviewLocalGitOptions(options) - return Object.keys(localGitOptions).length > 0 - ? provider.createReview(repoPath, input, connectionId, options) - : provider.createReview(repoPath, input, connectionId) + const result = + Object.keys(localGitOptions).length > 0 + ? await provider.createReview(repoPath, input, connectionId, options) + : await provider.createReview(repoPath, input, connectionId) + if (result.ok) { + // Why (#11532): the branch cache holds a "no review" answer for far longer + // than a poll interval, so Orca's own creation must retire it at once. + invalidateHostedReviewBranchCache(repoPath, connectionId) + } + return result } diff --git a/src/main/source-control/hosted-review-refresh-pacing.ts b/src/main/source-control/hosted-review-refresh-pacing.ts new file mode 100644 index 00000000000..7c2eaf3c666 --- /dev/null +++ b/src/main/source-control/hosted-review-refresh-pacing.ts @@ -0,0 +1,42 @@ +/** + * Shared pacing policy for hosted-review lookups (#11532). + * + * The PR refresh coordinator's queue and the `hostedReview:forBranch` entry + * point spend the same per-user API quota, so they must agree on how long an + * answer stays good and how hard to back off after a failure. Keeping the + * numbers here stops the two paths from drifting apart. + */ + +/** A branch with no review only gains one when a review is opened, which is not a per-minute event. */ +export const NO_REVIEW_REFRESH_INTERVAL_MS = 15 * 60_000 + +/** + * The worktree the user has selected is re-checked at the old cadence: a review + * opened in a browser should show up while they are still looking at the tab. + */ +export const ACTIVE_REFRESH_INTERVAL_MS = 60_000 + +/** + * Why: the fast tier is only affordable because it is O(1) — one selected + * worktree per client. Capping it here means a caller that wrongly marks a whole + * list active costs stale cards, not the 5,000/hr budget. + */ +export const MAX_ACTIVE_BRANCHES = 8 + +/** A selection nobody has re-asserted for a whole no-review interval is not current. */ +export const ACTIVE_CLAIM_TTL_MS = NO_REVIEW_REFRESH_INTERVAL_MS + +export const LOOKUP_BACKOFF_BASE_MS = 60_000 +export const LOOKUP_BACKOFF_MAX_MS = 15 * 60_000 + +// Why: capped so a long-lived failure settles at LOOKUP_BACKOFF_MAX_MS rather +// than overflowing the exponent. +const MAX_BACKOFF_DOUBLINGS = 4 + +/** Exponential backoff delay for the nth consecutive lookup failure (1-based). */ +export function lookupBackoffDelayMs(failures: number): number { + return Math.min( + LOOKUP_BACKOFF_MAX_MS, + LOOKUP_BACKOFF_BASE_MS * 2 ** Math.min(Math.max(failures, 1) - 1, MAX_BACKOFF_DOUBLINGS) + ) +} diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 102ebfebdbb..024ecff7168 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getHostedReviewForBranch } from './hosted-review' +import { __resetHostedReviewBranchCacheForTests } from './hosted-review-branch-cache' const { getProjectSlugMock, @@ -36,6 +38,7 @@ vi.mock('../gitlab/client', () => ({ vi.mock('../github/client', () => ({ getRepoSlug: getRepoSlugMock, getPRForBranchOutcome: getPRForBranchOutcomeMock, + getGitHubPRLookupRateLimitBlock: vi.fn(async () => null), createGitHubPullRequest: vi.fn() })) @@ -66,8 +69,6 @@ vi.mock('../gitea/client', () => ({ getGiteaPullRequest: vi.fn() })) -import { getHostedReviewForBranch } from './hosted-review' - describe('getHostedReviewForBranch', () => { beforeEach(() => { getProjectSlugMock.mockReset() @@ -80,6 +81,9 @@ describe('getHostedReviewForBranch', () => { getAzureDevOpsPullRequestForBranchMock.mockReset() getGiteaRepoSlugMock.mockReset() getGiteaPullRequestForBranchMock.mockReset() + // The branch cache is process-wide, so one test's answer would otherwise + // satisfy the next one's lookup. + __resetHostedReviewBranchCacheForTests() }) it('maps GitLab merge requests into the hosted review surface', async () => { diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index fbc5a71a528..50c68870b6e 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -1,5 +1,6 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' import { getForgeProviderForRepository, type ForgeProviderId } from './forge-provider' +import { withHostedReviewBranchCache } from './hosted-review-branch-cache' import type { HostedReviewExecutionOptions } from './hosted-review-git-options' function reviewLinkForProvider( @@ -35,6 +36,11 @@ export async function getHostedReviewForBranch( linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null currentHeadOid?: string | null + /** + * Set by surfaces that only ever render the selected worktree, which is the + * one branch cheap enough to re-check per minute (#11532). + */ + active?: boolean } & HostedReviewExecutionOptions ): Promise { const branchName = input.branch.replace(/^refs\/heads\//, '') @@ -52,20 +58,29 @@ export async function getHostedReviewForBranch( return null } - const provider = await getForgeProviderForRepository({ - repoPath: input.repoPath, - connectionId: input.connectionId, - ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}) - }) - if (!provider) { - return null - } - return provider.getReviewForBranch({ - repoPath: input.repoPath, - connectionId: input.connectionId, - branch: branchName, - ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}), - githubCurrentHeadOid: input.currentHeadOid ?? null, - ...reviewLinkForProvider(input, provider.id) - }) + const headOid = input.currentHeadOid?.trim() || null + // Why (#11532): every client polls this one entry point, and they share the + // host's per-user API quota, so the cache has to sit above the provider call. + return withHostedReviewBranchCache( + { ...input, branch: branchName }, + { headOid, ...(input.active === true ? { active: true } : {}) }, + async () => { + const provider = await getForgeProviderForRepository({ + repoPath: input.repoPath, + connectionId: input.connectionId, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}) + }) + if (!provider) { + return null + } + return provider.getReviewForBranch({ + repoPath: input.repoPath, + connectionId: input.connectionId, + branch: branchName, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}), + githubCurrentHeadOid: headOid, + ...reviewLinkForProvider(input, provider.id) + }) + } + ) } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ac5ad324211..30fade22496 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -1400,7 +1400,10 @@ export default function ChecksPanel(): React.JSX.Element { linkedBitbucketPR, linkedAzureDevOpsPR, linkedGiteaPR, - staleWhileRevalidate: true + staleWhileRevalidate: true, + // Why: this panel only ever renders the selected worktree, so it earns + // the host's fast re-check tier (#11532). + active: true }) // Why: the gh-based refresh coordinator is GitHub-only; running it elsewhere gave a spurious gh_unavailable error hiding a valid composer. if (activeWorktreeId && isGitHubReviewContext) { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 6f5a4cc5fb1..a579b4ff846 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1721,7 +1721,10 @@ function SourceControlInner(): React.JSX.Element { linkedBitbucketPR, linkedAzureDevOpsPR, linkedGiteaPR, - staleWhileRevalidate: true + staleWhileRevalidate: true, + // Why: scoped to the active worktree, so it earns the host's fast + // re-check tier instead of the O(N) card pacing (#11532). + active: true }) // Why: keep the GitHub cache refresh behind the coordinator so Source Control doesn't bypass pacing. enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 596c52b951b..885425c4b4b 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -32,6 +32,11 @@ type FetchOptions = { repoId?: string staleWhileRevalidate?: boolean currentHeadOid?: string | null + /** + * Pass from surfaces that only render the selected worktree. The host re-checks + * that branch per minute and paces the O(N) card list far slower (#11532). + */ + active?: boolean } type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } @@ -387,6 +392,7 @@ export const createHostedReviewSlice: StateCreator Date: Sat, 1 Aug 2026 22:52:43 +0000 Subject: [PATCH 11/64] release: v1.4.164-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a24daae6226..bcf7efafeb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.163", + "version": "1.4.164-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 8fc892dd023850df379c07cc4e7a46cff9a3ea84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=B2=E4=BA=BA?= <38777313+qiuyongjin@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:22:39 +0800 Subject: [PATCH 12/64] fix(i18n): add missing Editor Font Family translations for es, ja, ko, zh (#11573) --- src/renderer/src/i18n/locales/es.json | 10 +++++----- src/renderer/src/i18n/locales/ja.json | 10 +++++----- src/renderer/src/i18n/locales/ko.json | 10 +++++----- src/renderer/src/i18n/locales/zh.json | 10 +++++----- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 289d66b826a..14bb29e82d6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8163,8 +8163,8 @@ "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", "e61157e926": "Ajuste de línea en el editor", "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal.", - "editorFontFamily": "Editor Font Family", - "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." + "editorFontFamily": "Fuente del editor", + "editorFontFamilyDesc": "Fuente utilizada por los editores de archivos y vistas de diferencias. Dejar vacío para seguir la fuente de la terminal." } }, "git": { @@ -9609,9 +9609,9 @@ "notice": "Orca Relay is in beta." }, "EditorFontFamilySetting": { - "title": "Editor Font Family", - "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", - "placeholder": "Same as terminal font" + "title": "Fuente del editor", + "description": "Fuente utilizada por los editores de archivos y vistas de diferencias. Dejar vacío para seguir la fuente de la terminal.", + "placeholder": "Igual que la fuente de la terminal" }, "GeneralRemoteServerUpdates": { "serverCount": "{{value0}} paired servers", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 79b6d5f51b6..ae78b70475e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8185,8 +8185,8 @@ "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", "e61157e926": "エディターのワードラップ", "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。", - "editorFontFamily": "Editor Font Family", - "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." + "editorFontFamily": "エディターフォント", + "editorFontFamilyDesc": "ファイルエディターと差分ビューで使用されるフォント。空の場合は端末フォントに従います。" } }, "git": { @@ -9609,9 +9609,9 @@ "notice": "Orca Relay is in beta." }, "EditorFontFamilySetting": { - "title": "Editor Font Family", - "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", - "placeholder": "Same as terminal font" + "title": "エディターフォント", + "description": "ファイルエディターと差分ビューで使用されるフォント。空の場合は端末フォントに従います。", + "placeholder": "端末フォントと同じ" }, "GeneralRemoteServerUpdates": { "serverCount": "{{value0}} paired servers", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fc471188b89..0fbfe1d38b5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8148,8 +8148,8 @@ "4497e2e2bb": "리치 Markdown을 편집하는 동안 브라우저 맞춤법 밑줄과 제안을 표시합니다.", "e61157e926": "편집기 자동 줄 바꿈", "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다.", - "editorFontFamily": "Editor Font Family", - "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." + "editorFontFamily": "편집기 글꼴", + "editorFontFamilyDesc": "파일 편집기 및 diff 보기에서 사용되는 글꼴입니다. 비워두면 터미널 글꼴을 따릅니다." } }, "git": { @@ -9609,9 +9609,9 @@ "notice": "Orca Relay is in beta." }, "EditorFontFamilySetting": { - "title": "Editor Font Family", - "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", - "placeholder": "Same as terminal font" + "title": "편집기 글꼴", + "description": "파일 편집기 및 diff 보기에서 사용되는 글꼴입니다. 비워두면 터미널 글꼴을 따릅니다.", + "placeholder": "터미널 글꼴과 동일" }, "GeneralRemoteServerUpdates": { "serverCount": "{{value0}} paired servers", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9a2037479b7..7c4328dac98 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8148,8 +8148,8 @@ "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "e61157e926": "编辑器自动换行", "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。", - "editorFontFamily": "Editor Font Family", - "editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font." + "editorFontFamily": "编辑器字体", + "editorFontFamilyDesc": "文件编辑器和差异视图使用的字体。留空则跟随终端字体。" } }, "git": { @@ -9609,9 +9609,9 @@ "notice": "Orca Relay is in beta." }, "EditorFontFamilySetting": { - "title": "Editor Font Family", - "description": "Font used by file editors and diff views. Leave empty to follow the terminal font.", - "placeholder": "Same as terminal font" + "title": "编辑器字体", + "description": "文件编辑器和差异视图使用的字体。留空则跟随终端字体。", + "placeholder": "与终端字体相同" }, "GeneralRemoteServerUpdates": { "serverCount": "{{value0}} paired servers", From e0f597a351f005b04d90a6b996180790737f97f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:58:08 +0000 Subject: [PATCH 13/64] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index c2e937384ec..df51a8732ac 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 12m + + downloads: 13m @@ -15,7 +15,7 @@ downloads downloads - 12m - 12m + 13m + 13m From dbfffa6530f468169efd106b88b4db68b4818796 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:38:49 -0700 Subject: [PATCH 14/64] Add first user prompt to AI Vault session history row (#12006) * Add first user prompt to AI Vault session history rows Re-parse transcripts on demand to extract and display the untruncated first user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads it when session details expand. Grok sessions extract the typed ask from envelope, skipping injected bootstrap rows. Supports Claude, Codex, Grok, and OpenCode agents. * fix(ai-vault): split SessionTime out to pass max-lines lint AiVaultSessionDetails exceeded the 400-line oxlint limit after adding first-prompt UI; move SessionTime into its own module. * fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers. * test(ai-vault): add first-user-prompt UTF-16 safety tests Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs, preventing corruption of astral characters in captured prompts. * fix(ai-vault): key first-prompt-card by session.id Remounting the card on session switches prevents late responses from a previous load from writing stale data into the component's refs. Also improves conversation-turn key stability. --- src/main/ai-vault/runtime-session-scanner.ts | 1 + .../session-first-user-prompt-read.test.ts | 137 ++++++++++ .../session-first-user-prompt-read.ts | 144 +++++++++++ .../ai-vault/session-scanner-accumulator.ts | 36 ++- ...ssion-scanner-first-user-prompt-capture.ts | 15 ++ .../session-scanner-first-user-prompt.test.ts | 39 +++ .../session-scanner-first-user-prompt.ts | 104 ++++++++ .../session-scanner-grok-parser.test.ts | 94 ++++++- .../ai-vault/session-scanner-grok-parser.ts | 91 ++++--- .../session-scanner-grok-user-text.test.ts | 50 ++++ .../session-scanner-grok-user-text.ts | 104 ++++++++ .../session-scanner-injected-title.test.ts | 2 + .../session-scanner-opencode-sqlite.test.ts | 104 ++++++++ .../session-scanner-opencode-sqlite.ts | 68 ++++- .../session-scanner-primary-parsers.ts | 16 +- .../session-scanner-secondary-parsers.ts | 6 +- .../session-scanner-text-normalization.ts | 30 ++- src/main/ai-vault/session-scanner-types.ts | 1 + .../ai-vault/session-scanner-values.test.ts | 19 ++ src/main/ai-vault/session-scanner-values.ts | 8 +- src/main/ai-vault/session-scanner.test.ts | 3 + src/main/ipc/ai-vault.ts | 5 + src/preload/api-types.ts | 4 + src/preload/index.ts | 8 +- .../right-sidebar/AiVaultSessionDetails.tsx | 243 +++++++----------- .../right-sidebar/AiVaultSessionRow.tsx | 31 ++- .../ai-vault-first-prompt-card.tsx | 188 ++++++++++++++ .../ai-vault-session-display.test.ts | 24 ++ .../right-sidebar/ai-vault-session-display.ts | 1 + .../ai-vault-session-row-display.tsx | 4 +- .../right-sidebar/ai-vault-session-time.tsx | 74 ++++++ src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/ai-vault-session-display.ts | 24 ++ src/shared/ai-vault-types.ts | 20 ++ .../harness-injected-user-turns.test.ts | 6 + src/shared/harness-injected-user-turns.ts | 20 +- 41 files changed, 1533 insertions(+), 238 deletions(-) create mode 100644 src/main/ai-vault/session-first-user-prompt-read.test.ts create mode 100644 src/main/ai-vault/session-first-user-prompt-read.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt-capture.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt.test.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt.ts create mode 100644 src/main/ai-vault/session-scanner-grok-user-text.test.ts create mode 100644 src/main/ai-vault/session-scanner-grok-user-text.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-time.tsx diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index baf4ca6d322..014a511cea2 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -76,6 +76,7 @@ const aiVaultListResultSchema = z.object({ totalTokens: z.number(), previewMessages: z.array(aiVaultSessionPreviewMessageSchema), // Optional keeps paired hosts on older builds compatible. + firstUserPrompt: z.string().nullable().optional(), lastUserPrompt: z.string().nullable().optional(), // Default keeps remote hosts running an older build (no recoverable-signal // fields) parseable; they simply report no recoverable-empty sessions. diff --git a/src/main/ai-vault/session-first-user-prompt-read.test.ts b/src/main/ai-vault/session-first-user-prompt-read.test.ts new file mode 100644 index 00000000000..5213c49ab3e --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' + +const tempRoots: string[] = [] + +afterEach(async () => { + // Best-effort cleanup; tests are sandboxed under mkdtemp. + const { rm } = await import('node:fs/promises') + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('readAiVaultFirstUserPrompt', () => { + it('returns the full first user prompt without preview truncation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const longPrompt = `Please implement the full vault first-prompt copy path.\n\n${'detail '.repeat(80).trimEnd()}` + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: false, + message: { role: 'user', content: longPrompt } + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'assistant', content: 'Working on it.', model: 'claude-sonnet-4-5' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.includes('\n\n')).toBe(true) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('extracts full Codex input_text content blocks (not preview-capped)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-codex-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-full.jsonl') + await mkdir(join(root, 'sessions', '2026', '07', '21'), { recursive: true }) + const longPrompt = `Review the PR and fix real regressions.\n\n${'context '.repeat(60).trimEnd()}` + await writeFile( + sessionPath, + [ + JSON.stringify({ + timestamp: '2026-07-21T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'codex-full-prompt', cwd: '/repo/app' } + }), + JSON.stringify({ + timestamp: '2026-07-21T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: longPrompt }] + } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'codex', + filePath: sessionPath, + codexHome: root + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('skips meta/harness user turns and returns the first real ask', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-meta-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: true, + message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' } + }), + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:01.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'Ship the first-prompt copy button' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe('Ship the first-prompt copy button') + }) + + it('resolves null instead of rejecting when the transcript is corrupt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-corrupt-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + // Grok's parser JSON.parses summary.json eagerly, so truncated JSON throws. + const summaryPath = join(sessionDir, 'summary.json') + await writeFile(summaryPath, '{"info": {"id": "session-1", "cwd": "/repo/a') + + await expect( + readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath }) + ).resolves.toEqual({ prompt: null }) + }) +}) diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts new file mode 100644 index 00000000000..2023303e4e6 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -0,0 +1,144 @@ +import { stat } from 'node:fs/promises' +import type { + AiVaultAgent, + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, + AiVaultSession +} from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { parseAgentSessionFile } from './session-scanner-agent-parser' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { FileWithMtime } from './session-scanner-types' + +export type ReadAiVaultFirstUserPromptArgs = { + agent: AiVaultAgent + filePath: string + sessionId?: string + executionHostId?: ExecutionHostId + codexHome?: string | null +} + +export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult + +/** IPC-safe entry: validates untyped payload then reads the full first prompt. */ +export async function handleAiVaultGetFirstUserPrompt( + args?: AiVaultFirstUserPromptArgs +): Promise { + if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { + return { prompt: null } + } + return readAiVaultFirstUserPrompt({ + agent: args.agent, + filePath: args.filePath, + sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, + executionHostId: args.executionHostId, + codexHome: args.codexHome + }) +} + +/** + * Re-parse one session transcript under full first-prompt capture and return + * the untruncated first real user ask for copy/reuse. + */ +export async function readAiVaultFirstUserPrompt( + args: ReadAiVaultFirstUserPromptArgs +): Promise { + const filePath = args.filePath.trim() + if (!filePath || !args.agent) { + return { prompt: null } + } + + // Why: transcript bodies live on the session host. Remote rows are skipped + // (same posture as listSubagentSessions); UI falls back to preview text. + const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID + if (executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return { prompt: null } + } + + // Why: partial/corrupt transcripts make parsers throw. Resolve null like every + // other unavailable case instead of rejecting the IPC call. + let session: AiVaultSession | null + try { + session = await withFullFirstUserPromptCapture(() => + parseSessionForFullFirstUserPrompt({ + agent: args.agent, + filePath, + sessionId: args.sessionId?.trim() || undefined, + codexHome: args.codexHome ?? null + }) + ) + } catch { + return { prompt: null } + } + + const prompt = session?.firstUserPrompt?.trim() || null + return { prompt } +} + +async function parseSessionForFullFirstUserPrompt(args: { + agent: AiVaultAgent + filePath: string + sessionId?: string + codexHome: string | null +}): Promise { + // Why: OpenCode SQLite sessions store filePath as the db path (not db#id). + // Re-parse in-process under full capture so ALS applies and we can read the + // earliest user row (worker list-scan path only joins newest messages). + if (args.agent === 'opencode') { + const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath) + if (fromSynthetic) { + return parseOpenCodeSqliteSession({ + dbPath: fromSynthetic.dbPath, + sessionId: fromSynthetic.sessionId, + platform: process.platform + }) + } + if (args.sessionId) { + return parseOpenCodeSqliteSession({ + dbPath: args.filePath, + sessionId: args.sessionId, + platform: process.platform + }) + } + } + + const file = await fileWithMtimeForPath(args.filePath) + if (!file) { + return null + } + + return parseAgentSessionFile( + { + agent: args.agent, + file, + codexHome: args.codexHome + }, + process.platform + ) +} + +async function fileWithMtimeForPath(filePath: string): Promise { + // OpenCode SQLite candidates use a synthetic `dbPath#sessionId` path that is + // not a real filesystem object; parsers that need it accept the path as-is. + if (filePath.includes('#')) { + return { + path: filePath, + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() + } + } + + try { + const info = await stat(filePath) + return { + path: filePath, + mtimeMs: info.mtimeMs, + modifiedAt: info.mtime.toISOString(), + sizeBytes: info.size + } + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 3ce4cd1181d..97651c57109 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -12,6 +12,11 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { extractPreviewContentText, extractString, @@ -41,6 +46,7 @@ export function createAccumulator(args: { messageCount: 0, totalTokens: 0, previewMessages: [], + firstUserPrompt: null, lastUserPrompt: null, queuedMessageCount: 0, subagentTranscriptCount: 0, @@ -113,6 +119,7 @@ export function finalizeSession( messageCount: accumulator.messageCount, totalTokens: accumulator.totalTokens, previewMessages: accumulator.previewMessages, + ...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}), ...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}), queuedMessageCount: accumulator.queuedMessageCount, subagentTranscriptCount: accumulator.subagentTranscriptCount, @@ -149,6 +156,9 @@ export function addPreviewMessage( role: AiVaultSessionPreviewMessage['role'] text: string | null timestamp?: unknown + // Why: Claude meta/injected turns still preview, but must not seed the + // copyable first-prompt row. + seedFirstUserPrompt?: boolean } ): void { const text = normalizePreviewText(args.text ?? '') @@ -163,18 +173,40 @@ export function addPreviewMessage( if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) { accumulator.previewMessages.shift() } + // Why: list scans never store firstUserPrompt (payload/perf). Only the + // on-demand full-capture path seeds the untruncated copy body. + if ( + args.role === 'user' && + args.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() && + args.text + ) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text) + } } export function addPreviewContent( accumulator: SessionAccumulator, role: AiVaultSessionPreviewMessage['role'], content: unknown, - timestamp?: unknown + timestamp?: unknown, + options?: { seedFirstUserPrompt?: boolean } ): void { + if ( + role === 'user' && + options?.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() + ) { + accumulator.firstUserPrompt = extractFullFirstUserPromptText(content) + } addPreviewMessage(accumulator, { role, text: extractPreviewContentText(content), - timestamp + timestamp, + // Content path already seeded above when capture is enabled. + seedFirstUserPrompt: false }) } diff --git a/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts new file mode 100644 index 00000000000..796fd273d0a --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +// Why: list scans must not carry full first-prompt bodies (up to 500 sessions +// per refresh). On-demand copy re-parses one transcript under `full` mode. +export type FirstUserPromptCaptureMode = 'none' | 'full' + +const firstUserPromptCaptureStorage = new AsyncLocalStorage() + +export function getFirstUserPromptCaptureMode(): FirstUserPromptCaptureMode { + return firstUserPromptCaptureStorage.getStore() ?? 'none' +} + +export function withFullFirstUserPromptCapture(fn: () => Promise): Promise { + return firstUserPromptCaptureStorage.run('full', fn) +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.test.ts b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts new file mode 100644 index 00000000000..47235cbce12 --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFullFirstUserPromptText } from './session-scanner-first-user-prompt' + +// Mirrors FULL_FIRST_USER_PROMPT_SAFETY_LIMIT in the module under test. +const SAFETY_LIMIT = 256 * 1024 + +describe('AI Vault full first-user-prompt normalization', () => { + it('drops an astral char straddling the safety limit instead of splitting it', () => { + const result = normalizeFullFirstUserPromptText(`${'a'.repeat(SAFETY_LIMIT - 1)}😀tail`) + + expect(result).toHaveLength(SAFETY_LIMIT - 1) + expect(result?.endsWith('a')).toBe(true) + expect(hasUnpairedSurrogate(result ?? '')).toBe(false) + }) + + it('keeps a prompt shorter than the safety limit intact', () => { + expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀') + }) +}) + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + const isHigh = code >= 0xd800 && code <= 0xdbff + const isLow = code >= 0xdc00 && code <= 0xdfff + if (isHigh) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return true + } + index += 1 + continue + } + if (isLow) { + return true + } + } + return false +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.ts b/src/main/ai-vault/session-scanner-first-user-prompt.ts new file mode 100644 index 00000000000..4228abc9aaa --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.ts @@ -0,0 +1,104 @@ +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import { getFirstUserPromptCaptureMode } from './session-scanner-first-user-prompt-capture' +import { stripGrokUserQueryEnvelope } from './session-scanner-grok-user-text' +// Direct import: session-scanner-values re-exports this module, so going through +// it here would close an import cycle. +import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization' + +// Why: safety only for pathological multi-MB pastes. Copy path must not use the +// 220-char list preview cap. +const FULL_FIRST_USER_PROMPT_SAFETY_LIMIT = 256 * 1024 + +// Codex uses input_text; most others use text. Never treat tool/image blocks as +// the written first ask. +const TEXT_LIKE_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text']) + +/** True only while an on-demand first-prompt read is re-parsing one transcript. */ +export function shouldCaptureFullFirstUserPrompt(): boolean { + return getFirstUserPromptCaptureMode() === 'full' +} + +/** + * Extract the written first-user ask for copy/reuse. Preserves newlines and does + * not apply list-preview caps. Returns null for non-text / harness / empty. + */ +export function extractFullFirstUserPromptText(value: unknown): string | null { + if (typeof value === 'string') { + return finalizeFullFirstUserPrompt(value) + } + + // Single content block object (not wrapped in an array). + if (value && typeof value === 'object' && !Array.isArray(value)) { + const blockText = firstUserPromptContentItemText(value) + return blockText != null ? finalizeFullFirstUserPrompt(blockText) : null + } + + if (!Array.isArray(value)) { + return null + } + + const parts: string[] = [] + for (const item of value) { + const text = firstUserPromptContentItemText(item) + if (text != null) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return finalizeFullFirstUserPrompt(parts.join('\n')) +} + +export function normalizeFullFirstUserPromptText(value: string): string | null { + return finalizeFullFirstUserPrompt(value) +} + +function finalizeFullFirstUserPrompt(value: string): string | null { + // Why: Grok (and some pasted transcripts) wrap the real ask in ; + // strip that before copy so the clipboard is the typed prompt, not user_info. + const unwrapped = stripGrokUserQueryEnvelope(value.replace(/^\uFEFF/, '')) + const trimmed = unwrapped.trim() + if (!trimmed) { + return null + } + if (isSuppressedFullFirstUserPrompt(trimmed)) { + return null + } + if (isKnownHarnessInjectedUserTurnText(trimmed)) { + return null + } + // Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path. + const lower = trimmed.toLowerCase() + if (lower.startsWith('') && !lower.includes('')) { + return null + } + return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) +} + +function isSuppressedFullFirstUserPrompt(value: string): boolean { + const head = value.slice(0, 64).toLowerCase() + return head.startsWith('# agents.md instructions') || head.startsWith('') +} + +function firstUserPromptContentItemText(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return null + } + const record = item as Record + const type = typeof record.type === 'string' ? record.type : null + if (type != null && !TEXT_LIKE_BLOCK_TYPES.has(type)) { + return null + } + if (typeof record.text === 'string' && record.text.length > 0) { + return record.text + } + // Some providers put the body on `content` for text-shaped blocks. + if (typeof record.content === 'string' && record.content.length > 0) { + return record.content + } + return null +} diff --git a/src/main/ai-vault/session-scanner-grok-parser.test.ts b/src/main/ai-vault/session-scanner-grok-parser.test.ts index 75c2d7575a6..bdbc19db468 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.test.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' -import { extractGrokContentText } from './session-scanner-grok-parser' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { extractGrokContentText, parseGrokSessionFile } from './session-scanner-grok-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) describe('AI Vault Grok session parser', () => { it('extracts bounded user_query text without trimming the full body', () => { @@ -8,23 +19,94 @@ describe('AI Vault Grok session parser', () => { `context\n${'Grok prompt '.repeat(400)}` ) const trimCalls = trimSpy.mock.calls.length + trimSpy.mockRestore() - expect(trimCalls).toBe(0) + // stripGrokUserQueryEnvelope trims the body once; the fold must never trim + // per character (the input here is 4800+ chars). + expect(trimCalls).toBeGreaterThan(0) + expect(trimCalls).toBeLessThan(20) expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true) expect(result?.endsWith('...')).toBe(true) expect(result).not.toContain('USER_QUERY') + expect(result).not.toContain('USER_INFO') }) it('folds Grok array content without joining all text parts', () => { - const joinSpy = vi.spyOn(Array.prototype, 'join') const result = extractGrokContentText([ { type: 'text', text: 'Grok array '.repeat(80) }, { type: 'text', text: 'tail' } ]) - const joinCalls = joinSpy.mock.calls.length - expect(joinCalls).toBe(0) expect(result?.startsWith('Grok array Grok array')).toBe(true) expect(result?.endsWith('...')).toBe(true) }) + + it('drops an astral char straddling the preview scan cap instead of splitting it', () => { + // Hidden context is skipped by the fold, so the 4096-code-unit scan cap lands + // mid-emoji while the visible text stays well under the 220-char preview cap. + const hidden = `${'x'.repeat(4057)}` + const result = extractGrokContentText(`${hidden}ask😀tail`) + + expect(hidden).toHaveLength(4092) + expect(result).toBe('ask') + }) + + it('stores the unwrapped user_query as firstUserPrompt under full capture', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-first-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + const summaryPath = join(sessionDir, 'summary.json') + await writeFile( + summaryPath, + JSON.stringify({ + info: { id: 'session-1', cwd: '/repo/app' }, + generated_title: 'Grok title', + created_at: '2026-05-01T10:00:00.000Z', + updated_at: '2026-05-01T10:05:00.000Z' + }) + ) + const realAsk = 'fix i18n keep ko workspace worktree and primary' + await writeFile( + join(sessionDir, 'chat_history.jsonl'), + [ + JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths over absolute paths as tool call args when possible.', + '', + `\n${realAsk}\n` + ].join('\n') + } + ], + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'assistant', + content: 'On it.', + timestamp: '2026-05-01T10:00:02.000Z' + }) + ].join('\n') + ) + + const session = await withFullFirstUserPromptCapture(() => + parseGrokSessionFile({ + path: summaryPath, + mtimeMs: Date.now(), + modifiedAt: new Date().toISOString() + }) + ) + + expect(session?.firstUserPrompt).toBe(realAsk) + expect(session?.firstUserPrompt).not.toContain('user_info') + expect(session?.firstUserPrompt).not.toContain('OS Version') + }) }) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 13620007447..46ed29e8f1f 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -11,6 +11,14 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' +import { + extractGrokFirstUserPromptText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' import { asRecord, extractPreviewContentText, @@ -18,7 +26,8 @@ import { normalizePreviewText, normalizeTitleText, numberValue, - parseJsonObject + parseJsonObject, + sliceAtCodeUnitLimit } from './session-scanner-values' const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096 @@ -68,14 +77,38 @@ async function consumeGrokChatHistory( if (role !== 'user' && role !== 'assistant') { continue } - const text = extractGrokContentText(record.content) + if (role === 'user') { - accumulator.title ??= normalizeTitleText(text ?? '') + // Why: first-prompt copy must be the typed ask inside , never + // the injected bootstrap row. + const firstPromptBody = extractGrokFirstUserPromptText(record.content) + const text = firstPromptBody + ? normalizePreviewText(capGrokPreviewSource(firstPromptBody)) + : null + + if (firstPromptBody) { + accumulator.title ??= normalizeTitleText(firstPromptBody) + if (shouldCaptureFullFirstUserPrompt() && !accumulator.firstUserPrompt) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(firstPromptBody) + } + } + + if (text) { + addPreviewMessage(accumulator, { + role: 'user', + text, + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false + }) + } + continue } + addPreviewMessage(accumulator, { - role, - text, - timestamp: extractString(record.timestamp) + role: 'assistant', + text: extractGrokContentText(record.content), + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false }) } } catch { @@ -90,45 +123,11 @@ export function extractGrokContentText(value: unknown): string | null { return extractPreviewContentText(value) } +function capGrokPreviewSource(text: string): string { + return sliceAtCodeUnitLimit(text, GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) +} + function extractGrokStringContentText(text: string): string | null { - const bounds = grokUserQueryEnvelopeBounds(text) - if (!bounds) { - return normalizePreviewText(text) - } - - const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) - return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text) -} - -function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null { - const opener = '' - const startIndex = indexOfAsciiIgnoreCase(text, opener, 0) - if (startIndex === -1) { - return null - } - const bodyStartIndex = startIndex + opener.length - const endIndex = indexOfAsciiIgnoreCase(text, '', bodyStartIndex) - if (endIndex === -1) { - return null - } - return { start: bodyStartIndex, end: endIndex } -} - -function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { - const lastStart = value.length - search.length - for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { - let matches = true - for (let offset = 0; offset < search.length; offset++) { - const code = value.charCodeAt(index + offset) - const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code - if (normalizedCode !== search.charCodeAt(offset)) { - matches = false - break - } - } - if (matches) { - return index - } - } - return -1 + const unwrapped = stripGrokUserQueryEnvelope(text) + return normalizePreviewText(capGrokPreviewSource(unwrapped)) } diff --git a/src/main/ai-vault/session-scanner-grok-user-text.test.ts b/src/main/ai-vault/session-scanner-grok-user-text.test.ts new file mode 100644 index 00000000000..0c6a6d830ea --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + extractGrokFirstUserPromptText, + isGrokBootstrapContextText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' + +describe('Grok first-user prompt text', () => { + it('unwraps user_query and drops the user_info bootstrap envelope', () => { + const raw = [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths', + '', + '', + 'fix i18n keep ko workspace worktree and primary', + '' + ].join('\n') + + expect(extractGrokFirstUserPromptText(raw)).toBe( + 'fix i18n keep ko workspace worktree and primary' + ) + expect(stripGrokUserQueryEnvelope(raw)).toBe('fix i18n keep ko workspace worktree and primary') + }) + + it('unwraps user_query even when the closing tag is missing', () => { + const raw = 'context\nShip the full first prompt copy path' + expect(extractGrokFirstUserPromptText(raw)).toBe('Ship the full first prompt copy path') + }) + + it('rejects pure user_info bootstrap rows', () => { + const bootstrap = [ + '', + 'OS Version: macos', + 'Note: Prefer using relative paths over absolute paths', + '' + ].join('\n') + expect(isGrokBootstrapContextText(bootstrap)).toBe(true) + expect(extractGrokFirstUserPromptText(bootstrap)).toBeNull() + }) + + it('keeps ordinary user prompts', () => { + expect(extractGrokFirstUserPromptText('fix the flaky vault tests')).toBe( + 'fix the flaky vault tests' + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-grok-user-text.ts b/src/main/ai-vault/session-scanner-grok-user-text.ts new file mode 100644 index 00000000000..05ee7779b10 --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.ts @@ -0,0 +1,104 @@ +// Shared Grok user-turn text helpers for AI Vault (list preview + full first-prompt +// copy). Kept out of the native-chat decoder so vault scanners stay free of that +// dependency while matching its bootstrap / user_query rules. + +/** + * Prefer the body of a Grok `` envelope when present (closing tag + * optional). Bootstrap ``-only rows return null so a later real ask + * can seed the first prompt. + */ +export function extractGrokFirstUserPromptText(value: unknown): string | null { + const raw = flattenGrokUserContent(value) + if (!raw) { + return null + } + if (isGrokBootstrapContextText(raw)) { + return null + } + const unwrapped = stripGrokUserQueryEnvelope(raw) + const trimmed = unwrapped.trim() + if (!trimmed || isGrokBootstrapContextText(trimmed)) { + return null + } + // Why: if the turn is still just a user_info dump (no query envelope), it is + // not the prompt the user typed and must not be copied as "first prompt". + if ( + startsWithIgnoreCaseTag(trimmed, 'user_info') && + !containsIgnoreCaseTag(trimmed, 'user_query') + ) { + return null + } + return trimmed +} + +function flattenGrokUserContent(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (!item || typeof item !== 'object') { + continue + } + const record = item as Record + if (typeof record.type === 'string' && record.type !== 'text') { + continue + } + if (typeof record.text === 'string') { + parts.push(record.text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +export function stripGrokUserQueryEnvelope(text: string): string { + const opener = '' + const closer = '' + const lower = text.toLowerCase() + const start = lower.indexOf(opener) + if (start === -1) { + return text + } + const bodyStart = start + opener.length + const end = lower.indexOf(closer, bodyStart) + // Why: incomplete closing tag still holds the real ask after the opener. + if (end === -1) { + return text.slice(bodyStart).trim() + } + return text.slice(bodyStart, end).trim() +} + +export function isGrokBootstrapContextText(text: string): boolean { + const normalized = text.trim().toLowerCase() + if (!normalized.startsWith('')) { + return false + } + const userInfoEnd = normalized.indexOf('') + if (userInfoEnd === -1) { + // Open-ended user_info dump with no query: treat as bootstrap noise. + return !normalized.includes('') + } + const remainder = normalized.slice(userInfoEnd + ''.length).trim() + // Why: Grok appends a git snapshot to the bootstrap row; reject that envelope + // so real prompts mentioning either tag still count as user asks. + return ( + remainder.length === 0 || + (remainder.startsWith('') && remainder.endsWith('')) + ) +} + +function startsWithIgnoreCaseTag(text: string, tagName: string): boolean { + const lower = text.trimStart().toLowerCase() + return lower.startsWith(`<${tagName}>`) || lower.startsWith(`<${tagName} `) +} + +function containsIgnoreCaseTag(text: string, tagName: string): boolean { + return text.toLowerCase().includes(`<${tagName}>`) +} diff --git a/src/main/ai-vault/session-scanner-injected-title.test.ts b/src/main/ai-vault/session-scanner-injected-title.test.ts index 1b3071b7576..f0f566a79f5 100644 --- a/src/main/ai-vault/session-scanner-injected-title.test.ts +++ b/src/main/ai-vault/session-scanner-injected-title.test.ts @@ -134,5 +134,7 @@ describe('scanAiVaultSessions harness-injected title seeding', () => { expect(result.issues).toEqual([]) expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR') + // Meta skill preamble must not become the copyable first prompt. + expect(result.sessions[0]?.firstUserPrompt).toBeUndefined() }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts index 29f3685add1..f7e042d663b 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' let tempDirs: string[] = [] @@ -496,4 +497,107 @@ describe('parseOpenCodeSqliteSession', () => { expect(session).not.toBeNull() expect(session!.model).toBe('claude-sonnet-4-5') }) + + it('captures every text part of the earliest user message and no later turn', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 10, + text: 'first ask line one' + }) + insertPart(db, { + id: 'part_1b', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 20, + text: 'first ask line two' + }) + // Non-text parts of the same message must not leak into the copied prompt. + insertPart(db, { + id: 'part_1c', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 30, + type: 'tool', + text: 'tool output blob' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp', + timeCreated: 40, + text: 'a later ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two') + }) + + it('skips an earliest user message that has no text parts', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp2', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp2', + timeCreated: 10, + type: 'tool', + text: 'tool only' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp2', + timeCreated: 40, + text: 'the real typed ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('the real typed ask') + }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index ad3e9b76f41..d247156b8cf 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -5,6 +5,10 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { normalizeTitleText } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -22,6 +26,8 @@ const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 // id) index. Bounds the read to those messages' parts; the 15 s parse timeout // caps the residual for a single pathological giant part. const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 +// Bounds a pathological single message; a real typed prompt is a handful of parts. +const FIRST_USER_PROMPT_PART_LIMIT = 512 type SessionRow = { id: string @@ -154,6 +160,58 @@ function extractPartText(partData: string): string | null { } } +function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + + try { + // Why: pin to the single earliest user message that actually has text parts, + // then take all of its parts. Ordering parts across every user message would + // pad a short first prompt with later turns and truncate a long one. + const rows = db + .prepare( + `SELECT p.data AS part_data + FROM part p + WHERE p.message_id = ( + SELECT m.id + FROM message m + JOIN part fp ON fp.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(fp.data, '$.type') = 'text' + ORDER BY m.time_created ASC, m.id ASC + LIMIT 1 + ) + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created ASC, p.rowid ASC + LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` + ) + .all(sessionId) as { part_data: string }[] + + const parts: string[] = [] + for (const row of rows) { + const text = extractPartText(row.part_data) + if (text) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return normalizeFullFirstUserPromptText(parts.join('\n')) + } catch { + return null + } +} + function buildPreviewQuery(db: SyncDatabase): string | null { if ( !canCountOpenCodeMessages(db) || @@ -254,7 +312,9 @@ export async function parseOpenCodeSqliteSession(args: { addPreviewMessage(accumulator, { role: mapPreviewRole(previewRow.role), text, - timestamp: previewRow.time_created + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false }) if (previewRow.role === 'user' && !accumulator.title) { accumulator.title = @@ -264,6 +324,12 @@ export async function parseOpenCodeSqliteSession(args: { } } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } + return finalizeSession(accumulator, platform) } finally { db?.close() diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index c7e67ec463b..537055d87d4 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -127,13 +127,17 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s if (record.type === 'user') { accumulator.messageCount++ const title = extractMessageText(record.message) - addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + // Meta prompts (injected context) only seed the last-resort title. Some + // injected turns (task notifications) carry no isMeta, so also gate on + // the known-tag classifier — a real prompt pasting a custom `` + // must seed the primary title, not be demoted as machinery. + const isMetaUserTurn = + record.isMeta === true || (title != null && isKnownHarnessInjectedUserTurnText(title)) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp, { + seedFirstUserPrompt: !isMetaUserTurn + }) if (title) { - // Meta prompts (injected context) only seed the last-resort title. Some - // injected turns (task notifications) carry no isMeta, so also gate on - // the known-tag classifier — a real prompt pasting a custom `` - // must seed the primary title, not be demoted as machinery. - if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) { + if (isMetaUserTurn) { state.metaTitle ??= title } else { state.firstUserTitle ??= title diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index 364c25b5d85..cb472aaefe9 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -18,13 +18,13 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt' import { arrayValue, asRecord, copilotModelMetricsTotal, extractContentText, extractMessageText, - extractPreviewContentText, extractString, extractTrustedFolder, findOpenCodeStorageRoot, @@ -253,10 +253,12 @@ export async function consumeOpenCodeMessages( accumulator.title ??= extractString(asRecord(message.summary)?.title) accumulator.title ??= extractString(asRecord(message.summary)?.body) } + // Why: pass raw body text so full first-prompt capture is not stuck on the + // 220-char preview fold (addPreviewMessage preview-caps for display). addPreviewMessage(accumulator, { role, text: - extractPreviewContentText(message.content) ?? + extractFullFirstUserPromptText(message.content) ?? extractString(asRecord(message.summary)?.body) ?? extractString(asRecord(message.summary)?.title), timestamp: timeObjectValue(message.time, 'created') diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 88eae73eb2e..99f73a93d02 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -42,6 +42,15 @@ export function normalizePreviewText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) } +/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */ +export function sliceAtCodeUnitLimit(value: string, limit: number): string { + if (value.length <= limit) { + return value + } + const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit + return value.slice(0, end) +} + function normalizeContentText(value: unknown, limit: number): string | null { if (typeof value === 'string') { return finalizeNormalizedText(normalizeStringText(value, limit)) @@ -112,15 +121,18 @@ function appendInterPartSpace(builder: TextBuilder): void { } } -function appendNormalizedString(builder: TextBuilder, value: string): void { +function appendNormalizedString(builder: TextBuilder, value: string, maxScanLength?: number): void { + const scanEnd = maxScanLength == null ? value.length : Math.min(value.length, maxScanLength) let index = 0 - while (index < value.length && !builder.truncated) { + while (index < scanEnd && !builder.truncated) { const hiddenBlockEnd = hiddenTextBlockEnd(value, index) if (hiddenBlockEnd !== null) { if (builder.text.length > 0) { builder.pendingSpace = true } - index = hiddenBlockEnd + // Why: hidden blocks may jump past the scan budget; clamp so multi-MB + // suppressed context cannot keep the first-prompt path busy. + index = Math.min(hiddenBlockEnd, scanEnd) continue } @@ -142,9 +154,17 @@ function appendNormalizedString(builder: TextBuilder, value: string): void { } const charLength = codePointLength(value, index) + // Why: do not read past scanEnd mid code-point when the budget lands inside + // a surrogate pair — drop the incomplete char instead. + if (index + charLength > scanEnd) { + break + } appendVisibleText(builder, value.slice(index, index + charLength)) index += charLength } + if (!builder.truncated && scanEnd < value.length && builder.text.length > 0) { + builder.truncated = true + } } function appendVisibleText(builder: TextBuilder, value: string): void { @@ -230,9 +250,7 @@ function isSuppressedContextPrefix(value: string): boolean { } function truncateWithEllipsis(value: string, limit: number): string { - const end = Math.max(0, limit - ELLIPSIS.length) - const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end - return `${value.slice(0, safeEnd)}${ELLIPSIS}` + return `${sliceAtCodeUnitLimit(value, Math.max(0, limit - ELLIPSIS.length))}${ELLIPSIS}` } function objectRecord(value: unknown): Record | null { diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index ac94a067d2a..cc8f4939aa2 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -110,6 +110,7 @@ export type SessionAccumulator = { messageCount: number totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] + firstUserPrompt: string | null lastUserPrompt: string | null // Recoverable signal for a zero-turn transcript (see AiVaultSession). queuedMessageCount: number diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index a9954ae8d26..3ca34327299 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { + extractFullFirstUserPromptText, extractPreviewContentText, normalizeAgentSessionsDir, + normalizeFullFirstUserPromptText, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -53,6 +55,23 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + it('preserves full first-prompt text including newlines for copy', () => { + const body = `First prompt line one\n\nline two ${'word '.repeat(100)}` + expect(normalizeFullFirstUserPromptText(body)).toBe(body.trim()) + expect(extractFullFirstUserPromptText([{ type: 'text', text: body }])).toBe(body.trim()) + }) + + it('reads Codex input_text blocks and ignores tool blocks', () => { + const body = `Review the PR\n\n${'detail '.repeat(50).trimEnd()}` + expect(extractFullFirstUserPromptText([{ type: 'input_text', text: body }])).toBe(body) + expect( + extractFullFirstUserPromptText([ + { type: 'tool_result', content: 'src/main/window.ts was updated' }, + { type: 'text', text: 'Please continue the editor refactor' } + ]) + ).toBe('Please continue the editor refactor') + }) + it('expands Pi and OMP agent homes to their session directories', () => { expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 1deb8bf5dc9..10ec34cd15e 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -55,8 +55,14 @@ export { extractMessageText, extractPreviewContentText, normalizePreviewText, - normalizeTitleText + normalizeTitleText, + sliceAtCodeUnitLimit } from './session-scanner-text-normalization' +export { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' export function extractGitBranch(value: unknown): string | null { const git = asRecord(value) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 7ba80556051..7c472d6f3eb 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -174,6 +174,8 @@ describe('scanAiVaultSessions', () => { totalTokens: 155, resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'" }) + // Why: list scans omit firstUserPrompt so the vault payload stays bounded. + expect(claude?.firstUserPrompt).toBeUndefined() const codex = result.sessions.find((session) => session.agent === 'codex') expect(codex).toMatchObject({ @@ -185,6 +187,7 @@ describe('scanAiVaultSessions', () => { totalTokens: 625, resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'` }) + expect(codex?.firstUserPrompt).toBeUndefined() }) it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 0e44962906e..e103b7b5cd4 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -13,11 +13,13 @@ import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-disco import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import type { + AiVaultFirstUserPromptArgs, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, @@ -286,6 +288,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo (_event, args?: AiVaultSubagentListArgs): Promise => listAiVaultSubagentSessions(args) ) + ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => + handleAiVaultGetFirstUserPrompt(args) + ) // DOM focus/visibility events don't fire in the renderer on macOS app // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 368c4df4ff7..3c633dd2dd8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -481,6 +481,8 @@ import type { OpenCodeUsageSummary } from '../shared/opencode-usage-types' import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, @@ -884,6 +886,8 @@ export type AiVaultApi = { ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise + /** Full first user prompt for copy/reuse (re-parses one transcript). */ + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index d838a28d461..3df27dad978 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -230,7 +230,11 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' import { @@ -4163,6 +4167,8 @@ const api = { ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 29b21ff071a..30e44d375ac 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,5 +1,12 @@ import type React from 'react' -import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' +import { + FileJson, + FolderGit2, + MessageSquare, + MessageSquarePlus, + Play, + TextCursorInput +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -9,7 +16,8 @@ import { type AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' -import { sessionDetailConversationTurns } from './ai-vault-session-display' +import { FirstPromptCard } from './ai-vault-first-prompt-card' +import { sessionDetailConversationTurns, sessionFirstPrompt } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' import { @@ -51,6 +59,7 @@ export function SessionInlineDetails({ const showResumeInNewTab = hasResumableContent && (!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId)) + const firstPromptPreview = sessionFirstPrompt(session) const detailTurns = sessionDetailConversationTurns(session, 3) const worktreeDisplay = worktreeInfo @@ -66,78 +75,8 @@ export function SessionInlineDetails({ event.stopPropagation() }} > -
- {hasResumableContent ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', - 'Latest turns' - )} - > - {detailTurns.length > 0 ? ( -
- {detailTurns.map((turn, index) => ( - - ))} -
- ) : ( - - )} -
- ) : ( - // An unsaved session has no turns to show; the notice replaces the - // preview section instead of stacking a second empty state under it. - - )} - - - - {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { - vaultScope - }) ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.worktree', - 'Worktree' - )} - > - - - ) : null} -
- {showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? ( -
- {onContinueInNewSession ? ( - - ) : null} +
{showResumeInWorktree ? ( + ) : null} {onOpenLog ? (
) } @@ -241,7 +265,7 @@ function ConversationTurnCard({
{conversationRoleLabel(role)}
-

+

{text}

@@ -313,77 +337,6 @@ function SessionDetailEmptyState({ message }: { message: string }): React.JSX.El ) } -export function SessionTime({ - value, - className -}: { - value: string - className?: string -}): React.JSX.Element { - const timestamp = Date.parse(value) - if (!Number.isFinite(timestamp)) { - return ( - - {translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime', - 'Unknown time' - )} - - ) - } - - const date = new Date(timestamp) - return ( - - - - ) -} - -function formatTimeAgo(timestamp: number): string { - const diffMs = Date.now() - timestamp - if (diffMs < 60_000) { - return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now') - } - const minutes = Math.floor(diffMs / 60_000) - if (minutes < 60) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo', - '{{value0}}m ago', - { value0: minutes } - ) - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo', - '{{value0}}h ago', - { value0: hours } - ) - } - const days = Math.floor(hours / 24) - if (days < 30) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo', - '{{value0}}d ago', - { value0: days } - ) - } - const months = Math.floor(days / 30) - if (months < 12) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo', - '{{value0}}mo ago', - { value0: months } - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo', - '{{value0}}y ago', - { value0: Math.floor(months / 12) } - ) -} - function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { if (role === 'user') { return translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 921ecf33f30..416fb2bb79e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -87,11 +87,6 @@ export function VaultSessionRow({ const startResumeDrag = useCallback( (event: React.DragEvent): void => { event.stopPropagation() - const target = event.target - if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) { - event.preventDefault() - return - } if (resumeDisabled) { event.preventDefault() return @@ -122,27 +117,35 @@ export function VaultSessionRow({ + + + + ) } diff --git a/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx b/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx new file mode 100644 index 00000000000..b5dd245db11 --- /dev/null +++ b/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { VoiceSettings } from '../../../../shared/speech-types' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + buildVoiceMicrophoneSelectOptions, + listVoiceMicrophoneDevices, + microphoneDeviceIdFromSelectValue, + type VoiceMicrophoneDevice +} from '@/components/dictation/microphone-devices' +import { translate } from '@/i18n/i18n' + +type VoiceMicrophoneSettingProps = { + voiceSettings: VoiceSettings + onUpdateVoiceSettings: (updates: Partial) => void +} + +function sameDeviceList( + a: readonly VoiceMicrophoneDevice[], + b: readonly VoiceMicrophoneDevice[] +): boolean { + return ( + a.length === b.length && + a.every((device, index) => { + const other = b[index] + return device.deviceId === other?.deviceId && device.label === other.label + }) + ) +} + +export function VoiceMicrophoneSetting({ + voiceSettings, + onUpdateVoiceSettings +}: VoiceMicrophoneSettingProps): React.JSX.Element { + const [devices, setDevices] = useState([]) + const [devicesKnown, setDevicesKnown] = useState(false) + const [accessPending, setAccessPending] = useState(false) + const mountedRef = useRef(true) + // Why: devicechange fires several times per Bluetooth connect; drop enumerations + // that resolve out of order so a stale list cannot land last. + const refreshGenerationRef = useRef(0) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const refreshDevices = useCallback(async (): Promise => { + const generation = refreshGenerationRef.current + 1 + refreshGenerationRef.current = generation + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) { + return + } + let next: VoiceMicrophoneDevice[] = [] + try { + next = listVoiceMicrophoneDevices(await navigator.mediaDevices.enumerateDevices()) + } catch { + next = [] + } + if (!mountedRef.current || refreshGenerationRef.current !== generation) { + return + } + setDevicesKnown(next.length > 0) + setDevices((current) => (sameDeviceList(current, next) ? current : next)) + }, []) + + // Why: voiceSettings.enabled is a dependency so enabling dictation re-scans — + // that toggle is often when mic permission lands and real labels appear. + useEffect(() => { + void refreshDevices() + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.addEventListener) { + return + } + const handleDeviceChange = (): void => { + void refreshDevices() + } + navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange) + return () => { + navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange) + } + }, [refreshDevices, voiceSettings.enabled]) + + // Why: enumerateDevices hides ids and labels until mic permission is granted, so + // the list stays empty until something opens a stream at least once. + const requestMicrophoneAccess = useCallback(async (): Promise => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { + return + } + setAccessPending(true) + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + stream.getTracks().forEach((track) => track.stop()) + await refreshDevices() + } catch { + // Denied or unavailable — the hint stays visible so the user can retry. + } finally { + if (mountedRef.current) { + setAccessPending(false) + } + } + }, [refreshDevices]) + + const { options, selectedValue } = useMemo( + () => + buildVoiceMicrophoneSelectOptions({ + devices, + devicesKnown, + preferredDeviceId: voiceSettings.microphoneDeviceId, + preferredDeviceLabel: voiceSettings.microphoneDeviceLabel, + systemDefaultLabel: translate( + 'auto.components.settings.VoiceMicrophoneSetting.systemDefault', + 'System default' + ), + unavailableSuffix: translate( + 'auto.components.settings.VoiceMicrophoneSetting.unavailable', + 'unavailable' + ) + }), + [devices, devicesKnown, voiceSettings.microphoneDeviceId, voiceSettings.microphoneDeviceLabel] + ) + + const showAccessHint = voiceSettings.enabled && devices.length === 0 + + return ( +
+
+ +

+ {translate( + 'auto.components.settings.VoiceMicrophoneSetting.description', + 'Input device used for voice dictation. System default follows the OS microphone setting.' + )} +

+ {showAccessHint && ( +
+

+ {translate( + 'auto.components.settings.VoiceMicrophoneSetting.accessHint', + 'Allow microphone access to list input devices.' + )} +

+ +
+ )} +
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/voice-pane-search.ts b/src/renderer/src/components/settings/voice-pane-search.ts index 4568f65d7bd..7dbf56c8b57 100644 --- a/src/renderer/src/components/settings/voice-pane-search.ts +++ b/src/renderer/src/components/settings/voice-pane-search.ts @@ -73,6 +73,31 @@ export const getVoicePaneSearchEntries = createLocalizedCatalog(() => [ ) ] }, + { + title: translate('auto.components.settings.voice.pane.search.microphoneTitle', 'Microphone'), + description: translate( + 'auto.components.settings.voice.pane.search.microphoneDescription', + 'Choose which input device voice dictation uses.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.voice.pane.search.7640ed9848', 'voice'), + ...translateSearchKeyword( + 'auto.components.settings.voice.pane.search.089d31a45b', + 'dictation' + ), + ...translateSearchKeyword( + 'auto.components.settings.voice.pane.search.e360027a65', + 'microphone' + ), + ...translateSearchKeyword('auto.components.settings.voice.pane.search.micInput', 'input'), + ...translateSearchKeyword('auto.components.settings.voice.pane.search.micDevice', 'device'), + ...translateSearchKeyword('auto.components.settings.voice.pane.search.micAirpods', 'airpods'), + ...translateSearchKeyword( + 'auto.components.settings.voice.pane.search.micDefault', + 'system default' + ) + ] + }, getOpenaiTranscriptionSearchEntry(), { title: translate('auto.components.settings.voice.pane.search.7e62cd7c41', 'Speech Model'), diff --git a/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts new file mode 100644 index 00000000000..2f9dd125e91 --- /dev/null +++ b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAudioCapture } from './use-audio-capture' + +class FakeTrack extends EventTarget { + stop = vi.fn() +} + +function makeStream(track: FakeTrack): MediaStream { + return { + getTracks: () => [track], + getAudioTracks: () => [track] + } as unknown as MediaStream +} + +function installAudioContext(): void { + const processor = { + connect: vi.fn(), + disconnect: vi.fn(), + onaudioprocess: null + } + const source = { connect: vi.fn(), disconnect: vi.fn() } + vi.stubGlobal( + 'AudioContext', + class { + state = 'running' + sampleRate = 48_000 + destination = {} + createMediaStreamSource = vi.fn(() => source) + createScriptProcessor = vi.fn(() => processor) + resume = vi.fn(async () => undefined) + close = vi.fn(async () => undefined) + } + ) +} + +describe('useAudioCapture device loss', () => { + let track: FakeTrack + + beforeEach(() => { + track = new FakeTrack() + installAudioContext() + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => makeStream(track)), + enumerateDevices: vi.fn(async () => [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in' } + ]) + } + }) + vi.stubGlobal('window', { + ...globalThis.window, + api: { speech: { feedAudio: vi.fn(async () => undefined) } } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reports the device disappearing mid-capture', async () => { + const onCaptureLost = vi.fn() + const { result } = renderHook(() => useAudioCapture()) + + await result.current.start({ microphoneDeviceId: 'mic-1', onCaptureLost }) + track.dispatchEvent(new Event('ended')) + + expect(onCaptureLost).toHaveBeenCalledTimes(1) + }) + + it('stays silent when we stopped capture ourselves', async () => { + const onCaptureLost = vi.fn() + const { result } = renderHook(() => useAudioCapture()) + + await result.current.start({ microphoneDeviceId: 'mic-1', onCaptureLost }) + result.current.stop() + track.dispatchEvent(new Event('ended')) + + expect(onCaptureLost).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index 7348bce2419..025e29092e2 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -1,4 +1,5 @@ import { useRef, useCallback } from 'react' +import { openMicrophoneCaptureStream } from '@/components/dictation/microphone-devices' type BufferedAudioChunk = { samples: Float32Array @@ -9,6 +10,16 @@ type BufferedAudioChunk = { type StartAudioCaptureOptions = { bufferAudio?: boolean sessionId?: string + /** null/undefined = system default input device */ + microphoneDeviceId?: string | null + /** Cached label, used to re-resolve a device whose id was re-salted */ + microphoneDeviceLabel?: string | null + /** Fires when the live input device disappears mid-capture */ + onCaptureLost?: () => void +} + +export type StartAudioCaptureResult = { + fellBackToDefaultMicrophone: boolean } type StopAudioCaptureOptions = { @@ -32,8 +43,12 @@ export function useAudioCapture() { const bufferedAudioSecondsRef = useRef(0) const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') + const trackLostCleanupRef = useRef<(() => void) | null>(null) const cleanupCaptureResources = useCallback(() => { + trackLostCleanupRef.current?.() + trackLostCleanupRef.current = null + processorRef.current?.disconnect() sourceRef.current?.disconnect() processorRef.current = null @@ -84,7 +99,9 @@ export function useAudioCapture() { ) const start = useCallback( - async (options: StartAudioCaptureOptions = {}) => { + async ( + options: StartAudioCaptureOptions = {} + ): Promise => { if (isCapturingRef.current) { return } @@ -96,13 +113,13 @@ export function useAudioCapture() { resetBufferedAudio() capturedChunkCountRef.current = 0 - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - } + const { stream, fellBackToDefaultMicrophone } = await openMicrophoneCaptureStream({ + preferredDeviceId: options.microphoneDeviceId, + preferredDeviceLabel: options.microphoneDeviceLabel, + getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints), + enumerateDevices: navigator.mediaDevices?.enumerateDevices + ? () => navigator.mediaDevices.enumerateDevices() + : undefined }) if (startRequestRef.current !== startRequest) { stream.getTracks().forEach((track) => track.stop()) @@ -178,6 +195,24 @@ export function useAudioCapture() { processorRef.current = processor sourceRef.current = source isCapturingRef.current = true + + // Why: unplugging the input ends the track without ending the graph — the + // processor keeps feeding zeros, so dictation looks live while capturing nothing. + const onCaptureLost = options.onCaptureLost + const audioTrack = stream.getAudioTracks()[0] + if (onCaptureLost && audioTrack) { + const handleTrackEnded = (): void => { + if (startRequestRef.current !== startRequest || !isCapturingRef.current) { + return + } + onCaptureLost() + } + audioTrack.addEventListener('ended', handleTrackEnded) + trackLostCleanupRef.current = () => { + audioTrack.removeEventListener('ended', handleTrackEnded) + } + } + return { fellBackToDefaultMicrophone } } catch (err) { processor?.disconnect() source?.disconnect() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7e276012c36..3f4429d0c70 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9145,7 +9145,13 @@ "20574cbc72": "Enable Voice Dictation", "322d457a0d": "transcription", "dcc7846641": "Configure the OpenAI API key used for cloud speech-to-text models.", - "ebfd0b32e5": "OpenAI Transcription" + "ebfd0b32e5": "OpenAI Transcription", + "microphoneTitle": "Microphone", + "microphoneDescription": "Choose which input device voice dictation uses.", + "micInput": "input", + "micDevice": "device", + "micAirpods": "airpods", + "micDefault": "system default" } } }, @@ -10136,6 +10142,14 @@ "webUnavailable": "Switching builds is only available in the desktop app.", "devChannelMacOnlyAria": "{{value0}} (macOS only)", "devChannelMacOnly": "{{value0}} builds are produced only for macOS. Linux and Windows stay on Stable or RC." + }, + "VoiceMicrophoneSetting": { + "systemDefault": "System default", + "unavailable": "unavailable", + "label": "Microphone", + "description": "Input device used for voice dictation. System default follows the OS microphone setting.", + "accessHint": "Allow microphone access to list input devices.", + "allowAccess": "Allow access" } }, "right": { @@ -13328,7 +13342,9 @@ "55127a3706": "Dictation failed: {{value0}}", "bb7f599ee7": "Open Settings", "2d5b9fabf9": "Microphone access denied. Grant access in system settings, then restart Orca.", - "5d2c3e7ae3": "No speech detected." + "5d2c3e7ae3": "No speech detected.", + "micFallback": "Selected microphone unavailable. Using system default.", + "micDisconnected": "Microphone disconnected. Dictation stopped." }, "DictationIndicator": { "335e1bc6cb": "Stop dictation" diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 279296834e4..45afce66118 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -402,7 +402,9 @@ export function getDefaultVoiceSettings(): VoiceSettings { dictationMode: 'toggle' as const, terminalConfirmBeforeInsert: false, userModels: [], - openAiApiKeyConfigured: false + openAiApiKeyConfigured: false, + microphoneDeviceId: null, + microphoneDeviceLabel: null } } diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index 93e1989dbfc..1cf42f1d6e1 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -75,4 +75,8 @@ export type VoiceSettings = { terminalConfirmBeforeInsert: boolean userModels: UserModelConfig[] openAiApiKeyConfigured: boolean + /** null = system default input device */ + microphoneDeviceId: string | null + /** Cached label for display when the preferred device is unplugged */ + microphoneDeviceLabel: string | null } diff --git a/tests/e2e/voice-microphone-selection.spec.ts b/tests/e2e/voice-microphone-selection.spec.ts new file mode 100644 index 00000000000..54f3edeb37a --- /dev/null +++ b/tests/e2e/voice-microphone-selection.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type FakeMicrophoneDevice = { + deviceId: string + label: string +} + +type FakeMicrophoneState = { + devices: FakeMicrophoneDevice[] + dispatchDeviceChange: () => void +} + +async function installFakeMicrophoneDevices( + page: Parameters[0], + devices: FakeMicrophoneDevice[] +): Promise { + await page.addInitScript((initialDevices) => { + const listeners = new Set() + const state: FakeMicrophoneState = { + devices: initialDevices, + dispatchDeviceChange: () => { + for (const listener of listeners) { + listener(new Event('devicechange')) + } + } + } + const mediaDevices = { + enumerateDevices: async () => + state.devices.map((device) => ({ + ...device, + kind: 'audioinput' as const, + groupId: '' + })), + getUserMedia: async () => { + throw new DOMException('E2E microphone access is not used by this spec', 'NotAllowedError') + }, + addEventListener: (type: string, listener: EventListener) => { + if (type === 'devicechange') { + listeners.add(listener) + } + }, + removeEventListener: (type: string, listener: EventListener) => { + if (type === 'devicechange') { + listeners.delete(listener) + } + } + } + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: mediaDevices + }) + ;( + window as Window & { __orcaE2EFakeMicrophone?: FakeMicrophoneState } + ).__orcaE2EFakeMicrophone = state + }, devices) +} + +async function prepareVoiceSettings( + page: Parameters[0], + microphoneDeviceId: string | null, + microphoneDeviceLabel: string | null +): Promise { + await page.evaluate( + async ({ microphoneDeviceId, microphoneDeviceLabel }) => { + const store = window.__store + const settings = await window.api.settings.get() + if (!store || !settings.voice) { + throw new Error('Voice settings are not available') + } + await store.getState().updateSettings({ + uiLanguage: 'en', + voice: { + ...settings.voice, + enabled: true, + microphoneDeviceId, + microphoneDeviceLabel + } + }) + store.getState().openSettingsTarget({ pane: 'voice', repoId: null }) + store.getState().openSettingsPage() + }, + { microphoneDeviceId, microphoneDeviceLabel } + ) + await expect(page.getByPlaceholder('Search settings')).toBeVisible() + const featureTipDialog = page.getByRole('dialog', { name: 'Voice Dictation is here' }) + if (await featureTipDialog.isVisible().catch(() => false)) { + await page.getByRole('button', { name: 'Maybe Later' }).click() + } + await expect(page.getByRole('heading', { name: 'Voice', exact: true })).toBeVisible() +} + +async function readMicrophoneSettings( + page: Parameters[0] +): Promise<{ deviceId: string | null; label: string | null }> { + return page.evaluate(async () => { + const voice = (await window.api.settings.get()).voice + return { + deviceId: voice?.microphoneDeviceId ?? null, + label: voice?.microphoneDeviceLabel ?? null + } + }) +} + +test.describe('Voice microphone selection', () => { + test('lists devices, persists a selected microphone, and restores it', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await installFakeMicrophoneDevices(orcaPage, [ + { deviceId: 'built-in', label: 'Built-in Microphone' }, + { deviceId: 'usb-mic', label: 'USB Microphone' } + ]) + await orcaPage.reload({ waitUntil: 'domcontentloaded' }) + await waitForSessionReady(orcaPage) + await prepareVoiceSettings(orcaPage, null, null) + + const microphone = orcaPage.getByRole('combobox', { name: 'Microphone' }) + await expect(microphone).toHaveText('System default') + await microphone.click() + await expect(orcaPage.getByRole('option', { name: 'USB Microphone' })).toBeVisible() + await orcaPage.getByRole('option', { name: 'USB Microphone' }).click() + + await expect + .poll(() => readMicrophoneSettings(orcaPage), { + message: 'selected microphone did not persist' + }) + .toEqual({ deviceId: 'usb-mic', label: 'USB Microphone' }) + await expect(microphone).toHaveText('USB Microphone') + + await orcaPage.reload({ waitUntil: 'domcontentloaded' }) + await waitForSessionReady(orcaPage) + await prepareVoiceSettings(orcaPage, 'usb-mic', 'USB Microphone') + await expect(orcaPage.getByRole('combobox', { name: 'Microphone' })).toHaveText( + 'USB Microphone' + ) + }) + + test('marks an unplugged device unavailable and follows a relabeled device', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await installFakeMicrophoneDevices(orcaPage, [ + { deviceId: 'built-in', label: 'Built-in Microphone' } + ]) + await orcaPage.reload({ waitUntil: 'domcontentloaded' }) + await waitForSessionReady(orcaPage) + await prepareVoiceSettings(orcaPage, 'stale-airpods-id', 'AirPods') + + const microphone = orcaPage.getByRole('combobox', { name: 'Microphone' }) + await microphone.click() + await expect(orcaPage.getByRole('option', { name: 'AirPods (unavailable)' })).toBeVisible() + await orcaPage.keyboard.press('Escape') + + await orcaPage.evaluate(() => { + const state = (window as Window & { __orcaE2EFakeMicrophone?: FakeMicrophoneState }) + .__orcaE2EFakeMicrophone + if (!state) { + throw new Error('Fake microphone state is not available') + } + state.devices = [ + { deviceId: 'built-in', label: 'Built-in Microphone' }, + { deviceId: 'fresh-airpods-id', label: 'AirPods' } + ] + state.dispatchDeviceChange() + }) + + await expect(microphone).toHaveText('AirPods') + await microphone.click() + await expect(orcaPage.getByRole('option', { name: 'AirPods' })).toBeVisible() + await expect(orcaPage.getByRole('option', { name: 'AirPods (unavailable)' })).toHaveCount(0) + await orcaPage.keyboard.press('Escape') + await expect(readMicrophoneSettings(orcaPage)).resolves.toEqual({ + deviceId: 'stale-airpods-id', + label: 'AirPods' + }) + }) +}) From 040734b18e8ef05e088d6003fd2d39db75f141dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:56:08 -0700 Subject: [PATCH 60/64] fix(sidebar): show collapse chevron on pinned section headers (#12147) --- .../WorktreeList.lineage-child-card.test.ts | 24 +++++++++++++++++++ .../src/components/sidebar/WorktreeList.tsx | 7 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 7de4eb12f95..53d16e0d7b4 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -917,6 +917,30 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).toContain('aria-expanded="true"') }) + it('renders a collapse chevron on the pinned section header with worktrees', async () => { + setPinnedFixtureState() + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('Pinned') + expect(markup).toContain('data-workspace-pin-drop-target=""') + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="true"') + }) + + it('renders collapsed pinned section header affordance state', async () => { + setPinnedFixtureState() + mockStore.state = { + ...mockStore.state, + collapsedGroups: new Set(['pinned']) + } + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('data-workspace-pin-drop-target=""') + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="false"') + expect(markup).toContain('-rotate-90') + }) + it('renders a collapse chevron on grouped repo headers with worktrees', async () => { setLineageFixtureState('repo') const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 8cc18bb43f3..e21ccdd01e9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -4196,10 +4196,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectGroupPathStatus.reason === 'ambiguous-connection') const projectGroupDepth = row.projectGroupDepth ?? 0 const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project and status headers share compact section chrome; flat "All" stays a simple label. + // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. const showHeaderCollapseAffordance = row.count > 0 && - (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null) + (isRepoHeader || + isProjectGroupHeader || + headerWorkspaceStatus !== null || + isPinnedHeader) // Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent. const headerPaddingLeft = isRepoHeader || isProjectGroupHeader From ccb5850390536d608a10db58351cdb3b9152c552 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:01:49 -0700 Subject: [PATCH 61/64] fix(cmd-j): center palette row icons on the first text line (#12144) The leading-icon gutter used self-start with a hand-tuned pt-0.5 nudge, leaving status dots ~2px and lucide icons ~1px above the 20px title line box. Give the gutter h-5 to match the line box so icons center on the first line for single- and two-line rows alike. --- src/renderer/src/components/WorktreeJumpPalette.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9eee43a6a4b..c85a2d700e7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1838,7 +1838,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -1966,7 +1966,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2013,7 +2013,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2054,7 +2054,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2135,7 +2135,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2213,7 +2213,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
From 1685b96a9752108b0899678f8381852dc7529953 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:06:11 -0700 Subject: [PATCH 62/64] fix(workspace-cleanup): read reflog timestamps to avoid git maintenance (#12131) * fix(workspace-cleanup): read reflog timestamps to avoid git maintenance Workspace activity detection now reads the reflog to find the newest HEAD movement, avoiding false activity signals from `git gc` and `git status` restamping logs/HEAD. Extraction of git mtime probes to exclude files that maintenance rewrites (gitdir, index, logs/HEAD), and instead read commit markers (COMMIT_EDITMSG, ORIG_HEAD) and reflog entry timestamps. Expands the scan with a renderer-side activity estimate to reconcile against the Resource Manager button's fast count. Adds deletion phase tracking (queued vs deleting) and a mismatch notice when the two counts diverge. * fix(workspace-cleanup): parse reflog timestamps with fewer digits and im - Regex now accepts 1-11 digit timestamps (was 9-11); trailing timezone anchor makes digit-count floor unnecessary - Add `removalInFlight` state to prevent duplicate removal batches; UI checks this flag alongside `removalProgress` - Filter scan errors by selected repos; only show estimate-mismatch notice when scan is complete and error-free - Mark candidate rows as non-selectable while deleting, even if `removing` flag is omitted --- .../ipc/workspace-cleanup-activity.test.ts | 100 +++++++++++++--- src/main/ipc/workspace-cleanup-activity.ts | 53 ++++++--- src/main/ipc/workspace-cleanup-scan.ts | 2 +- .../status-bar/ResourceUsageStatusSegment.tsx | 21 +--- .../WorkspaceCleanupDialog.tsx | 110 ++++++++++++++---- .../inactive-workspace-estimate.test.ts | 69 +++++++++++ .../inactive-workspace-estimate.ts | 37 ++++++ .../workspace-cleanup-candidate-row.test.tsx | 36 ++++++ .../workspace-cleanup-candidate-row.tsx | 29 ++++- src/renderer/src/i18n/locales/en.json | 14 ++- src/renderer/src/i18n/locales/zh.json | 14 ++- .../claude-background-task-status.test.ts | 22 ++++ src/shared/workspace-cleanup.ts | 10 ++ 13 files changed, 445 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index 72f20291f4a..bfb1c080db3 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -55,17 +55,18 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { expect(worktree.lastActivityAt).toBe(20_000) }) - it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + it('uses linked worktree commit metadata when the .git pointer is stale', async () => { const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') const gitDirHeadPath = path.join(gitDirPath, 'HEAD') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') + const origHeadPath = path.join(gitDirPath, 'ORIG_HEAD') const statPath = vi.fn(async (targetPath: string) => { const mtimes: Record = { '/repo-feature': 10_000, [path.join('/repo-feature', '.git')]: 20_000, - [gitDirPath]: 50_000, [gitDirHeadPath]: 60_000, - [gitDirLogsHeadPath]: 70_000 + [origHeadPath]: 65_000, + [commitEditMsgPath]: 70_000 } return { mtimeMs: mtimes[targetPath] ?? 0 } }) @@ -79,17 +80,86 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { ) expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) - expect(statPath).toHaveBeenCalledWith(gitDirPath) expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) + expect(statPath).toHaveBeenCalledWith(origHeadPath) expect(worktree.lastActivityAt).toBe(70_000) }) + it('ignores gitdir metadata that git maintenance and git status restamp', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 10_000, + [gitDirPath]: 90_000, + [path.join(gitDirPath, 'index')]: 90_000, + [path.join(gitDirPath, 'logs', 'HEAD')]: 90_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).not.toHaveBeenCalledWith(gitDirPath) + expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'index')) + expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'logs', 'HEAD')) + expect(worktree.lastActivityAt).toBe(10_000) + }) + + it('reads the newest reflog entry timestamp instead of the reflog file mtime', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const reflogPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + const readTextFile = vi.fn(async (targetPath: string) => + targetPath === reflogPath + ? [ + '0000 1111 Dev 1700000000 -0700\tbranch: Created from HEAD', + '1111 2222 Dev 1700000900 -0700\tcommit: work', + '' + ].join('\n') + : `gitdir: ${gitDirPath}\n` + ) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(reflogPath) + expect(worktree.lastActivityAt).toBe(1_700_000_900_000) + }) + + it('degrades to other probes when the reflog was expired to an empty file', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + const readTextFile = vi.fn(async (targetPath: string) => + targetPath === path.join(gitDirPath, 'logs', 'HEAD') ? '' : `gitdir: ${gitDirPath}\n` + ) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(worktree.lastActivityAt).toBe(10_000) + }) + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') const statPath = vi.fn(async (targetPath: string) => ({ - mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + mtimeMs: targetPath === commitEditMsgPath ? 40_000 : 10_000 })) const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') @@ -100,7 +170,7 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { readTextFile ) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) expect(worktree.lastActivityAt).toBe(40_000) }) @@ -108,14 +178,13 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` const gitDirHeadPath = path.join(gitDirPath, 'HEAD') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') const statPath = vi.fn(async (targetPath: string) => { const mtimes: Record = { [worktreePath]: 10_000, [path.join(worktreePath, '.git')]: 20_000, - [gitDirPath]: 50_000, [gitDirHeadPath]: 60_000, - [gitDirLogsHeadPath]: 70_000 + [commitEditMsgPath]: 70_000 } return { mtimeMs: mtimes[targetPath] ?? 0 } }) @@ -129,10 +198,11 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { ) expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) - expect(statPath).toHaveBeenCalledWith(gitDirPath) expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) - expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) + expect(statPath).not.toHaveBeenCalledWith( + path.join('/home/me/repo/.git/worktrees/repo-feature', 'HEAD') + ) expect(worktree.lastActivityAt).toBe(70_000) }) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 9026b4464d9..56ac36f6b29 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,20 +1,13 @@ import { lstat, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo, Worktree } from '../../shared/types' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> type ReadTextFile = (targetPath: string) => Promise -export function getPersistedWorkspaceCleanupActivityAt( - worktree: Pick -): number { - const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 - const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 - return Math.max(persistedActivityAt, createdAt) -} - export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) if (persistedActivityAt <= worktree.lastActivityAt) { @@ -56,7 +49,7 @@ async function resolveWorkspaceCleanupActivityAt( return persistedActivityAt } - const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + const filesystemActivityAt = await getNewestLocalWorktreeActivityAt( worktree.path, statPath, readTextFile @@ -67,26 +60,54 @@ async function resolveWorkspaceCleanupActivityAt( // Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely // report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the // persisted activity timestamp instead of blocking or mislabeling the row. -async function getNewestLocalWorktreeStatMtime( +async function getNewestLocalWorktreeActivityAt( worktreePath: string, statPath: StatPath, readTextFile: ReadTextFile ): Promise { const gitPath = path.join(worktreePath, '.git') const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) - const gitDirMtimePromises = gitDirPath + // Why: every entry here must move only on a user action. Excluded on purpose: + // the gitdir directory and logs/HEAD (restamped for every linked worktree at + // once by `git gc` / `git reflog expire`, which made one maintenance run look + // like activity everywhere), and index (rewritten by `git status` — this scan + // runs one per candidate, so it would erase the evidence it just read). + const gitDirProbes = gitDirPath ? [ - readMtime(gitDirPath, statPath), readMtime(path.join(gitDirPath, 'HEAD'), statPath), - readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + readMtime(path.join(gitDirPath, 'COMMIT_EDITMSG'), statPath), + readMtime(path.join(gitDirPath, 'ORIG_HEAD'), statPath), + readNewestReflogEntryAt(path.join(gitDirPath, 'logs', 'HEAD'), readTextFile) ] : [] - const mtimes = await Promise.all([ + const timestamps = await Promise.all([ readMtime(worktreePath, statPath), readMtime(gitPath, statPath), - ...gitDirMtimePromises + ...gitDirProbes ]) - return Math.max(0, ...mtimes) + return Math.max(0, ...timestamps) +} + +// Why: the reflog records when HEAD actually moved, so it survives the mtime +// churn that makes logs/HEAD itself unreadable as a signal. Expired reflogs are +// truncated to an empty file, which degrades to the other probes. +async function readNewestReflogEntryAt( + reflogPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const lines = (await readTextFile(reflogPath)).split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. + const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] + if (seconds) { + return Number(seconds) * 1000 + } + } + return 0 + } catch { + return 0 + } } async function readLocalWorktreeGitDir( diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index f5deb16a2bd..32794f61977 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -13,8 +13,8 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' import { - getPersistedWorkspaceCleanupActivityAt, resolvePersistedWorkspaceCleanupActivityWorktree, resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index de06fcac580..a8ece07593d 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -34,8 +34,7 @@ import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions import type { AppMemory, BrowserWorkspace, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { isFolderRepo } from '../../../../shared/repo-kind' -import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup' +import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { Metric, @@ -917,20 +916,10 @@ export function ResourceUsageStatusSegment({ [allWorktrees] ) - const oldWorkspaceCount = useMemo(() => { - const now = Date.now() - let count = 0 - for (const worktree of allWorktrees) { - const repo = repoById.get(worktree.repoId) - if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { - continue - } - if (isWorkspaceOldForCleanup(worktree, now)) { - count += 1 - } - } - return count - }, [allWorktrees, repoById]) + const oldWorkspaceCount = useMemo( + () => countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now()), + [allWorktrees, repoById] + ) // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy. const unifiedRepos = useMemo( diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 1bfe02aebbf..0c870ee4485 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, + Info, Loader2, RefreshCcw, Search, @@ -68,7 +69,8 @@ import { startWorkspaceCleanupBackgroundRemoval, type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' -import { CandidateRow } from './workspace-cleanup-candidate-row' +import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' +import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { getCandidateStatus, @@ -198,15 +200,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) - const deletingWorktreeIds = useAppStore( - useShallow( - (s) => - new Set( - Object.entries(s.deleteStateByWorktreeId) - .filter(([, state]) => state.isDeleting) - .map(([worktreeId]) => worktreeId) - ) - ) + const deletionPhaseByWorktreeId = useAppStore( + useShallow((s) => { + const phases: Record = {} + for (const [worktreeId, state] of Object.entries(s.deleteStateByWorktreeId)) { + if (state.isDeleting) { + phases[worktreeId] = state.phase ?? 'deleting' + } + } + return phases + }) + ) + const deletingWorktreeIds = useMemo( + () => new Set(Object.keys(deletionPhaseByWorktreeId)), + [deletionPhaseByWorktreeId] ) const open = activeModal === 'workspace-cleanup' @@ -220,6 +227,9 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const [removalProgress, setRemovalProgress] = useState( null ) + // Why: `removalProgress` only arrives once the batch reports, so rendering + // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. + const [removalInFlight, setRemovalInFlight] = useState(false) const [rowFailures, setRowFailures] = useState>({}) const [repoSelection, setRepoSelection] = useState>(() => new Set()) const [filters, setFilters] = useState(DEFAULT_FILTERS) @@ -342,6 +352,10 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } return new Set(eligibleRepoIds) }, [eligibleRepoIds, repoSelection]) + const selectedScanErrors = useMemo( + () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), + [effectiveRepoSelection, scan?.errors] + ) const filteredCandidates = useMemo(() => { if ( effectiveRepoSelection.size === 0 || @@ -351,6 +365,35 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } return candidates.filter((candidate) => effectiveRepoSelection.has(candidate.repoId)) }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) + // Why: the Resource Manager button counts from the renderer's activity record only, + // so it routinely disagrees with the scanned list. Reconcile it rather than leaving + // the user to wonder which number is real. + const estimatedInactiveCount = useMemo(() => { + if (!open) { + return null + } + return countEstimatedInactiveWorkspaces( + Object.values(reviewStateInputs.worktreesByRepo).flat(), + new Map(reviewStateInputs.repos.map((repo) => [repo.id, repo])), + Date.now() + ) + }, [open, reviewStateInputs]) + // Why: the reconciliation only holds for a complete scan; a failed or partial + // one would blame the difference on git-history checks instead of the error. + const estimateMismatchNotice = + !loading && + !error && + selectedScanErrors.length === 0 && + scan && + estimatedInactiveCount !== null && + estimatedInactiveCount !== candidates.length && + filteredCandidates.length === candidates.length + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.f637f63882', + "Resource Manager counts {{value0}}; this list found {{value1}}. That counter reads Orca's activity record alone, while this scan also checks each workspace's git history and skips disconnected remotes.", + { value0: estimatedInactiveCount, value1: candidates.length } + ) + : null useEffect(() => { if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { @@ -407,10 +450,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), [repos] ) - const selectedScanErrors = useMemo( - () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), - [effectiveRepoSelection, scan?.errors] - ) const scanNoticeMessage = useMemo( () => formatScanNoticeMessage(selectedScanErrors, repoNameById), [repoNameById, selectedScanErrors] @@ -530,7 +569,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { // instances from re-rendering on scan stream-in and selection changes. const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - if (loading) { + if (loading || removalInFlightRef.current) { return } setSelectedIds(new Set([candidate.worktreeId])) @@ -557,6 +596,15 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setConfirmCandidates([]) }, [closeModal, removalProgress]) + // Why: the header X reads as "leave this screen", not "abandon the dialog". The batch + // keeps running in the background either way, and the list shows each row's progress. + // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here + // removalProgress stays set until the batch settles, so re-entry is still blocked. + const backToWorkspaceCleanupList = useCallback(() => { + setConfirming(false) + setConfirmCandidates([]) + }, []) + const clearQueuedDeleteState = useCallback( (worktreeId: string) => { const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] @@ -597,6 +645,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { return } removalInFlightRef.current = true + setRemovalInFlight(true) removalBatchIdRef.current += 1 const removalBatchId = removalBatchIdRef.current // Why: a hung late settlement retains these callbacks for the renderer's @@ -625,6 +674,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setRowFailures(nextFailures) deselectRemovedIds(result.removedIds) setRemovalProgress(null) + setRemovalInFlight(false) setConfirming(false) setConfirmCandidates([]) } @@ -657,6 +707,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } if (mountedRef.current) { setRemovalProgress(null) + setRemovalInFlight(false) setConfirming(false) setConfirmCandidates([]) } @@ -779,7 +830,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { variant="destructive" size="sm" onClick={() => openConfirmRemove(selectedCandidates)} - disabled={selectedCount === 0 || loading} + // Why: leaving the progress view no longer closes the dialog, so the + // list is reachable mid-batch; a second batch would silently no-op. + disabled={ + selectedCount === 0 || loading || removalProgress !== null || removalInFlight + } > {translate( @@ -819,6 +874,13 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
) : null} + {estimateMismatchNotice ? ( +
+ + {estimateMismatchNotice} +
+ ) : null} +
1 && index === activeRows.length - 1} expanded={expandedRowIds.has(candidate.worktreeId)} lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} + deletionPhase={deletionPhaseByWorktreeId[candidate.worktreeId]} + removing={ + loading || + removalInFlight || + deletingWorktreeIds.has(candidate.worktreeId) + } selected={ selectedIds.has(candidate.worktreeId) && !loading && @@ -952,6 +1019,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { candidates={confirmCandidates} reviewInfoByWorktreeId={reviewInfoByWorktreeId} progress={removalProgress} + onBack={backToWorkspaceCleanupList} onCancel={cancelConfirmRemove} onConfirm={confirmRemove} /> @@ -1275,12 +1343,14 @@ function ConfirmRemove({ candidates, reviewInfoByWorktreeId, progress, + onBack, onCancel, onConfirm }: { candidates: WorkspaceCleanupCandidate[] reviewInfoByWorktreeId: ReadonlyMap progress: WorkspaceCleanupRemovalProgress | null + onBack: () => void onCancel: () => void onConfirm: () => void }): React.JSX.Element { @@ -1332,10 +1402,10 @@ function ConfirmRemove({ variant="ghost" size="icon-sm" aria-label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.191f0bc98e', - 'Close' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.74f6c16279', + 'Back' )} - onClick={onCancel} + onClick={onBack} > diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts new file mode 100644 index 00000000000..e2546210158 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { Repo, Worktree } from '../../../../shared/types' +import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' + +const NOW = 1_800_000_000_000 +const DAY_MS = 24 * 60 * 60 * 1000 + +const GIT_REPO = { id: 'repo-1', path: '/repo', displayName: 'Repo' } as Repo +const FOLDER_REPO = { + id: 'folder-1', + path: '/folder', + displayName: 'Folder', + kind: 'folder' +} as Repo + +const REPOS = new Map([ + [GIT_REPO.id, GIT_REPO], + [FOLDER_REPO.id, FOLDER_REPO] +]) + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo-feature', + repoId: 'repo-1', + path: '/repo-feature', + isMainWorktree: false, + isArchived: false, + lastActivityAt: NOW - 90 * DAY_MS, + ...overrides + } as Worktree +} + +describe('countEstimatedInactiveWorkspaces', () => { + it('counts workspaces idle past the cleanup threshold', () => { + expect(countEstimatedInactiveWorkspaces([makeWorktree()], REPOS, NOW)).toBe(1) + }) + + it('does not count a missing activity stamp as ancient', () => { + const worktrees = [ + makeWorktree({ lastActivityAt: 0 }), + makeWorktree({ lastActivityAt: Number.NaN }) + ] + + expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) + }) + + it('falls back to the creation stamp when activity was never recorded', () => { + const recent = makeWorktree({ lastActivityAt: 0, createdAt: NOW - DAY_MS }) + const old = makeWorktree({ lastActivityAt: 0, createdAt: NOW - 90 * DAY_MS }) + + expect(countEstimatedInactiveWorkspaces([recent, old], REPOS, NOW)).toBe(1) + }) + + it('skips main worktrees, folder workspaces, and unknown repos', () => { + const worktrees = [ + makeWorktree({ isMainWorktree: true }), + makeWorktree({ repoId: FOLDER_REPO.id }), + makeWorktree({ repoId: 'gone' }) + ] + + expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) + }) + + it('uses the shorter archived threshold', () => { + const worktree = makeWorktree({ isArchived: true, lastActivityAt: NOW - 10 * DAY_MS }) + + expect(countEstimatedInactiveWorkspaces([worktree], REPOS, NOW)).toBe(1) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts new file mode 100644 index 00000000000..a0103360f5f --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts @@ -0,0 +1,37 @@ +import type { Repo, Worktree } from '../../../../shared/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { + getPersistedWorkspaceCleanupActivityAt, + isWorkspaceOldForCleanup +} from '../../../../shared/workspace-cleanup' + +/** + * Renderer-side estimate of how many workspaces the cleanup scan would list. It + * sees only Orca's persisted activity record, so it disagrees with the scan by + * design — the scan additionally reads each worktree's git history and cannot + * inspect disconnected remotes. + */ +export function countEstimatedInactiveWorkspaces( + worktrees: readonly Worktree[], + repoById: ReadonlyMap, + now: number +): number { + let count = 0 + for (const worktree of worktrees) { + const repo = repoById.get(worktree.repoId) + if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { + continue + } + // Why: an unstamped workspace (externally created, or never opened here) reads as + // epoch 0, which counted every one of them as idle and inflated the estimate far + // past what the scan lists. Unknown is not old. + const lastActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if ( + lastActivityAt > 0 && + isWorkspaceOldForCleanup({ isArchived: worktree.isArchived, lastActivityAt }, now) + ) { + count += 1 + } + } + return count +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index b65e495a7ea..7de51d662cb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -62,4 +62,40 @@ describe('CandidateRow', () => { expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() }) + + it.each([ + ['deleting' as const, 'Deleting…'], + ['queued' as const, 'Queued for deletion'] + ])('replaces the status pill with the %s state', (deletionPhase, label) => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + expect(container?.textContent).toContain(label) + expect(container?.textContent).not.toContain('Ready') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index faf540f3a29..2953f46fa5a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -8,6 +8,7 @@ import { FileWarning, GitBranch, GitPullRequest, + Loader2, Search, SquareTerminal, Trash2, @@ -40,8 +41,11 @@ import { } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' +export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' + type CandidateRowProps = { candidate: WorkspaceCleanupCandidate + deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean failure?: string last: boolean @@ -99,6 +103,7 @@ function MetadataIconChip({ // prop identity changes); virtualization, not memo, bounds that cost. export const CandidateRow = React.memo(function CandidateRow({ candidate, + deletionPhase, expanded, failure, last, @@ -112,7 +117,10 @@ export const CandidateRow = React.memo(function CandidateRow({ onToggleSelected, onView }: CandidateRowProps): React.JSX.Element { - const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing + const deleting = deletionPhase !== undefined + // Why: derive from `deleting` too, so the row never offers a checkbox or + // Remove button while it is queuing/deleting, even if `removing` was omitted. + const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing && !deleting const ignored = candidate.blockers.includes('dismissed') const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) @@ -133,6 +141,7 @@ export const CandidateRow = React.memo(function CandidateRow({ className={cn( 'group w-full border-b border-border/60 px-3 py-2.5 text-left text-foreground transition-colors hover:bg-accent/40', selected && 'bg-accent/30', + deleting && 'opacity-70', last && 'border-b-0' )} > @@ -152,6 +161,8 @@ export const CandidateRow = React.memo(function CandidateRow({ > {selected ? : null} + ) : deleting ? ( + ) : (